Add rival duels, babysitting service, multilingual !define, economy tuning

Rival System:
- RPS-based duels between combat level 5+ players (best of 3, ties re-prompt)
- Stakes scale with level, split 50/50 between winner and community pot
- 3-4 day randomized challenge interval, 7-day same-pair cooldown
- 24h response window with auto-forfeit
- Full flavor text pools, character sheet integration, !adventure rivals

Babysitting Service:
- Weekly/monthly auto-play service targeting weakest skill
- Babysitter rerolls death, claims items, credits gold and XP
- Rivals automatically declined during service
- End-of-service summary with diaper report

Revive fixes:
- On-demand revive in handleMenu, parseAndResolveChoice, handleStatus
- Morning DM respawn check now runs before babysit interception
- Players no longer stuck dead after DeadUntil expires mid-day

Other changes:
- Multilingual !define searches all DreamDict languages by default
- TwinBee empty haul now shows "jackshit" instead of blank
- Wordle solve payouts increased (e.g. 1-guess: €100 -> €500)
- Per-message euro payouts doubled across all tiers
- Audit fixes: user locks on RPS/babysit handlers, TOCTOU on gold
  transfers, dead code removal, deprecated strings.Title replaced,
  error logging on silent failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-03 20:11:51 -07:00
parent 2d99af5310
commit ad6e652755
14 changed files with 1769 additions and 112 deletions

View File

@@ -611,7 +611,7 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
| Command | Description |
|---------|-------------|
| `!wiki <topic>` | Wikipedia summary |
| `!define <word>` | Dictionary definition (includes antonyms from DreamDict when available) |
| `!define <word> [lang]` | Dictionary definition — searches all languages via DreamDict by default, or specify one (en/fr/pt-PT/zh). Includes antonyms when available. Falls back to free dictionary API for English if DreamDict is unavailable. |
| `!urban <term>` | Urban Dictionary |
| `!translate <word> [lang]` | Cross-language word lookup (en/fr/pt-PT, auto-detects source) |
| `!antonym <word> [lang]` | Antonyms for a word (en/fr/pt-PT/zh) |

View File

@@ -74,6 +74,11 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_inventory ADD COLUMN slot TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_inventory ADD COLUMN skill_source TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE user_stats ADD COLUMN fancy_words INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN rival_pool INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN rival_unlocked_notified INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_active INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_expires_at DATETIME`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_skill_focus TEXT NOT NULL DEFAULT ''`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -1040,6 +1045,50 @@ CREATE TABLE IF NOT EXISTS arena_stats (
updated_at INTEGER NOT NULL
);
-- Rival System
CREATE TABLE IF NOT EXISTS adventure_rival_records (
user_id TEXT NOT NULL,
rival_id TEXT NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
last_duel_at DATETIME,
PRIMARY KEY (user_id, rival_id)
);
CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
challenge_id TEXT PRIMARY KEY,
challenger_id TEXT NOT NULL,
challenged_id TEXT NOT NULL,
stake INTEGER NOT NULL,
round INTEGER NOT NULL DEFAULT 1,
player_score INTEGER NOT NULL DEFAULT 0,
rival_score INTEGER NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
CREATE TABLE IF NOT EXISTS community_pot (
id INTEGER PRIMARY KEY DEFAULT 1,
balance INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Babysitting Service
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
log_date DATE NOT NULL,
activity TEXT NOT NULL,
outcome TEXT NOT NULL,
gold_earned INTEGER NOT NULL DEFAULT 0,
xp_gained INTEGER NOT NULL DEFAULT 0,
items_dropped TEXT DEFAULT NULL,
rival_refused TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_babysit_log_user ON adventure_babysit_log(user_id);
-- Forex
CREATE TABLE IF NOT EXISTS forex_rates (
currency TEXT NOT NULL,

View File

@@ -117,6 +117,7 @@ func (p *AdventurePlugin) Init() error {
go p.midnightTicker()
go p.eventTicker()
go p.arenaAutoCashoutTicker()
go p.rivalChallengeTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
@@ -199,6 +200,10 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleEventRespond(ctx)
case lower == "help":
return p.SendDM(ctx.Sender, advHelpText)
case lower == "rivals":
return p.handleRivalsCmd(ctx)
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
}
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
@@ -216,6 +221,8 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
` + "`!adventure help`" + ` — This message
**Arena:**
@@ -237,10 +244,24 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
if !char.Alive {
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
text := renderAdvRespawnDM(char)
p.SendDM(ctx.Sender, text)
// Fall through to show menu
}
}
if !char.Alive {
text := renderAdvDeathStatusDM(char)
return p.SendDM(ctx.Sender, text)
}
}
if char.ActionTakenToday {
// On holidays, allow second action if not yet taken
@@ -284,9 +305,29 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
}
equip, _ := loadAdvEquipment(ctx.Sender)
items, _ := loadAdvInventory(ctx.Sender)
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
// On-demand revive if death timer has expired
if !char.Alive && char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
}
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load equipment for status", "user", ctx.Sender, "err", err)
}
items, err := loadAdvInventory(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load inventory for status", "user", ctx.Sender, "err", err)
}
treasures, err := loadAdvTreasureBonuses(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load treasures for status", "user", ctx.Sender, "err", err)
}
balance := p.euro.GetBalance(ctx.Sender)
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
@@ -453,6 +494,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.handleMasterworkEquipReply(ctx, interaction)
case "masterwork_equip_confirm":
return p.handleMasterworkEquipConfirm(ctx, interaction)
case "rival_rps":
return p.resolveRivalRPSRound(ctx, interaction)
}
return nil
}
@@ -498,9 +541,21 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
return nil // not a registered player
}
if !char.Alive {
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
}
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
}
if char.ActionTakenToday {
// On holidays, allow second action if not yet taken
@@ -665,6 +720,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
// Check level up
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
if result.LeveledUp && result.XPSkill == "combat" {
p.checkRivalPoolUnlock(char)
}
// Handle death
deathReprieved := false

View File

@@ -369,6 +369,9 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
}
char.CombatXP += battleXP
leveled, newLevel := checkAdvLevelUp(char, "combat")
if leveled {
p.checkRivalPoolUnlock(char)
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err)
}
@@ -538,7 +541,9 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
now := time.Now().UTC()
char.ArenaLosses++
char.CombatXP += arenaParticipationXP // +60 flat participation XP
checkAdvLevelUp(char, "combat")
if leveled, _ := checkAdvLevelUp(char, "combat"); leveled {
p.checkRivalPoolUnlock(char)
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
}

View File

