From ad6e652755701122fc7ac2bfc46d5ae5134fd462 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:11:51 -0700 Subject: [PATCH] Add rival duels, babysitting service, multilingual !define, economy tuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- internal/db/db.go | 49 ++ internal/plugin/adventure.go | 70 +- internal/plugin/adventure_arena.go | 7 +- internal/plugin/adventure_babysit.go | 455 +++++++++++ internal/plugin/adventure_character.go | 54 +- internal/plugin/adventure_flavor_babysit.go | 37 + internal/plugin/adventure_flavor_rival.go | 105 +++ internal/plugin/adventure_render.go | 46 +- internal/plugin/adventure_rival.go | 789 ++++++++++++++++++++ internal/plugin/adventure_scheduler.go | 20 +- internal/plugin/euro.go | 10 +- internal/plugin/lookup.go | 231 ++++-- internal/plugin/wordle.go | 6 +- 14 files changed, 1769 insertions(+), 112 deletions(-) create mode 100644 internal/plugin/adventure_babysit.go create mode 100644 internal/plugin/adventure_flavor_babysit.go create mode 100644 internal/plugin/adventure_flavor_rival.go create mode 100644 internal/plugin/adventure_rival.go diff --git a/README.md b/README.md index 8f69017..2b1b285 100644 --- a/README.md +++ b/README.md @@ -611,7 +611,7 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th | Command | Description | |---------|-------------| | `!wiki ` | Wikipedia summary | -| `!define ` | Dictionary definition (includes antonyms from DreamDict when available) | +| `!define [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 ` | Urban Dictionary | | `!translate [lang]` | Cross-language word lookup (en/fr/pt-PT, auto-detects source) | | `!antonym [lang]` | Antonyms for a word (en/fr/pt-PT/zh) | diff --git a/internal/db/db.go b/internal/db/db.go index f39af74..1ecd47c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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, diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index fa4ca86..007eaa8 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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:** @@ -238,8 +245,22 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error { } if !char.Alive { - text := renderAdvDeathStatusDM(char) - return p.SendDM(ctx.Sender, text) + // 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 { @@ -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 } @@ -499,7 +542,19 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) } if !char.Alive { - return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char)) + // 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 { @@ -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 diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 8d3747d..4a1fbdd 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -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) } diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go new file mode 100644 index 0000000..862196d --- /dev/null +++ b/internal/plugin/adventure_babysit.go @@ -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 +} diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 1bd15a4..1c6f3ff 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -49,8 +49,13 @@ type AdventureCharacter struct { GrudgeLocation string CreatedAt time.Time LastActiveAt time.Time - DeathReprieveLast *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() diff --git a/internal/plugin/adventure_flavor_babysit.go b/internal/plugin/adventure_flavor_babysit.go new file mode 100644 index 0000000..a088be8 --- /dev/null +++ b/internal/plugin/adventure_flavor_babysit.go @@ -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))] +} diff --git a/internal/plugin/adventure_flavor_rival.go b/internal/plugin/adventure_flavor_rival.go new file mode 100644 index 0000000..dfec97e --- /dev/null +++ b/internal/plugin/adventure_flavor_rival.go @@ -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))] +} diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index c50a6b3..2fef7f3 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -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,12 +576,17 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa sb.WriteString("\n") if tbRewards.Eligible > 0 { - 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)) - } - if tbRewards.GiftCount > 0 { - sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount)) + 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)) + } + 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") diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go new file mode 100644 index 0000000..5384f38 --- /dev/null +++ b/internal/plugin/adventure_rival.go @@ -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()) +} diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 14f2409..1ed9aae 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -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 } diff --git a/internal/plugin/euro.go b/internal/plugin/euro.go index 29f0284..6efb624 100644 --- a/internal/plugin/euro.go +++ b/internal/plugin/euro.go @@ -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) diff --git a/internal/plugin/lookup.go b/internal/plugin/lookup.go index 9baa4cf..a64dc33 100644 --- a/internal/plugin/lookup.go +++ b/internal/plugin/lookup.go @@ -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 ", Category: "Lookup & Reference"}, - {Name: "define", Description: "Look up a word definition", Usage: "!define ", Category: "Lookup & Reference"}, + {Name: "define", Description: "Look up a word definition", Usage: "!define [lang]", Category: "Lookup & Reference"}, {Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban ", Category: "Lookup & Reference"}, {Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate [lang]", Category: "Lookup & Reference"}, } @@ -129,99 +129,172 @@ 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 ") + args := strings.TrimSpace(p.GetArgs(ctx.Body, "define")) + if args == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define [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) } - apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word)) - - resp, err := httpClient.Get(apiURL) - if err != nil { - slog.Error("lookup: define request", "err", err) - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.") - } - defer resp.Body.Close() - - if resp.StatusCode == 404 { - return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.") - } - - var entries []struct { - Word string `json:"word"` - Meanings []struct { - PartOfSpeech string `json:"partOfSpeech"` - Definitions []struct { - Definition string `json:"definition"` - Example string `json:"example"` - } `json:"definitions"` - } `json:"meanings"` - } - - if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 { - return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word)) - } - - entry := entries[0] var sb strings.Builder - sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word)) + foundDreamDict := false - for i, meaning := range entry.Meanings { - if i >= 3 { // Limit to 3 meanings - break - } - sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech)) - for j, def := range meaning.Definitions { - if j >= 2 { // Limit to 2 definitions per meaning - break - } - sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition)) - if def.Example != "" { - sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example)) - } - } - } - - // Concurrently fetch antonyms from DreamDict (500ms timeout). + // 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 } - 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} - }() + antCh := make(chan antResult, 1) + antStarted := false - 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, ", "))) + 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} + }() } - case <-timer.C: - // Timeout — omit antonyms silently. } + + // 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) + if err != nil { + slog.Error("lookup: define request", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.") + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.") + } + + var entries []struct { + Word string `json:"word"` + Meanings []struct { + PartOfSpeech string `json:"partOfSpeech"` + Definitions []struct { + Definition string `json:"definition"` + Example string `json:"example"` + } `json:"definitions"` + } `json:"meanings"` + } + + if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word)) + } + + entry := entries[0] + sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word)) + + for i, meaning := range entry.Meanings { + if i >= 3 { + break + } + sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech)) + for j, def := range meaning.Definitions { + if j >= 2 { + break + } + sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition)) + if def.Example != "" { + sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example)) + } + } + } + } 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() diff --git a/internal/plugin/wordle.go b/internal/plugin/wordle.go index 09492c7..039eda0 100644 --- a/internal/plugin/wordle.go +++ b/internal/plugin/wordle.go @@ -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