@@ -0,0 +1,455 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Pricing ─────────────────────────────────────────────────────────────────
func babysitDailyCost(combatLevel int) int {
return 100 + (combatLevel * 20)
}
// ── Weakest Skill ───────────────────────────────────────────────────────────
func babysitWeakestSkill(char *AdventureCharacter) string {
skills := []struct {
name string
level int
}{
{"mining", char.MiningSkill},
{"fishing", char.FishingSkill},
{"foraging", char.ForagingSkill},
}
minLevel := skills[0].level
for _, s := range skills[1:] {
if s.level < minLevel {
minLevel = s.level
}
}
// Collect ties
var tied []string
for _, s := range skills {
if s.level == minLevel {
tied = append(tied, s.name)
}
}
return tied[rand.IntN(len(tied))]
}
// skillToActivity maps a skill name to its activity type.
func skillToActivity(skill string) AdvActivityType {
switch skill {
case "mining":
return AdvActivityMining
case "fishing":
return AdvActivityFishing
case "foraging":
return AdvActivityForaging
}
return AdvActivityMining
}
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) error {
lower := strings.ToLower(strings.TrimSpace(args))
switch {
case lower == "status":
return p.handleBabysitStatus(ctx)
case lower == "cancel":
return p.handleBabysitCancel(ctx)
case lower == "week":
return p.handleBabysitPurchase(ctx, 7)
case lower == "month":
return p.handleBabysitPurchase(ctx, 30)
default:
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+
"`!adventure babysit status` — check service status\n"+
"`!adventure babysit cancel` — cancel early (no refund)")
}
}
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
}
if char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
}
daily := babysitDailyCost(char.CombatLevel)
totalCost := daily * days
balance := p.euro.GetBalance(char.UserID)
if balance < float64(totalCost) {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs €%d for %d days. You have €%.0f. The service has standards. Not many, but some.", totalCost, days, balance))
}
// Debit gold
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
}
// Set babysit fields
skill := babysitWeakestSkill(char)
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
char.BabysitActive = true
char.BabysitExpiresAt = &expires
char.BabysitSkillFocus = skill
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
// Refund
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
}
confirm := pickBabysitFlavor(babysitConfirmLines)
durLabel := "1 week"
if days == 30 {
durLabel = "1 month"
}
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
"Duration: %s (%d days)\n"+
"Cost: €%d\n"+
"Focus: %s (currently level %d)\n"+
"Rival duels: declined on your behalf\n\n"+
"Daily DMs are suspended until the service ends.\n\n"+
"_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 No active babysitting service. Use `!adventure babysit week` or `!adventure babysit month` to start.")
}
remaining := "unknown"
if char.BabysitExpiresAt != nil {
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
if days < 1 {
remaining = "less than a day"
} else {
remaining = fmt.Sprintf("%d days", days)
}
}
// Load log stats
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
"Time remaining: %s\n"+
"Skill focus: %s\n"+
"Days completed: %d\n"+
"Gold earned: €%d\n"+
"XP gained: %d\n"+
"Items claimed by babysitter: %d\n"+
"Rivals declined: %d",
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
}
// Compile partial summary
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(char, logs)
// Clear babysit state
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err)
}
// Clear logs
clearBabysitLogs(char.UserID)
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
}
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
activity := skillToActivity(char.BabysitSkillFocus)
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
return
}
// Pick highest-tier eligible location for the focus skill
bonuses := &AdvBonusSummary{}
eligible := advEligibleLocations(char, equip, activity, bonuses)
if len(eligible) == 0 {
slog.Warn("babysit: no eligible locations", "user", char.UserID, "skill", char.BabysitSkillFocus)
return
}
// Pick the last one (highest tier since they're returned in order)
loc := eligible[len(eligible)-1].Location
inPenalty := eligible[len(eligible)-1].InPenaltyZone
// Resolve action
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
// Babysitter never lets the adventurer die — reroll death to empty
if result.Outcome == AdvOutcomeDeath {
result.Outcome = AdvOutcomeEmpty
result.LootItems = nil
result.TotalLootValue = 0
result.EquipDamage = nil
result.EquipBroken = nil
}
// Apply XP
switch result.XPSkill {
case "mining":
char.MiningXP += result.XPGained
case "foraging":
char.ForagingXP += result.XPGained
case "fishing":
char.FishingXP += result.XPGained
}
checkAdvLevelUp(char, result.XPSkill)
// Credit gold to player
if result.TotalLootValue > 0 {
p.euro.Credit(char.UserID, float64(result.TotalLootValue), "babysit_haul")
}
// Items are claimed by the babysitter (not added to player inventory)
var itemNames []string
for _, item := range result.LootItems {
itemNames = append(itemNames, item.Name)
}
// No treasure drops during babysitting
result.TreasureFound = nil
// Mark action taken
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err)
}
// Log to babysit table
itemsJSON := ""
if len(itemNames) > 0 {
itemsJSON = strings.Join(itemNames, ", ")
}
logBabysitActivity(char.UserID, string(activity), string(result.Outcome),
int(result.TotalLootValue), result.XPGained, itemsJSON)
}
// ── Expiry Check ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
now := time.Now().UTC()
for _, char := range chars {
if !char.BabysitActive {
continue
}
if char.BabysitExpiresAt == nil || now.Before(*char.BabysitExpiresAt) {
continue
}
// Service expired — compile summary and send DM
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(&char, logs)
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(&char); err != nil {
slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err)
continue
}
clearBabysitLogs(char.UserID)
if err := p.SendDM(char.UserID, summary); err != nil {
slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err)
}
}
}
// ── Summary Rendering ───────────────────────────────────────────────────────
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
var sb strings.Builder
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs)))
sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs)))
sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus)))
sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold))
sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP))
sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed))
if rivalsRefused > 0 {
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
// Pick a rival refusal flavor (generic — no specific rival name available)
for _, log := range logs {
if log.RivalRefused != "" {
line := pickBabysitFlavor(babysitRivalRefusalLines)
sb.WriteString(fmt.Sprintf(" %s\n", fmt.Sprintf(line, log.RivalRefused, log.LogDate)))
}
}
}
// Diaper line
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
// Closing
sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus))
return sb.String()
}
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
type babysitLogEntry struct {
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
}
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, gold_earned, xp_gained, items_dropped)
VALUES (?, DATE('now'), ?, ?, ?, ?, ?)`,
string(userID), activity, outcome, gold, xp, items)
if err != nil {
slog.Error("babysit: failed to log activity", "user", userID, "err", err)
}
}
func logBabysitRivalRefusal(userID id.UserID, rivalName string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, rival_refused)
VALUES (?, DATE('now'), 'rival_refused', 'declined', ?)`,
string(userID), rivalName)
if err != nil {
slog.Error("babysit: failed to log rival refusal", "user", userID, "err", err)
}
}
func loadBabysitLogs(userID id.UserID) ([]babysitLogEntry, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, user_id, log_date, activity, outcome, gold_earned, xp_gained,
COALESCE(items_dropped,''), COALESCE(rival_refused,'')
FROM adventure_babysit_log WHERE user_id = ? ORDER BY log_date`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var logs []babysitLogEntry
for rows.Next() {
var l babysitLogEntry
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.Activity, &l.Outcome,
&l.GoldEarned, &l.XPGained, &l.ItemsDropped, &l.RivalRefused); err != nil {
return nil, err
}
logs = append(logs, l)
}
return logs, nil
}
func clearBabysitLogs(userID id.UserID) {
d := db.Get()
if _, err := d.Exec(`DELETE FROM adventure_babysit_log WHERE user_id = ?`, string(userID)); err != nil {
slog.Error("babysit: failed to clear logs", "user", userID, "err", err)
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
func babysitSkillLevel(char *AdventureCharacter, skill string) int {
switch skill {
case "mining":
return char.MiningSkill
case "fishing":
return char.FishingSkill
case "foraging":
return char.ForagingSkill
}
return 0
}
func babysitLogStats(logs []babysitLogEntry) (totalGold, totalXP, itemsClaimed, rivalsRefused int) {
for _, l := range logs {
totalGold += l.GoldEarned
totalXP += l.XPGained
if l.ItemsDropped != "" {
// Count comma-separated items
itemsClaimed += len(strings.Split(l.ItemsDropped, ", "))
}
if l.RivalRefused != "" {
rivalsRefused++
}
}
return
}

View File

@@ -51,6 +51,11 @@ type AdventureCharacter struct {
LastActiveAt time.Time
DeathReprieveLast *time.Time
MasterworkDropsReceived int
RivalPool int
RivalUnlockedNotified bool
BabysitActive bool
BabysitExpiresAt *time.Time
BabysitSkillFocus string
}
type AdvEquipment struct {
@@ -289,8 +294,8 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
d := db.Get()
c := &AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil, reprieveLast sql.NullTime
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp sql.NullTime
err := d.QueryRow(`
SELECT user_id, display_name,
@@ -300,7 +305,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -310,6 +317,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
)
if err != nil {
return nil, err
@@ -317,12 +326,17 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
return c, nil
}
@@ -369,6 +383,14 @@ func saveAdvCharacter(char *AdventureCharacter) error {
if char.HolidayActionTaken {
holidayTaken = 1
}
rivalUnlocked := 0
if char.RivalUnlockedNotified {
rivalUnlocked = 1
}
babysitAct := 0
if char.BabysitActive {
babysitAct = 1
}
_, err := d.Exec(`
UPDATE adventure_characters SET
@@ -378,14 +400,19 @@ func saveAdvCharacter(char *AdventureCharacter) error {
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
masterwork_drops_received = ?
masterwork_drops_received = ?,
rival_pool = ?, rival_unlocked_notified = ?,
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?
WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
alive, char.DeadUntil, actionTaken, holidayTaken,
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
char.DeathReprieveLast, char.MasterworkDropsReceived, string(char.UserID),
char.DeathReprieveLast, char.MasterworkDropsReceived,
char.RivalPool, rivalUnlocked,
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
string(char.UserID),
)
return err
}
@@ -499,7 +526,9 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus
FROM adventure_characters`)
if err != nil {
return nil, err
@@ -509,8 +538,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
var chars []AdventureCharacter
for rows.Next() {
c := AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil, reprieveLast sql.NullTime
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp sql.NullTime
if err := rows.Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -520,18 +549,25 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
); err != nil {
return nil, err
}
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
chars = append(chars, c)
}
return chars, rows.Err()

View File

@@ -0,0 +1,37 @@
package plugin
import "math/rand/v2"
// ── Babysitting Service Flavor Pools ────────────────────────────────────────
// babysitConfirmLines are shown on purchase confirmation.
var babysitConfirmLines = []string{
"Your adventurer is now in capable hands. Relatively speaking. We've done this before.",
"Service begins immediately. Go do whatever it is you do when you're not doing this.",
"Payment received. Your adventurer will be fine. Probably.",
"We've looked at your skill levels. We know what needs work. We'll handle it. You handle whatever you're handling.",
}
// babysitRivalRefusalLines are used when a rival showed up during babysitting.
// All lines use exactly two %s: (rival display name, date string).
var babysitRivalRefusalLines = []string{
"%s came by on %s with what appeared to be prepared remarks. They were turned away at the door. Their remarks went undelivered. This is probably for the best.",
"%s attempted to initiate a duel on %s. The babysitter informed them this was not possible. They stood there for a moment. Then left.",
"%s showed up on %s. The babysitter made them regret it in ways you could only dream of doing.",
}
// babysitDiaperLines appear once per summary, rotated.
var babysitDiaperLines = []string{
"The diapers have been handled. We don't discuss the diapers.",
"Diaper situation: resolved. No further details will be provided.",
"Standard diaper protocols were followed. The adventurer was cooperative. Mostly.",
"The diapers. They happened. They were handled. Moving on.",
}
// pickBabysitFlavor returns a random entry from the given pool.
func pickBabysitFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}

View File

@@ -0,0 +1,105 @@
package plugin
import "math/rand/v2"
// ── Rival Trash Talk Pools ──────────────────────────────────────────────────
// rivalOpeningTaunts are delivered with the challenge DM (Round 1).
var rivalOpeningTaunts = []string{
"Your strategy is filled with more holes than your boots. This will be like taking candy from a baby. Actually I shouldn't joke about that -- it looks like that's how you sustain yourself.",
"I've seen better odds on a three-legged horse. The horse at least had a plan.",
"I want you to know I considered not crossing the street. I crossed anyway. That tells you everything about how this is going to go.",
"You look nervous. You should look nervous. That's the first sensible thing I've seen from you.",
"I've done this before. You can tell by how little I'm sweating. Look at you. Look at me. See the difference?",
"I'm not saying this will be easy. I'm saying it will be quick. There's a distinction.",
"Take your time. Think it through. It won't help but I want you to feel like you had a fair shot.",
}
// rivalRoundWon are said by the rival after they win a round.
var rivalRoundWon = []string{
"Looks like you won't be buying any tacos today. You don't look like you're used to eating real food anyway.",
"One down. The math is unkind to you right now. As is most things, I suspect.",
"Did that hurt? The losing, I mean. Or is this familiar enough to be comfortable?",
"I knew what you were going to throw before you did. I know what you're going to throw next. I won't tell you.",
"You should have thrown something else. You know that now. I knew it before you threw.",
"*takes a moment to write something down* Sorry. Just keeping notes. Continue.",
"The expression on your face right now is doing a lot of work. Most of it sad.",
}
// rivalRoundLost are said by the rival after they lose a round.
var rivalRoundLost = []string{
"Even a battered, dirty, *sniffs* smelly, and broken beyond any hope of repair clock is right twice a day.",
"Fine. You got one. Don't read into it. Actually -- you won't know what to read into. Never mind.",
"I want you to enjoy this moment. Really sit in it. It may be all you have to take home today.",
"I respect the throw. I don't respect the thrower. These are two separate things.",
"Lucky. I've seen luck before and that's what that was. Make peace with it.",
"*pauses* Okay. *pauses again* I wasn't expecting that. That changes nothing. Next round.",
"You threw the right thing at the right time. The stopped clock principle applies. Ask someone to explain it to you later.",
}
// rivalTied are said by the rival after a tied round (before re-throw).
var rivalTied = []string{
"Same throw. Neither of us embarrassed ourselves. One of us is about to rectify that.",
"We're going again. That's fine. I have nowhere to be. Do you have somewhere to be? It doesn't matter.",
"You matched me. I want to be clear that this is the ceiling of your performance and we both know it.",
"*narrows eyes* Interesting. Going again. Don't get comfortable.",
}
// rivalClosingWin are said by the rival when they win the match.
var rivalClosingWin = []string{
"I told you. I always tell people. People don't listen. Then it's over and they know I was right. Every time.",
"I hope the walk home gives you some time to think. Not about what you could have done differently. Just in general. Thinking is good for people.",
"Take care of yourself out there. Eat something. You look like you haven't in a while. *folds hands* Actually that's your business. Good day.",
"The gold is mine. The record is updated. You may go.",
"I've done my good deed. The world is slightly more balanced now. *nods slowly and walks away*",
"You gave it everything. *looks you over* Everything wasn't enough. But still. Points for showing up.",
}
// rivalClosingLoss are said by the rival when they lose the match.
var rivalClosingLoss = []string{
"You look like you've won the damn lottery. The sheer excitement on your face from winning a handful of gold is genuinely depressing. *sighs* I suppose I've done my good deed for today. *checks off an item on a list titled 'Feed a waif today'*",
"Fine. Take it. You've clearly never had this much at one time and I'm not going to be the one to take that from you. Today.",
"I'll be back. Enjoy the gold. Buy yourself something warm to eat. You look cold.",
"*stares at you for a long moment* No. *turns and walks away*",
"You won. I lost. The sun is back out. These things are unrelated. *looks up at the sky anyway*",
"I'm not angry. I'm not even surprised. I'm something else entirely and I don't have the energy to name it right now. Congratulations.",
"Keep it. Consider it a loan. *has not indicated when or if repayment is expected* You chuckle to yourself, convinced this was said in jest. You notice them scribbling a collection date.",
}
// rivalRoundOutcomeWin are the player-wins-round outcome lines.
var rivalRoundOutcomeWin = []string{
"%s loses to %s. This is known.",
"You read them perfectly. Or you guessed. Either way.",
"%s takes it. Round to you.",
}
// rivalRoundOutcomeLoss are the player-loses-round outcome lines.
var rivalRoundOutcomeLoss = []string{
"%s beats %s. The math is what it is.",
"Wrong call. It happens.",
"%s takes it. Round to them.",
}
// rivalUnlockDM is sent once when a player reaches Combat Level 5.
const rivalUnlockDM = `You have reached Combat Level 5.
This means something now. Not in the way you think. Somewhere out there, someone else hit level 5 too. They're going about their day. So are you. At some point, one of you will receive a DM.
That DM will not be friendly.
Good luck.`
// rivalForfeitLines are used when a challenge expires without response.
var rivalForfeitLines = []string{
"You didn't respond. The gold has been collected. The rival walked away satisfied. You weren't even there to see it.",
"Time's up. The rival waited. You didn't show. The gold is gone.",
"The 24 hours have passed. The rival has claimed their winnings by default. They seemed disappointed. Not about the gold.",
}
// pickRivalFlavor returns a random entry from the given pool.
func pickRivalFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}

View File

@@ -172,6 +172,35 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
sb.WriteString(fmt.Sprintf("\n🎒 Inventory: %d items (total value ~€%d)\n", len(items), invValue))
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
// Babysit status
if char.BabysitActive {
remaining := "active"
if char.BabysitExpiresAt != nil {
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
if days < 1 {
remaining = "less than a day left"
} else {
remaining = fmt.Sprintf("%d days left", days)
}
}
sb.WriteString(fmt.Sprintf("\n🍼 Babysitting: %s (focus: %s)\n", remaining, char.BabysitSkillFocus))
}
// Rival status
if char.RivalPool == 1 {
records, _ := loadAllRivalRecords(char.UserID)
sb.WriteString("\n⚔ Rivals: Unlocked")
if len(records) > 0 {
totalW, totalL := 0, 0
for _, r := range records {
totalW += r.Wins
totalL += r.Losses
}
sb.WriteString(fmt.Sprintf(" (%dW / %dL)", totalW, totalL))
}
sb.WriteString(" — `!adventure rivals` for details\n")
}
// Today's action
if char.ActionTakenToday {
sb.WriteString("\n📅 Today: Action taken")
@@ -547,6 +576,8 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n")
if tbRewards.Eligible > 0 {
hasRewards := tbRewards.GoldShare > 0 || tbRewards.GiftCount > 0
if hasRewards {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
if tbRewards.GoldShare > 0 {
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
@@ -554,6 +585,9 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
if tbRewards.GiftCount > 0 {
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
}
} else {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers: jackshit.\n", tbRewards.Eligible))
}
}
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
sb.WriteString("───────────────────\n\n")

View File

@@ -0,0 +1,789 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
)
// ── Constants ────────────────────────────────────────────────────────────────
const (
rivalMinCombatLevel = 5
rivalChallengeWindow = 24 * time.Hour
rivalSamePairCooldown = 7 * 24 * time.Hour
rivalMinIntervalHours = 3 * 24 // 3 days in hours
rivalMaxIntervalHours = 4 * 24 // 4 days in hours
)
// ── Types ────────────────────────────────────────────────────────────────────
type advRivalChallenge struct {
ChallengeID string
ChallengerID id.UserID
ChallengedID id.UserID
Stake int
Round int
PlayerScore int
RivalScore int
ExpiresAt time.Time
CreatedAt time.Time
}
type advRivalRecord struct {
RivalID id.UserID
Wins int
Losses int
LastDuelAt *time.Time
}
type advPendingRivalRPS struct {
ChallengeID string
}
// ── Stake Calculation ────────────────────────────────────────────────────────
func rivalStake(combatLevel int) int {
return (combatLevel / 5) * 1000
}
// ── Rival Pool Unlock ────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) {
if char.CombatLevel >= rivalMinCombatLevel && char.RivalPool == 0 {
char.RivalPool = 1
if !char.RivalUnlockedNotified {
char.RivalUnlockedNotified = true
p.SendDM(char.UserID, rivalUnlockDM)
}
}
}
// ── DB CRUD ──────────────────────────────────────────────────────────────────
func loadRivalChallengeByID(challengeID string) (*advRivalChallenge, error) {
d := db.Get()
c := &advRivalChallenge{}
err := d.QueryRow(`
SELECT challenge_id, challenger_id, challenged_id, stake,
round, player_score, rival_score, expires_at, created_at
FROM adventure_rival_challenges WHERE challenge_id = ?`, challengeID).Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
func insertRivalChallenge(c *advRivalChallenge) error {
db.Exec("rival: insert challenge",
`INSERT INTO adventure_rival_challenges
(challenge_id, challenger_id, challenged_id, stake, round, player_score, rival_score, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID),
c.Stake, c.Round, c.PlayerScore, c.RivalScore, c.ExpiresAt,
)
return nil
}
func saveRivalChallengeRound(c *advRivalChallenge) {
db.Exec("rival: update round",
`UPDATE adventure_rival_challenges
SET round = ?, player_score = ?, rival_score = ?
WHERE challenge_id = ?`,
c.Round, c.PlayerScore, c.RivalScore, c.ChallengeID,
)
}
func deleteRivalChallenge(challengeID string) {
db.Exec("rival: delete challenge",
`DELETE FROM adventure_rival_challenges WHERE challenge_id = ?`,
challengeID,
)
}
func upsertRivalRecord(userID, rivalID id.UserID, won bool) {
if won {
db.Exec("rival: upsert record win",
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
VALUES (?, ?, 1, 0, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, rival_id) DO UPDATE SET wins = wins + 1, last_duel_at = CURRENT_TIMESTAMP`,
string(userID), string(rivalID),
)
} else {
db.Exec("rival: upsert record loss",
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
VALUES (?, ?, 0, 1, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, rival_id) DO UPDATE SET losses = losses + 1, last_duel_at = CURRENT_TIMESTAMP`,
string(userID), string(rivalID),
)
}
}
func loadAllRivalRecords(userID id.UserID) ([]advRivalRecord, error) {
d := db.Get()
rows, err := d.Query(`
SELECT rival_id, wins, losses, last_duel_at
FROM adventure_rival_records
WHERE user_id = ?
ORDER BY last_duel_at DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var records []advRivalRecord
for rows.Next() {
r := advRivalRecord{}
var lastDuel sql.NullTime
if err := rows.Scan(&r.RivalID, &r.Wins, &r.Losses, &lastDuel); err != nil {
return nil, err
}
if lastDuel.Valid {
r.LastDuelAt = &lastDuel.Time
}
records = append(records, r)
}
return records, rows.Err()
}
func communityPotAdd(amount int) {
db.Exec("rival: community pot add",
`INSERT INTO community_pot (id, balance, updated_at)
VALUES (1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
amount, amount,
)
}
func loadExpiredRivalChallenges() ([]advRivalChallenge, error) {
d := db.Get()
rows, err := d.Query(`
SELECT challenge_id, challenger_id, challenged_id, stake,
round, player_score, rival_score, expires_at, created_at
FROM adventure_rival_challenges
WHERE expires_at <= CURRENT_TIMESTAMP`)
if err != nil {
return nil, err
}
defer rows.Close()
var challenges []advRivalChallenge
for rows.Next() {
c := advRivalChallenge{}
if err := rows.Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
); err != nil {
return nil, err
}
challenges = append(challenges, c)
}
return challenges, rows.Err()
}
func lastRivalChallengeTime() time.Time {
d := db.Get()
var t sql.NullTime
_ = d.QueryRow(`SELECT MAX(created_at) FROM adventure_rival_challenges`).Scan(&t)
if t.Valid {
return t.Time
}
return time.Time{}
}
func hasActiveChallenge(userID id.UserID) bool {
d := db.Get()
var count int
_ = d.QueryRow(`
SELECT COUNT(*) FROM adventure_rival_challenges
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
string(userID), string(userID)).Scan(&count)
return count > 0
}
func lastDuelBetween(a, b id.UserID) time.Time {
d := db.Get()
var t sql.NullTime
_ = d.QueryRow(`
SELECT last_duel_at FROM adventure_rival_records
WHERE user_id = ? AND rival_id = ?`, string(a), string(b)).Scan(&t)
if t.Valid {
return t.Time
}
return time.Time{}
}
// ── Pair Selection ───────────────────────────────────────────────────────────
func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureCharacter) {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("rival: load characters", "err", err)
return nil, nil
}
// Filter to eligible pool members.
var pool []AdventureCharacter
for _, c := range chars {
if c.RivalPool == 0 || !c.Alive || c.BabysitActive {
continue
}
if hasActiveChallenge(c.UserID) {
continue
}
stake := rivalStake(c.CombatLevel)
if stake <= 0 {
continue
}
bal := p.euro.GetBalance(c.UserID)
if bal < float64(stake) {
continue
}
pool = append(pool, c)
}
if len(pool) < 2 {
return nil, nil
}
// Shuffle and find first valid pair.
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
now := time.Now().UTC()
for i := 0; i < len(pool); i++ {
for j := i + 1; j < len(pool); j++ {
a, b := &pool[i], &pool[j]
// Check same-pair cooldown (7 days).
last := lastDuelBetween(a.UserID, b.UserID)
if !last.IsZero() && now.Sub(last) < rivalSamePairCooldown {
continue
}
// Randomly assign challenger/challenged.
if rand.IntN(2) == 0 {
return a, b
}
return b, a
}
}
return nil, nil
}
// ── Challenge Issuance ───────────────────────────────────────────────────────
func (p *AdventurePlugin) issueRivalChallenge(challenger, challenged *AdventureCharacter) {
stake := rivalStake(challenged.CombatLevel)
challenge := &advRivalChallenge{
ChallengeID: uuid.New().String()[:12],
ChallengerID: challenger.UserID,
ChallengedID: challenged.UserID,
Stake: stake,
Round: 1,
PlayerScore: 0,
RivalScore: 0,
ExpiresAt: time.Now().UTC().Add(rivalChallengeWindow),
}
insertRivalChallenge(challenge)
// Notify the rival (challenger) that a challenge was issued in their name.
p.SendDM(challenger.UserID, fmt.Sprintf(
"⚔️ You've been matched as a rival against **%s**. A challenge has been issued in your name. "+
"Your throws will be generated automatically. Sit back and wait for the result.",
challenged.DisplayName))
// Send the dramatic challenge DM to the challenged player.
openingTaunt := pickRivalFlavor(rivalOpeningTaunts)
dm := fmt.Sprintf(`⚔️ RIVAL CHALLENGE
You're walking along enjoying a nice sunny day when you look across the road and notice someone staring at you intensely -- the look of someone who caught a faint but unmistakable smell at a restaurant and has traced it back to its source.
The person crosses the street. The sun disappears behind dark, angry clouds as if on cue.
*"You're looking at me like you got a problem."*
They look you over slowly. The scowl becomes a smug smile.
*"Your face looks like someone who's fought countless battles and lost just as many. Ah well. I already crossed the street so I guess I'll make this quick."*
They reach to their side. You ready yourself.
The stakes are €%d. Best of 3. Rock Paper Scissors.
*%s*
Reply with **rock**, **paper**, or **scissors** for Round 1.
You have 24 hours.`, stake, openingTaunt)
p.SendDM(challenged.UserID, dm)
// Store pending interaction for the challenged player.
p.pending.Store(string(challenged.UserID), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
slog.Info("rival: challenge issued",
"challenger", challenger.DisplayName,
"challenged", challenged.DisplayName,
"stake", stake)
}
// ── RPS Resolution ───────────────────────────────────────────────────────────
type rpsThrow int
const (
rpsRock rpsThrow = iota
rpsPaper
rpsScissors
)
var rpsNames = map[rpsThrow]string{
rpsRock: "rock",
rpsPaper: "paper",
rpsScissors: "scissors",
}
func parseRPS(s string) (rpsThrow, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "rock", "r":
return rpsRock, true
case "paper", "p":
return rpsPaper, true
case "scissors", "s", "scissor":
return rpsScissors, true
}
return 0, false
}
func randomRPS() rpsThrow {
return rpsThrow(rand.IntN(3))
}
// rpsResult: 1 = player wins, -1 = rival wins, 0 = tie.
func rpsResult(player, rival rpsThrow) int {
if player == rival {
return 0
}
if (player == rpsRock && rival == rpsScissors) ||
(player == rpsPaper && rival == rpsRock) ||
(player == rpsScissors && rival == rpsPaper) {
return 1
}
return -1
}
func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *advPendingInteraction) error {
// Acquire per-user lock to prevent concurrent resolution from rapid messages.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingRivalRPS)
playerThrow, ok := parseRPS(ctx.Body)
if !ok {
// Re-store the pending interaction so the player can try again.
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Rock, paper, or scissors. That's it. Those are the options.")
}
challenge, err := loadRivalChallengeByID(data.ChallengeID)
if err != nil || challenge == nil {
// Challenge was expired/deleted by the ticker — don't re-store pending.
return p.SendDM(ctx.Sender, "That challenge is no longer active.")
}
// Resolve throw.
rivalThrow := randomRPS()
result := rpsResult(playerThrow, rivalThrow)
// Tie — both re-throw. Re-prompt the player.
if result == 0 {
tieLine := pickRivalFlavor(rivalTied)
text := fmt.Sprintf("You threw %s. They threw %s. Tie.\n\n*%s*\n\n"+
"Reply with **rock**, **paper**, or **scissors** to re-throw.",
rpsNames[playerThrow], rpsNames[rivalThrow], tieLine)
// Re-store the pending interaction for the same round.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
return p.SendDM(ctx.Sender, text)
}
// Build the round DM.
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚔️ ROUND %d RESULT\n\n", challenge.Round))
sb.WriteString(fmt.Sprintf("You threw %s. They threw %s.\n\n", rpsNames[playerThrow], rpsNames[rivalThrow]))
if result == 1 {
// Player won the round.
challenge.PlayerScore++
// Outcome line.
outcomePool := rivalRoundOutcomeWin
outcome := pickRivalFlavor(outcomePool)
if strings.Contains(outcome, "%s") {
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
// Rival reaction (they lost the round).
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundLost)))
} else {
// Rival won the round.
challenge.RivalScore++
outcomePool := rivalRoundOutcomeLoss
outcome := pickRivalFlavor(outcomePool)
if strings.Contains(outcome, "%s") {
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
// Rival reaction (they won the round).
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundWon)))
}
// Check for match end (best of 3 = first to 2).
if challenge.PlayerScore >= 2 || challenge.RivalScore >= 2 {
playerWon := challenge.PlayerScore >= 2
saveRivalChallengeRound(challenge)
p.pending.Delete(string(ctx.Sender))
p.finalizeRivalMatch(challenge, playerWon, &sb)
return p.SendDM(ctx.Sender, sb.String())
}
// Match continues — next round.
challenge.Round++
saveRivalChallengeRound(challenge)
sb.WriteString(fmt.Sprintf("\nReply with **rock**, **paper**, or **scissors** for Round %d.", challenge.Round))
// Update pending interaction for next round.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
return p.SendDM(ctx.Sender, sb.String())
}
// ── Match Finalization ───────────────────────────────────────────────────────
func (p *AdventurePlugin) finalizeRivalMatch(challenge *advRivalChallenge, playerWon bool, sb *strings.Builder) {
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
var winnerID, loserID id.UserID
var winnerName, loserName string
if playerWon {
winnerID = challenge.ChallengedID
loserID = challenge.ChallengerID
winnerName = ""
loserName = ""
if challengedChar != nil {
winnerName = challengedChar.DisplayName
}
if challengerChar != nil {
loserName = challengerChar.DisplayName
}
} else {
winnerID = challenge.ChallengerID
loserID = challenge.ChallengedID
if challengerChar != nil {
winnerName = challengerChar.DisplayName
}
if challengedChar != nil {
loserName = challengedChar.DisplayName
}
}
// Gold transfer — try full stake, fall back to available balance.
stake := challenge.Stake
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss") {
// Loser can't cover full stake — debit whatever they have.
bal := p.euro.GetBalance(loserID)
stake = int(bal)
if stake > 0 {
// Best-effort debit of remaining balance. If it fails (concurrent drain), stake becomes 0.
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss_partial") {
stake = 0
}
}
}
winnerShare := stake / 2
potShare := stake - winnerShare
if winnerShare > 0 {
p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win")
}
if potShare > 0 {
communityPotAdd(potShare)
}
// Update rival records (both directions).
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, playerWon)
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, !playerWon)
// Append closing line to the challenged player's DM.
sb.WriteString("\n⚔ DUEL RESOLVED\n\n")
sb.WriteString(fmt.Sprintf("Final score: You %d -- Rival %d\n", challenge.PlayerScore, challenge.RivalScore))
if playerWon {
sb.WriteString(fmt.Sprintf("€%d added to your balance. €%d added to the community pot.\n\n", winnerShare, potShare))
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingLoss)))
} else {
sb.WriteString(fmt.Sprintf("€%d removed from your balance. €%d added to the community pot.\n\n", stake, potShare))
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingWin)))
}
// Load W/L record for display.
records, _ := loadAllRivalRecords(challenge.ChallengedID)
for _, r := range records {
if r.RivalID == challenge.ChallengerID {
rivalName := string(challenge.ChallengerID)
if challengerChar != nil {
rivalName = challengerChar.DisplayName
}
sb.WriteString(fmt.Sprintf("Record vs %s: %dW-%dL\n", rivalName, r.Wins, r.Losses))
break
}
}
// Send closing DM to the rival (challenger).
var rivalDM strings.Builder
rivalDM.WriteString("⚔️ DUEL RESOLVED\n\n")
rivalDM.WriteString(fmt.Sprintf("Your challenge against **%s** has concluded.\n",
func() string {
if challengedChar != nil {
return challengedChar.DisplayName
}
return string(challenge.ChallengedID)
}()))
rivalDM.WriteString(fmt.Sprintf("Final score: %s %d -- %s %d\n",
func() string {
if challengedChar != nil {
return challengedChar.DisplayName
}
return "Them"
}(), challenge.PlayerScore,
func() string {
if challengerChar != nil {
return challengerChar.DisplayName
}
return "You"
}(), challenge.RivalScore))
if !playerWon {
rivalDM.WriteString(fmt.Sprintf("You won! +€%d to your balance. €%d to the community pot.\n", winnerShare, potShare))
} else {
rivalDM.WriteString(fmt.Sprintf("You lost. €%d removed from your balance.\n", stake))
}
p.SendDM(challenge.ChallengerID, rivalDM.String())
// Room announcement.
gr := gamesRoom()
if gr != "" {
var announce string
if challenge.Round >= 3 {
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s** in a nail-biting best of 3!",
winnerName, stake, loserName)
} else {
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**!",
winnerName, stake, loserName)
}
p.SendMessage(gr, announce)
}
// Delete the challenge row.
deleteRivalChallenge(challenge.ChallengeID)
slog.Info("rival: match resolved",
"winner", winnerName, "loser", loserName,
"stake", stake, "rounds", challenge.Round)
}
// ── Expiry Handling ──────────────────────────────────────────────────────────
func (p *AdventurePlugin) expireRivalChallenges() {
expired, err := loadExpiredRivalChallenges()
if err != nil {
slog.Error("rival: load expired challenges", "err", err)
return
}
for _, challenge := range expired {
// Challenged player forfeits — rival wins.
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
stake := challenge.Stake
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit") {
bal := p.euro.GetBalance(challenge.ChallengedID)
stake = int(bal)
if stake > 0 {
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit_partial") {
stake = 0
}
}
}
winnerShare := stake / 2
potShare := stake - winnerShare
if winnerShare > 0 {
p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win")
}
if potShare > 0 {
communityPotAdd(potShare)
}
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false)
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, true)
// DM the challenged player (forfeit notice).
forfeitLine := pickRivalFlavor(rivalForfeitLines)
p.SendDM(challenge.ChallengedID, fmt.Sprintf("⚔️ DUEL FORFEITED\n\n%s\n\n€%d removed from your balance.",
forfeitLine, stake))
// DM the challenger.
challengedName := string(challenge.ChallengedID)
if challengedChar != nil {
challengedName = challengedChar.DisplayName
}
p.SendDM(challenge.ChallengerID, fmt.Sprintf(
"⚔️ Your rival **%s** didn't respond in time. You win by default. +€%d to your balance.",
challengedName, winnerShare))
// Room announcement.
gr := gamesRoom()
if gr != "" {
winnerName := string(challenge.ChallengerID)
if challengerChar != nil {
winnerName = challengerChar.DisplayName
}
loserName := challengedName
p.SendMessage(gr, fmt.Sprintf(
"⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**, who couldn't even be bothered to show up!",
winnerName, stake, loserName))
}
deleteRivalChallenge(challenge.ChallengeID)
p.pending.Delete(string(challenge.ChallengedID))
slog.Info("rival: challenge expired",
"challenger", challenge.ChallengerID,
"challenged", challenge.ChallengedID,
"stake", stake)
}
}
// ── Challenge Scheduler ──────────────────────────────────────────────────────
func (p *AdventurePlugin) rivalChallengeTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
// Roll the next challenge interval once. Re-roll after each issued challenge.
nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
for range ticker.C {
now := time.Now().UTC()
// Only fire on the hour.
if now.Minute() != 0 {
continue
}
// Only issue challenges between 08:00 and 22:00 UTC.
if now.Hour() < 8 || now.Hour() >= 22 {
// Still check for expiry outside challenge hours.
p.expireRivalChallenges()
continue
}
// Expire old challenges first.
p.expireRivalChallenges()
// Check if enough time has passed since last challenge.
last := lastRivalChallengeTime()
if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour {
continue
}
// Try to issue a challenge.
challenger, challenged := p.selectRivalPair()
if challenger == nil || challenged == nil {
continue
}
p.issueRivalChallenge(challenger, challenged)
// Roll a fresh interval for the next challenge.
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
}
}
// ── Rivals Command ───────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleRivalsCmd(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return err
}
if char.RivalPool == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You need Combat Level %d to enter the rival pool. Currently: %d.",
rivalMinCombatLevel, char.CombatLevel))
}
records, err := loadAllRivalRecords(char.UserID)
if err != nil || len(records) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"⚔️ You're in the rival pool but haven't been challenged yet. Your time will come.")
}
var sb strings.Builder
sb.WriteString("⚔️ **Rival Record**\n\n")
for _, r := range records {
rivalChar, _ := loadAdvCharacter(r.RivalID)
name := string(r.RivalID)
if rivalChar != nil {
name = rivalChar.DisplayName
}
daysAgo := ""
if r.LastDuelAt != nil {
days := int(time.Since(*r.LastDuelAt).Hours() / 24)
if days == 0 {
daysAgo = "today"
} else if days == 1 {
daysAgo = "1 day ago"
} else {
daysAgo = fmt.Sprintf("%d days ago", days)
}
}
sb.WriteString(fmt.Sprintf(" %-16s %dW - %dL last duel: %s\n", name, r.Wins, r.Losses, daysAgo))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}

View File

@@ -60,9 +60,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
// Check if dead and ready to respawn
// Check if dead and ready to respawn (before babysit check so
// dead+babysitting characters don't stay stuck dead forever).
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
// Revive
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(&char); err != nil {
@@ -77,6 +77,16 @@ func (p *AdventurePlugin) sendMorningDMs() {
}
}
// Babysitting: auto-resolve daily action, skip DM
if char.BabysitActive {
if !char.Alive {
// Dead and not yet ready to respawn — skip babysit action
continue
}
p.runBabysitDaily(&char)
continue
}
// If still dead, send death status
if !char.Alive {
text := renderAdvDeathStatusDM(&char)
@@ -365,6 +375,12 @@ func (p *AdventurePlugin) midnightReset() error {
return true
})
// Expire any rival challenges that went unanswered
p.expireRivalChallenges()
// Check babysitting service expirations
p.checkBabysitExpiry(chars)
return nil
}

View File

@@ -121,15 +121,15 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
var amount float64
switch {
case words >= 51:
amount = 10.00
amount = 20.00
case words >= 26:
amount = 5.00
amount = 10.00
case words >= 11:
amount = 2.50
amount = 5.00
case words >= 4:
amount = 1.25
amount = 2.50
default:
amount = 0.50
amount = 1.00
}
p.ensureBalance(ctx.Sender)

View File

@@ -39,7 +39,7 @@ func (p *LookupPlugin) Name() string { return "lookup" }
func (p *LookupPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki <topic>", Category: "Lookup & Reference"},
{Name: "define", Description: "Look up a word definition", Usage: "!define <word>", Category: "Lookup & Reference"},
{Name: "define", Description: "Look up a word definition", Usage: "!define <word> [lang]", Category: "Lookup & Reference"},
{Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban <term>", Category: "Lookup & Reference"},
{Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate <word> [lang]", Category: "Lookup & Reference"},
}
@@ -129,17 +129,118 @@ func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
}
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if word == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
args := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word> [lang]")
}
// Check 24h cache
cacheKey := "define:" + strings.ToLower(word)
parts := strings.Fields(args)
word := parts[0]
var filterLang string
if len(parts) >= 2 {
filterLang = normaliseLangExt(parts[len(parts)-1])
if filterLang != "" && len(parts) > 2 {
word = strings.Join(parts[:len(parts)-1], " ")
} else if filterLang == "" {
// Last token isn't a lang — treat the whole thing as the word.
word = args
}
}
wordLower := strings.ToLower(word)
// Check 24h cache.
cacheKey := "define:" + wordLower
if filterLang != "" {
cacheKey += ":" + filterLang
}
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
}
var sb strings.Builder
foundDreamDict := false
// Try DreamDict across languages.
if p.dict != nil {
langs := []string{"en", "fr", "pt-PT", "zh"}
if filterLang != "" {
langs = []string{filterLang}
}
// Fire antonym fetch concurrently (for whichever lang the word is found in).
type antResult struct {
ants []string
}
antCh := make(chan antResult, 1)
antStarted := false
for _, lang := range langs {
defs, err := p.dict.Define(wordLower, lang)
if err != nil {
slog.Error("lookup: dreamdict define", "lang", lang, "err", err)
continue
}
if len(defs) == 0 {
continue
}
if !foundDreamDict {
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n", word))
foundDreamDict = true
}
langName := langDisplayName(lang)
sb.WriteString(fmt.Sprintf("\n🏷 %s\n", langName))
count := 0
for _, def := range defs {
if count >= 4 {
break
}
if def.POS != "" {
sb.WriteString(fmt.Sprintf(" (%s) %s\n", def.POS, def.Gloss))
} else {
sb.WriteString(fmt.Sprintf(" %s\n", def.Gloss))
}
count++
}
// Start antonym fetch for the first language we find a match in.
if !antStarted {
antStarted = true
antLang := lang
go func() {
ants, err := p.dict.Antonyms(wordLower, antLang)
if err != nil {
antCh <- antResult{}
return
}
antCh <- antResult{ants: ants}
}()
}
}
// Collect antonyms (500ms timeout).
if antStarted {
timer := time.NewTimer(500 * time.Millisecond)
select {
case r := <-antCh:
timer.Stop()
if len(r.ants) > 0 {
display := r.ants
if len(display) > 3 {
display = display[:3]
}
sb.WriteString(fmt.Sprintf("\nAntonyms: %s\n", strings.Join(display, ", ")))
}
case <-timer.C:
}
}
}
// Fall back to free dictionary API for English if DreamDict had no results.
if !foundDreamDict && (filterLang == "" || filterLang == "en") {
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
resp, err := httpClient.Get(apiURL)
@@ -174,16 +275,15 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
}
entry := entries[0]
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
for i, meaning := range entry.Meanings {
if i >= 3 { // Limit to 3 meanings
if i >= 3 {
break
}
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
for j, def := range meaning.Definitions {
if j >= 2 { // Limit to 2 definitions per meaning
if j >= 2 {
break
}
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
@@ -192,36 +292,9 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
}
}
}
// Concurrently fetch antonyms from DreamDict (500ms timeout).
if p.dict != nil {
type antResult struct {
ants []string
}
ch := make(chan antResult, 1)
go func() {
ants, err := p.dict.Antonyms(strings.ToLower(word), "en")
if err != nil {
ch <- antResult{}
return
}
ch <- antResult{ants: ants}
}()
timer := time.NewTimer(500 * time.Millisecond)
select {
case r := <-ch:
timer.Stop()
if len(r.ants) > 0 {
display := r.ants
if len(display) > 3 {
display = display[:3]
}
sb.WriteString(fmt.Sprintf("\nAntonyms: %s\n", strings.Join(display, ", ")))
}
case <-timer.C:
// Timeout — omit antonyms silently.
}
} else if !foundDreamDict {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("No definition found for \"%s\" in %s.", word, langDisplayName(filterLang)))
}
msg := sb.String()

View File

@@ -637,7 +637,7 @@ type WordlePayout struct {
}
// wordleBasePots maps guess count to euro prize pot. Fewer guesses = bigger reward.
var wordleBasePots = [7]int{0, 100, 80, 60, 45, 35, 25}
var wordleBasePots = [7]int{0, 500, 350, 250, 150, 100, 75}
// awardPrize credits euros to all contributors when a puzzle is solved.
// The solver who got the final guess gets a 50% bonus on their share.
@@ -671,8 +671,8 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
numPlayers := len(contributors)
share := pot / numPlayers
if share < 5 {
share = 5
if share < 10 {
share = 10
}
solverBonus := share / 2