diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index f533bf4..d5b421f 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -244,38 +244,26 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, --- -## 5. Phase L3 — Co-op dungeons (DELETION, not migration) +## 5. Phase L3 — Co-op dungeons (DELETION, not migration) — SHIPPED 2026-05-09 **Decision (2026-05-08):** Drop co-op dungeons entirely. Do **not** migrate. The user has a different design in mind for the future co-op slot; the current implementation is being retired wholesale. -**Files deleted:** -- `coop_dungeon.go` -- `coop_dungeon_db.go` -- `coop_dungeon_render.go` -- `coop_dungeon_scheduler.go` -- `coop_dungeon_balance.go` -- `coop_dungeon_balance_test.go` -- `coop_dungeon_betting.go` -- `coop_dungeon_gifts.go` -- `coop_dungeon_stats.go` -- `coop_dungeon_test.go` -- `coop_event_meta.go` -- `coop_flavor_twinbee.go` +**What shipped (2026-05-09):** +- 11 `coop_*.go` files deleted (no `coop_dungeon_balance.go` ever existed on disk — only `coop_dungeon_balance_test.go`). +- `internal/plugin/cleanup_l3.go` added: on every startup, cancels any `coop_dungeon_runs` left in `open`/`active`, refunds `coop_dungeon_members.total_contributed` and unsettled `coop_dungeon_bets` via `EuroPlugin.Credit`. Idempotent via per-run status-guarded UPDATE — only the writer that flips status to `cancelled` performs the refund for that run, so a crashed-then-restarted process can't double-credit. Tables left intact for historical querying; SQL drop deferred to a future GOGOBEE_COOP_PURGE pass. +- `adventure.go`: stripped `!coop` dispatch, `coopTicker` goroutine, startup `lockCoopCombatActions` call, `!coop` help line, ticker comment. Replaced startup hook with `closeAndRefundLegacyCoopRuns(p.euro)`. +- `adventure_scheduler.go`: removed `activeCoopMemberSet` load + skip branch and post-reset `lockCoopCombatActions` call. +- `adventure_render.go`: removed `renderCoopTeaser`, `pickCoopTeaserCandidate`, the `loadCoopRunForUser` block, and the in-coop combat-locked status line. Replaced with a temporary closure announcement in the morning DM ("**Co-op dungeons closed for now** — `!expedition` is the way forward; a better co-op design is on the way."). Marked with a TODO to remove after 2026-05-16. +- `adventure_followups_test.go`: removed `TestPickCoopTeaserCandidate_SkipsLeader`. -**Steps:** -1. Find all co-op references outside the `coop_*` files (commands registered in `adventure.go`, scheduler hooks, render hooks, betting integration). Grep `coop` and `Coop` across the package. -2. Remove command handlers from `adventure.go::OnMessage` and `Commands()`. -3. Remove scheduler ticks that call coop entrypoints. -4. Drop the `coop_dungeon*` SQL tables in a separate `cleanup_l3.go` migration gated behind `GOGOBEE_COOP_PURGE=1`. Default off — keep historical rows around until manually flipped. -5. Delete the files. -6. Add a TwinBee announcement line for active players: "co-op dungeons closed for now; expedition is the way forward; better thing coming." Wire into morning DM for one week post-deploy. -7. `go test ./... && go vet`. - -**Exit criteria:** -- `grep -rn 'coop_dungeon\|CoopDungeon' internal/plugin/ --include='*.go'` returns 0 (or only in archive/migration). +**Exit criteria — met:** +- `grep -rn 'coop_dungeon\|CoopDungeon' internal/plugin/ --include='*.go'` returns only the cleanup migration in `cleanup_l3.go` and a startup hook in `adventure.go` — no live system code references. +- `go vet ./...` clean. - `go test ./...` clean. -**Risk:** Players with active co-op state at deploy time. Mitigate: scheduler closes any in-flight session at L3 deploy with a refund of staked coins; no XP/loot awarded for cancelled sessions. Refund logic is one-shot in the L3 migration, not preserved code. +**Open follow-ups:** +- Remove the morning-DM closure announcement after 2026-05-16 (one-week soak). +- Future GOGOBEE_COOP_PURGE pass drops `coop_dungeon_runs`/`_members`/`_events`/`_bets`/`_gifts` tables and the `cleanup_l3.go` startup hook. --- diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 165b67a..b4de0bc 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -42,7 +42,7 @@ type AdventurePlugin struct { // stopCh is closed by Stop() to signal all background tickers // (morning/summary/midnight/event/rival/robbie/hospital/mortgage/ - // coop/expedition briefing+recap) to exit. Each ticker selects + // expedition briefing+recap) to exit. Each ticker selects // on its time.Ticker channel and stopCh; the second branch returns. stopCh chan struct{} } @@ -194,9 +194,9 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaArena(); err != nil { slog.Error("player_meta: arena backfill failed", "err", err) } - if err := lockCoopCombatActions(); err != nil { - slog.Error("adventure: startup coop combat lock failed", "err", err) - } + // Phase L3 — cancel any open/active legacy coop dungeon runs and + // refund member contributions + unsettled bets. Idempotent. + closeAndRefundLegacyCoopRuns(p.euro) // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which @@ -221,7 +221,6 @@ func (p *AdventurePlugin) Init() error { go p.robbieTicker() go p.hospitalNudgeTicker() go p.mortgageTicker() - go p.coopTicker() go p.expeditionBriefingTicker() go p.expeditionRecapTicker() @@ -366,11 +365,6 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { return p.handleHospitalCmd(ctx) } - // 1c. Co-op dungeon commands (work in rooms and DMs) - if p.IsCommand(ctx.Body, "coop") { - return p.handleCoopCmd(ctx) - } - // 2. Check if this is a DM reply from a registered player p.mu.Lock() playerID, isDM := p.dmToPlayer[ctx.RoomID] @@ -478,7 +472,6 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level ` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus ` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps -` + "`!coop`" + ` — Co-op dungeons (multi-day party runs). See ` + "`!coop help`" + `. ` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead) ` + "`!thom`" + ` — Visit Thom Krooke (housing and loans) ` + "`!adventure help`" + ` — This message diff --git a/internal/plugin/adventure_followups_test.go b/internal/plugin/adventure_followups_test.go index 1187864..7a5cf5f 100644 --- a/internal/plugin/adventure_followups_test.go +++ b/internal/plugin/adventure_followups_test.go @@ -321,34 +321,6 @@ func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) { } } -// ── Co-op Teaser Leader Skip ─────────────────────────────────────────────── - -func TestPickCoopTeaserCandidate_SkipsLeader(t *testing.T) { - me := id.UserID("@me:test") - other := id.UserID("@other:test") - - myRun := &CoopRun{ID: 1, Tier: 1, LeaderID: me} - theirRunMatch := &CoopRun{ID: 2, Tier: 1, LeaderID: other} - theirRunStretch := &CoopRun{ID: 3, Tier: 5, LeaderID: other} - - char := &AdventureCharacter{UserID: me, CombatLevel: 10} - - // Player leads run 1, qualifies for run 2 (T1 minLevel 5), under-level for run 3 (T5 minLevel 40). - match, stretch := pickCoopTeaserCandidate([]*CoopRun{myRun, theirRunMatch, theirRunStretch}, char) - if match == nil || match.ID != 2 { - t.Errorf("expected match=run 2, got %v", match) - } - if stretch == nil || stretch.ID != 3 { - t.Errorf("expected stretch=run 3, got %v", stretch) - } - - // Only own run open: nothing to surface. - match, stretch = pickCoopTeaserCandidate([]*CoopRun{myRun}, char) - if match != nil || stretch != nil { - t.Errorf("expected both nil when only own run open; got match=%v stretch=%v", match, stretch) - } -} - // ── Location Risk/Reward Numbers ─────────────────────────────────────────── func TestCalculateAdvProbabilities_RiskReward_NormalizesAcrossConfigs(t *testing.T) { diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index e3abae3..46f770b 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -213,55 +213,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* // ── Morning DM ─────────────────────────────────────────────────────────────── -// renderCoopTeaser surfaces an open co-op run the player could join, so the -// system isn't a footnote in help text. Returns "" when there's nothing to -// surface (no open runs, or the player is already locked into one). When -// runs exist that the player is under-levelled for, we still hint at them -// as a stretch goal — joining as a liability is a real choice. -func renderCoopTeaser(char *AdventureCharacter) string { - runs, err := loadOpenCoopRuns() - if err != nil || len(runs) == 0 { - return "" - } - - pick, stretch := pickCoopTeaserCandidate(runs, char) - tag := "" - if pick == nil { - pick = stretch - tag = " *(below recommended level — would join as liability)*" - } - if pick == nil { - return "" - } - - leaderName := string(pick.LeaderID) - if c, err := loadAdvCharacter(pick.LeaderID); err == nil && c != nil && c.DisplayName != "" { - leaderName = c.DisplayName - } - def := coopTierTable[pick.Tier] - return fmt.Sprintf("🛡️ **Open Co-op #%d** — Tier %d (%s) led by %s · `!coop join %d`%s", - pick.ID, pick.Tier, def.difficulty, leaderName, pick.ID, tag) -} - -// pickCoopTeaserCandidate scans the open coop runs and returns up to two -// picks: a primary (where the player meets the level gate) and a stretch -// (any other run, joined as a liability). Skips runs the player already -// leads. Pure function — testable without DB. -func pickCoopTeaserCandidate(runs []*CoopRun, char *AdventureCharacter) (match, stretch *CoopRun) { - for _, r := range runs { - if r.LeaderID == char.UserID { - continue - } - def := coopTierTable[r.Tier] - if char.CombatLevel >= def.minLevel && match == nil { - match = r - } else if stretch == nil { - stretch = r - } - } - return match, stretch -} - // renderCraftingTeaser surfaces the crafting system before and just after // the Foraging-10 auto-unlock, so it doesn't stay invisible to players who // never type !adventure recipes. Returns "" when the teaser shouldn't fire @@ -379,23 +330,10 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq sb.WriteString("\n\n") } - // Co-op participants have combat locked for the duration of the run. - // Under Adv 2.0 the per-day combat/harvest action caps are no longer - // surfaced — harvesting is gated by per-room nodes, supplies, and the - // threat clock; combat is gated by zones/expeditions/arena themselves. - coopRun, _ := loadCoopRunForUser(char.UserID) - inCoop := coopRun != nil && coopRun.Status == "active" - - if inCoop { - sb.WriteString(fmt.Sprintf("📋 **Co-op #%d** — day %d/%d, combat locked for the duration of the run\n\n", - coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays)) - } else { - // Co-op teaser — show open runs the player could join. - if line := renderCoopTeaser(char); line != "" { - sb.WriteString(line) - sb.WriteString("\n") - } - } + // L3 closure announcement — surfaced for one-week post-deploy soak so + // active co-op participants see why the system is gone. Remove after + // 2026-05-16 (TODO: drop announcement block at that revisit). + sb.WriteString("📋 **Co-op dungeons closed for now** — `!expedition` is the way forward; a better co-op design is on the way.\n\n") // Rival nudge — a pending challenge waits for action. if line := renderRivalNudge(char); line != "" { @@ -416,9 +354,6 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq sb.WriteString("• `!expedition` — overview & open expeditions\n") sb.WriteString("• `!expedition start ` — begin a new run\n") sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n") - if inCoop { - sb.WriteString(fmt.Sprintf("_(combat is locked while you're in Co-op #%d — `!coop status` for run state)_\n", coopRun.ID)) - } sb.WriteString("\n") sb.WriteString("**🏘️ In town:**\n") diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 6e8f016..ccf1e71 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -355,30 +355,8 @@ func (p *AdventurePlugin) midnightReset() error { today := time.Now().UTC().Format("2006-01-02") yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") - coopMembers, err := activeCoopMemberSet() - if err != nil { - slog.Error("adventure: failed to load active coop members", "err", err) - coopMembers = map[string]bool{} - } - dmsSent := 0 for _, char := range chars { - // Active co-op members can't take manual actions (combat is locked - // to the coop run, harvest is optional). Their participation - // auto-resolves daily, so credit them with a streak day and skip - // idle/babysit logic — otherwise the lockCoopCombatActions sentinel - // (combat_actions_used = 99) trips HasActedToday and falls through - // to the streak-reset branch. - if coopMembers[string(char.UserID)] { - char.CurrentStreak++ - if char.CurrentStreak > char.BestStreak { - char.BestStreak = char.CurrentStreak - } - char.LastActionDate = today - _ = saveAdvCharacter(&char) - continue - } - if !char.HasActedToday() { // If the player died today or yesterday, they couldn't act — no shame, // no streak reset. This covers both currently-dead players and players @@ -434,13 +412,6 @@ func (p *AdventurePlugin) midnightReset() error { slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr) time.Sleep(time.Duration(attempt+1) * 2 * time.Second) } - if resetErr == nil { - // Re-lock combat for active co-op participants after the universal - // reset would otherwise have given them a fresh combat action. - if err := lockCoopCombatActions(); err != nil { - slog.Error("adventure: post-reset coop combat lock failed", "err", err) - } - } if resetErr != nil { return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr) } diff --git a/internal/plugin/cleanup_l3.go b/internal/plugin/cleanup_l3.go new file mode 100644 index 0000000..f5759e3 --- /dev/null +++ b/internal/plugin/cleanup_l3.go @@ -0,0 +1,111 @@ +package plugin + +import ( + "log/slog" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// closeAndRefundLegacyCoopRuns is the Phase L3 one-shot. On every startup +// it scans for any coop_dungeon_runs still in 'open' or 'active' status, +// refunds member contributions and unsettled bets via the wallet, and +// flips the run to 'cancelled'. +// +// Idempotent: once a run is 'cancelled' it is skipped on subsequent runs. +// The cancel UPDATE is per-run with a status-guard, so two parallel calls +// (or a crashed-then-restarted process) will not double-credit a member +// or bettor — only the writer that flips status from 'open'/'active' to +// 'cancelled' performs the refund for that run. +// +// Tables are NOT dropped here. Historical rows remain for querying; a +// future GOGOBEE_COOP_PURGE pass will drop the schema. +func closeAndRefundLegacyCoopRuns(euro *EuroPlugin) { + d := db.Get() + + rows, err := d.Query(`SELECT id FROM coop_dungeon_runs WHERE status IN ('open','active')`) + if err != nil { + slog.Error("coop L3 cleanup: list runs", "err", err) + return + } + var runIDs []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + slog.Error("coop L3 cleanup: scan run id", "err", err) + continue + } + runIDs = append(runIDs, id) + } + rows.Close() + + if len(runIDs) == 0 { + return + } + + totalRuns, totalMembers, totalBets := 0, 0, 0 + for _, runID := range runIDs { + res, err := d.Exec(`UPDATE coop_dungeon_runs + SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP + WHERE id = ? AND status IN ('open','active')`, runID) + if err != nil { + slog.Error("coop L3 cleanup: cancel run", "run_id", runID, "err", err) + continue + } + n, _ := res.RowsAffected() + if n != 1 { + continue + } + totalRuns++ + + mRows, err := d.Query(`SELECT user_id, total_contributed + FROM coop_dungeon_members + WHERE run_id = ? AND total_contributed > 0`, runID) + if err != nil { + slog.Error("coop L3 cleanup: load members", "run_id", runID, "err", err) + } else { + for mRows.Next() { + var uid string + var amt int + if err := mRows.Scan(&uid, &amt); err != nil { + slog.Error("coop L3 cleanup: scan member", "run_id", runID, "err", err) + continue + } + if euro != nil && amt > 0 { + euro.Credit(id.UserID(uid), float64(amt), "coop_l3_cancel_refund") + totalMembers++ + } + } + mRows.Close() + } + + bRows, err := d.Query(`SELECT player_id, amount + FROM coop_dungeon_bets + WHERE run_id = ? AND payout IS NULL`, runID) + if err != nil { + slog.Error("coop L3 cleanup: load bets", "run_id", runID, "err", err) + } else { + for bRows.Next() { + var uid string + var amt int + if err := bRows.Scan(&uid, &amt); err != nil { + slog.Error("coop L3 cleanup: scan bet", "run_id", runID, "err", err) + continue + } + if euro != nil && amt > 0 { + euro.Credit(id.UserID(uid), float64(amt), "coop_l3_cancel_bet_refund") + totalBets++ + } + } + bRows.Close() + } + } + + if totalRuns > 0 { + slog.Info("coop L3 cleanup: cancelled in-flight runs", + "runs_cancelled", totalRuns, + "member_refunds", totalMembers, + "bet_refunds", totalBets) + } +} diff --git a/internal/plugin/coop_dungeon.go b/internal/plugin/coop_dungeon.go deleted file mode 100644 index 45a39f2..0000000 --- a/internal/plugin/coop_dungeon.go +++ /dev/null @@ -1,535 +0,0 @@ -package plugin - -import ( - "fmt" - "log/slog" - "strconv" - "strings" - "time" - - "maunium.net/go/mautrix/id" -) - -// ── Balance Constants ─────────────────────────────────────────────────────── -// -// Phase 1 values. Adjust during balance pass. Co-op should feel meaningfully -// harder than solo and reward groups for showing up. - -type CoopFundingTier string - -const ( - CoopFundNone CoopFundingTier = "none" - CoopFundMinimal CoopFundingTier = "minimal" - CoopFundStandard CoopFundingTier = "standard" - CoopFundAggressive CoopFundingTier = "aggressive" - CoopFundAllIn CoopFundingTier = "all_in" -) - -type coopFundingDef struct { - cost int - modifier int // % added to per-floor success roll - label string -} - -var coopFundingTable = map[CoopFundingTier]coopFundingDef{ - CoopFundNone: {0, -10, "None"}, - CoopFundMinimal: {500, 0, "Minimal"}, - CoopFundStandard: {1500, 8, "Standard"}, - CoopFundAggressive: {4000, 18, "Aggressive"}, - CoopFundAllIn: {10000, 30, "All In"}, -} - -// fundingTierOrder for menu display. -var coopFundingOrder = []CoopFundingTier{ - CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn, -} - -type coopTierDef struct { - totalDays int - baseFailurePct int // base % failure per floor at zero modifier - rewardBase int // base reward (gold) before split - difficulty string // label - minLevel int // newbie liability threshold - lootMult float64 // for display only -} - -// Reward bases tuned via Monte Carlo (coop_dungeon_balance_test.go) so the -// optimal funding strategy walks up tiers: T1-T2 Minimal/Standard, T3 Standard, -// T4 Standard or Aggressive, T5 Aggressive only. All-In stays -EV at every -// tier — it's the boost lever for liability parties, not the optimal play. -var coopTierTable = map[int]coopTierDef{ - 1: {2, 25, 22500, "Moderate", 5, 2.0}, - 2: {3, 32, 40000, "Hard", 12, 3.5}, - 3: {4, 40, 72500, "Very Hard", 20, 5.5}, - 4: {5, 48, 120000, "Brutal", 30, 8.0}, - 5: {7, 58, 600000, "Murderous", 40, 12.0}, -} - -const ( - coopMinPartySize = 2 - coopMaxPartySize = 4 - coopInviteWindow = 24 * time.Hour - coopLiabilityCap = 8 // liability players' funding modifier capped at +8% regardless of tier - coopAdventureRake = 0.05 -) - -// ── Command Dispatch ──────────────────────────────────────────────────────── - -func (p *AdventurePlugin) handleCoopCmd(ctx MessageContext) error { - args := strings.TrimSpace(p.GetArgs(ctx.Body, "coop")) - lower := strings.ToLower(args) - - switch { - case args == "" || lower == "help": - return p.SendDM(ctx.Sender, coopHelpText) - case lower == "list": - return p.handleCoopList(ctx) - case lower == "status": - return p.handleCoopStatus(ctx) - case lower == "stats": - return p.SendDM(ctx.Sender, renderCoopStats()) - case strings.HasPrefix(lower, "start "): - return p.handleCoopStart(ctx, strings.TrimSpace(args[6:])) - case strings.HasPrefix(lower, "join"): - return p.handleCoopJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join"))) - case strings.HasPrefix(lower, "fund "): - return p.handleCoopFund(ctx, strings.TrimSpace(args[5:])) - case lower == "cancel": - return p.handleCoopCancel(ctx) - case strings.HasPrefix(lower, "vote "): - return p.handleCoopVote(ctx, strings.TrimSpace(args[5:])) - case strings.HasPrefix(lower, "bet "): - return p.handleCoopBet(ctx, strings.TrimSpace(args[4:])) - case strings.HasPrefix(lower, "gift "): - return p.handleCoopGift(ctx, strings.TrimSpace(args[5:])) - case strings.HasPrefix(lower, "giftvote "): - return p.handleCoopGiftVote(ctx, strings.TrimSpace(args[9:])) - case strings.HasPrefix(lower, "admgift "): - return p.handleCoopAdmGift(ctx, strings.TrimSpace(args[8:])) - } - - return p.SendDM(ctx.Sender, "Unknown coop command. Try `!coop help`.") -} - -const coopHelpText = `**Co-op Dungeon Commands** - -` + "`!coop list`" + ` — Show open invites -` + "`!coop start `" + ` — Open an invite for a co-op dungeon (tier 1-5) -` + "`!coop join []`" + ` — Join an open invite (defaults to most recent) -` + "`!coop fund `" + ` — Set today's funding (none/minimal/standard/aggressive/all_in) -` + "`!coop vote `" + ` — Vote on the day's floor event -` + "`!coop bet `" + ` — Spectator bet (parimutuel, 10% rake) -` + "`!coop gift `" + ` — Send a gift (1 harvest action) -` + "`!coop giftvote `" + ` — Party votes whether to open a gift -` + "`!coop status`" + ` — Show your current run -` + "`!coop stats`" + ` — Aggregate stats: outcomes, gold flow, betting, gifts, TwinBee helpfulness -` + "`!coop cancel`" + ` — Cancel an open invite (leader only, before lock) - -**How it runs:** -- 24h invite window. Run locks automatically when window closes. -- 2-4 players. 2 minimum or invite cancels. -- Locking the party consumes the day's combat action for every member. -- Each subsequent day, every player picks a funding tier. Funding modifies - the party's per-floor success chance. -- Inactive players (no funding decision) auto-play at None (-10%). -- Wipes refund the day's combat action only. Funding is gone. -- On success, the reward is split evenly. Tax applies.` - -// ── Handlers ──────────────────────────────────────────────────────────────── - -func (p *AdventurePlugin) handleCoopStart(ctx MessageContext, tierArg string) error { - tier, err := strconv.Atoi(tierArg) - if err != nil || tier < 1 || tier > 5 { - return p.SendDM(ctx.Sender, "Tier must be 1-5. Example: `!coop start 3`.") - } - - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - char, _, err := p.ensureCharacter(ctx.Sender) - if err != nil { - return p.SendDM(ctx.Sender, "Failed to load your character.") - } - if !char.Alive { - return p.SendDM(ctx.Sender, "You're dead. Co-op dungeons require a living adventurer.") - } - if !char.CanDoCombat(false) { - return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.") - } - - // One active run per player. - if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil { - return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in a co-op run (#%d, %s). Use `!coop status`.", existing.ID, existing.Status)) - } - - def := coopTierTable[tier] - run, err := createCoopRun(tier, ctx.Sender, def.totalDays, def.baseFailurePct) - if err != nil { - slog.Error("coop: create run", "err", err) - return p.SendDM(ctx.Sender, "Couldn't open an invite. Try again.") - } - if err := addCoopMember(run.ID, ctx.Sender, 0, char.CombatLevel < def.minLevel); err != nil { - slog.Error("coop: add leader", "err", err) - return p.SendDM(ctx.Sender, "Couldn't add you to the run.") - } - - gr := gamesRoom() - if gr != "" { - postID, err := p.SendMessageID(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName)) - if err == nil { - _ = setCoopRunInvitePostID(run.ID, postID) - _ = p.PinEvent(gr, postID) - } - } - return p.SendDM(ctx.Sender, fmt.Sprintf("⚔️ Tier %d co-op invite opened (#%d). Locks in 24h. Recruit your party.", tier, run.ID)) -} - -func (p *AdventurePlugin) handleCoopJoin(ctx MessageContext, runIDArg string) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - char, _, err := p.ensureCharacter(ctx.Sender) - if err != nil { - return p.SendDM(ctx.Sender, "Failed to load your character.") - } - if !char.Alive { - return p.SendDM(ctx.Sender, "You're dead. The dungeon does not accept corpses.") - } - if !char.CanDoCombat(false) { - return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.") - } - if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil { - return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in run #%d.", existing.ID)) - } - - var run *CoopRun - if runIDArg == "" { - run, err = loadMostRecentOpenCoopRun() - } else { - id, perr := strconv.Atoi(runIDArg) - if perr != nil { - return p.SendDM(ctx.Sender, "Run ID must be a number. Try `!coop join 7` or `!coop join`.") - } - run, err = loadCoopRun(id) - } - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "No open invite found. Use `!coop list` to see open runs.") - } - if run.Status != "open" { - return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s. Can't join.", run.ID, run.Status)) - } - - members, err := loadCoopMembers(run.ID) - if err != nil { - return p.SendDM(ctx.Sender, "Couldn't load the party.") - } - if len(members) >= coopMaxPartySize { - return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is full (%d/%d).", run.ID, len(members), coopMaxPartySize)) - } - - def := coopTierTable[run.Tier] - liability := char.CombatLevel < def.minLevel - if err := addCoopMember(run.ID, ctx.Sender, len(members), liability); err != nil { - slog.Error("coop: join", "err", err) - return p.SendDM(ctx.Sender, "Couldn't join.") - } - - gr := gamesRoom() - if gr != "" { - warn := "" - if liability { - warn = " ⚠️ liability" - } - _ = p.SendMessage(gr, fmt.Sprintf("⚔️ %s joined Tier %d Co-op #%d (%d/%d)%s.", - char.DisplayName, run.Tier, run.ID, len(members)+1, coopMaxPartySize, warn)) - } - return p.SendDM(ctx.Sender, fmt.Sprintf("Joined run #%d. Wait for the lock; the run starts then.", run.ID)) -} - -func (p *AdventurePlugin) handleCoopList(ctx MessageContext) error { - runs, err := loadOpenCoopRuns() - if err != nil { - return p.SendDM(ctx.Sender, "Couldn't load the list.") - } - if len(runs) == 0 { - return p.SendDM(ctx.Sender, "No open invites right now. Start one with `!coop start `.") - } - var sb strings.Builder - sb.WriteString("**Open Co-op Invites**\n\n") - for _, r := range runs { - members, _ := loadCoopMembers(r.ID) - closesIn := time.Until(r.CreatedAt.Add(coopInviteWindow)).Truncate(time.Minute) - sb.WriteString(fmt.Sprintf(" #%d T%d (%s) %d/%d closes in %s\n", - r.ID, r.Tier, coopTierTable[r.Tier].difficulty, len(members), coopMaxPartySize, closesIn)) - } - sb.WriteString("\nJoin with `!coop join ` or just `!coop join` for the most recent.") - return p.SendDM(ctx.Sender, sb.String()) -} - -func (p *AdventurePlugin) handleCoopStatus(ctx MessageContext) error { - run, err := loadCoopRunForUser(ctx.Sender) - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "You're not in a co-op run. Use `!coop list` or `!coop start `.") - } - members, _ := loadCoopMembers(run.ID) - return p.SendDM(ctx.Sender, renderCoopStatus(p, run, members)) -} - -func (p *AdventurePlugin) handleCoopFund(ctx MessageContext, tierArg string) error { - tier := parseCoopFundingTier(tierArg) - if tier == "" { - return p.SendDM(ctx.Sender, "Funding must be one of: none, minimal, standard, aggressive, all_in.") - } - - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - run, err := loadCoopRunForUser(ctx.Sender) - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "You're not in an active co-op run.") - } - if run.Status != "active" { - return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — funding decisions don't apply.", run.ID, run.Status)) - } - - day := run.CurrentDay - if day < 1 { - return p.SendDM(ctx.Sender, "The run hasn't started yet. Wait for the lock tick.") - } - - member, err := loadCoopMember(run.ID, ctx.Sender) - if err != nil || member == nil { - return p.SendDM(ctx.Sender, "You're not on the party roster for this run.") - } - if _, alreadySet := member.DailyFunding[day]; alreadySet { - return p.SendDM(ctx.Sender, fmt.Sprintf("You've already locked in funding for day %d. Decisions are final until the daily tick.", day)) - } - - cost := coopFundingTable[tier].cost - if cost > 0 { - balance := p.euro.GetBalance(ctx.Sender) - if balance < float64(cost) { - return p.SendDM(ctx.Sender, fmt.Sprintf("That tier costs €%d. You have €%.0f.", cost, balance)) - } - if !p.euro.Debit(ctx.Sender, float64(cost), "coop_funding") { - return p.SendDM(ctx.Sender, "Payment failed.") - } - } - - member.DailyFunding[day] = tier - member.TotalContributed += cost - if err := saveCoopMemberFunding(member); err != nil { - // Refund and surface - if cost > 0 { - p.euro.Credit(ctx.Sender, float64(cost), "coop_funding_refund") - } - return p.SendDM(ctx.Sender, "Couldn't save your decision. Gold refunded if any was charged.") - } - if cost > 0 { - if err := addCoopRunPool(run.ID, cost); err != nil { - slog.Error("coop: pool update", "err", err) - } - } - - mod := coopFundingTable[tier].modifier - return p.SendDM(ctx.Sender, fmt.Sprintf("Funding for day %d locked: %s (€%d, %+d%%). Tomorrow's tick resolves the floor.", - day, coopFundingTable[tier].label, cost, mod)) -} - -func (p *AdventurePlugin) handleCoopCancel(ctx MessageContext) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - run, err := loadCoopRunForUser(ctx.Sender) - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "No co-op run to cancel.") - } - if run.LeaderID != ctx.Sender { - return p.SendDM(ctx.Sender, "Only the party leader can cancel.") - } - if run.Status != "open" { - return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d already %s — too late.", run.ID, run.Status)) - } - if err := setCoopRunStatus(run.ID, "cancelled"); err != nil { - return p.SendDM(ctx.Sender, "Couldn't cancel.") - } - gr := gamesRoom() - if gr != "" { - if run.InvitePostID != "" { - _ = p.UnpinEvent(gr, run.InvitePostID) - } - _ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d cancelled by the leader.", run.Tier, run.ID)) - } - return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d cancelled.", run.ID)) -} - -// handleCoopAdmGift is an admin-only debug path that creates a gift on -// behalf of the caller (or a specified sender) without the harvest-action -// or party-membership checks. Useful for testing the gift mechanic without -// recruiting a non-party spectator. -// -// Usage: !coop admgift [sender_user_id] -func (p *AdventurePlugin) handleCoopAdmGift(ctx MessageContext, args string) error { - if !p.IsAdmin(ctx.Sender) { - return nil - } - parts := strings.Fields(args) - if len(parts) < 2 { - return p.SendDM(ctx.Sender, "Usage: `!coop admgift [sender_user_id]`. Admin-only — bypasses party-member and harvest checks.") - } - runID, err := strconv.Atoi(parts[0]) - if err != nil { - return p.SendDM(ctx.Sender, "Run ID must be a number.") - } - giftType := strings.ToLower(parts[1]) - if giftType != coopGiftBasket && giftType != coopGiftMimic { - return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.") - } - sender := ctx.Sender - if len(parts) >= 3 { - sender = id.UserID(parts[2]) - } - - run, err := loadCoopRun(runID) - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "Run not found.") - } - if run.Status != "active" { - return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — gifts only land during active runs.", runID, run.Status)) - } - - giftID, leadID, isNewLead, err := createCoopGift(runID, sender, run.CurrentDay, giftType) - if err != nil { - return p.SendDM(ctx.Sender, "Couldn't create gift.") - } - - gr := gamesRoom() - if gr != "" { - members, _ := loadCoopMembers(runID) - if isNewLead { - gift, _ := loadCoopGift(giftID) - postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members)) - if perr == nil { - _ = saveCoopGiftPostID(giftID, postID) - } - } else { - lead, _ := loadCoopGift(leadID) - if lead != nil && lead.PostEventID != "" { - _ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members)) - } - } - } - - return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Admin gift dispatched: %s from %s to run #%d (day %d). Gift id #%d (stack lead %d). No harvest action consumed.", - giftType, sender, runID, run.CurrentDay, giftID, leadID)) -} - -func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - run, err := loadCoopRunForUser(ctx.Sender) - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "You're not in an active co-op run.") - } - if run.Status != "active" { - return p.SendDM(ctx.Sender, "No active floor event to vote on.") - } - event, err := loadCoopEvent(run.ID, run.CurrentDay) - if err != nil || event == nil { - return p.SendDM(ctx.Sender, "No floor event found for the current day.") - } - if event.Outcome != "" { - return p.SendDM(ctx.Sender, "Voting closed for this floor.") - } - label, ok := validateCoopVote(event.Category, event.EventIndex, voteArg) - if !ok { - return p.SendDM(ctx.Sender, fmt.Sprintf("Vote one of: %s.", - strings.Join(coopEventOptionLabels(event.Category, event.EventIndex), ", "))) - } - - if event.Votes == nil { - event.Votes = map[id.UserID]string{} - } - event.Votes[ctx.Sender] = label - if err := saveCoopEventVotes(event); err != nil { - return p.SendDM(ctx.Sender, "Couldn't save your vote.") - } - - // Update the game-room post in place to show the running tally. - gr := gamesRoom() - if gr != "" && event.PostEventID != "" { - members, _ := loadCoopMembers(run.ID) - _ = p.EditMessage(gr, event.PostEventID, renderCoopEventPost(run, members, event)) - } - return p.SendDM(ctx.Sender, fmt.Sprintf("Vote recorded: %s.", label)) -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -func parseCoopFundingTier(s string) CoopFundingTier { - s = strings.ToLower(strings.TrimSpace(s)) - s = strings.ReplaceAll(s, "-", "_") - s = strings.ReplaceAll(s, " ", "_") - switch s { - case "none", "skip", "pass": - return CoopFundNone - case "minimal", "min": - return CoopFundMinimal - case "standard", "std": - return CoopFundStandard - case "aggressive", "agg": - return CoopFundAggressive - case "all_in", "allin", "all": - return CoopFundAllIn - } - return "" -} - -// effectiveModifier returns a member's per-day funding modifier, respecting the -// liability cap. -func coopMemberModifier(m *CoopMember, day int) (CoopFundingTier, int) { - tier, ok := m.DailyFunding[day] - if !ok { - tier = CoopFundNone - } - mod := coopFundingTable[tier].modifier - if m.IsLiability && mod > coopLiabilityCap { - mod = coopLiabilityCap - } - return tier, mod -} - -// coopLevelBonus returns the per-floor success bonus from a player's combat -// level relative to the tier's minimum. Levels above the floor count up -// (capped); levels below count down. Encourages tier-appropriate parties. -func coopLevelBonus(combatLevel, tierMinLevel int) int { - delta := combatLevel - tierMinLevel - bonus := delta * 4 / 10 // 0.4× scale, integer math - if bonus > 8 { - bonus = 8 - } - if bonus < -8 { - bonus = -8 - } - return bonus -} - -// coopPetBonus returns the per-floor success bonus from a player's pet. Worth -// roughly a quarter of an additional baseline party member at pet level 10. -// Returns 0 if the player has no pet. -func coopPetBonus(char *AdventureCharacter) int { - if !char.HasPet() || char.PetLevel <= 0 { - return 0 - } - bonus := char.PetLevel * 25 / 100 // 0.25× scale - if bonus > 2 { - bonus = 2 - } - return bonus -} diff --git a/internal/plugin/coop_dungeon_balance_test.go b/internal/plugin/coop_dungeon_balance_test.go deleted file mode 100644 index e91c1b5..0000000 --- a/internal/plugin/coop_dungeon_balance_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package plugin - -import ( - "fmt" - "math/rand/v2" - "testing" -) - -// Monte Carlo balance analysis for co-op dungeons. -// -// Run with: go test ./internal/plugin -run TestCoopBalanceReport -v -// Skipped by default (requires -run filter to invoke); pure-function, no DB. - -const ( - balanceTrials = 50_000 - balanceTax = 0.05 // matches coopAdventureRake -) - -type fundingPlan struct { - name string - // per-player tiers; length = party size - tiers []CoopFundingTier - // optional fixed per-player level bonus (mimics a profile of avg or - // veteran party). 0 = at-minimum (default), positive = stronger party. - levelBonusEach int - petBonusEach int -} - -func TestCoopBalanceReport(t *testing.T) { - if testing.Short() { - t.Skip("balance report skipped in short mode") - } - t.Parallel() - - rng := rand.New(rand.NewPCG(42, 42)) - - profiles := []struct { - label string - levelBonus int - petBonus int - }{ - {"at-minimum (no pets)", 0, 0}, - {"average (level+5, pet 5)", 2, 1}, - {"veteran (level+10, pet 10)", 4, 2}, - } - - for tier := 1; tier <= 5; tier++ { - def := coopTierTable[tier] - fmt.Printf("\n══ Tier %d (%s) — %d days, base failure %d%%/floor, reward €%d ══\n", - tier, def.difficulty, def.totalDays, def.baseFailurePct, def.rewardBase) - - for _, prof := range profiles { - fmt.Printf("\n Party profile: %s\n", prof.label) - fmt.Printf(" %-28s %-10s %-12s %-12s %-12s %-12s\n", - "strategy", "P(win)", "E[reward]", "E[funding]", "E[net]", "E[net/day]") - for _, plan := range balancePlans() { - plan.levelBonusEach = prof.levelBonus - plan.petBonusEach = prof.petBonus - pSuccess, eReward, eCost, eNet := simulatePlan(rng, tier, def, plan) - perDay := eNet / float64(def.totalDays) - fmt.Printf(" %-28s %-10.3f €%-11.0f €%-11.0f €%-11.0f €%-11.0f\n", - plan.name, pSuccess, eReward, eCost, eNet, perDay) - } - } - } - fmt.Println() - fmt.Println("Note: E[net] is per-player. Funding is non-refundable. Combat-action opportunity cost not included.") -} - -func anyNoneFunder(plan fundingPlan) bool { - for _, t := range plan.tiers { - if t == CoopFundNone { - return true - } - } - return false -} - -// balancePlans returns a representative set of party funding strategies. -// Party size = 4 unless name says otherwise. -func balancePlans() []fundingPlan { - mk := func(n int, t CoopFundingTier) []CoopFundingTier { - out := make([]CoopFundingTier, n) - for i := range out { - out[i] = t - } - return out - } - return []fundingPlan{ - {name: "4× Minimal", tiers: mk(4, CoopFundMinimal)}, - {name: "4× Standard", tiers: mk(4, CoopFundStandard)}, - {name: "4× Aggressive", tiers: mk(4, CoopFundAggressive)}, - {name: "4× All-In", tiers: mk(4, CoopFundAllIn)}, - {name: "4× mixed (2 Std, 2 Agg)", tiers: []CoopFundingTier{CoopFundStandard, CoopFundStandard, CoopFundAggressive, CoopFundAggressive}}, - {name: "4× sandbag (3 Std, 1 None)", tiers: []CoopFundingTier{CoopFundStandard, CoopFundStandard, CoopFundStandard, CoopFundNone}}, - {name: "3× Standard", tiers: mk(3, CoopFundStandard)}, - {name: "3× Aggressive", tiers: mk(3, CoopFundAggressive)}, - {name: "2× Standard", tiers: mk(2, CoopFundStandard)}, - {name: "2× Aggressive", tiers: mk(2, CoopFundAggressive)}, - {name: "2× All-In", tiers: mk(2, CoopFundAllIn)}, - } -} - -// simulatePlan runs the trials and returns: -// -// P(success), E[reward share post-tax], E[funding spent per player], E[net per player] -// -// Funding is paid every day regardless of wipe. Reward is split evenly across -// the party on success only. Per-player figures average the contribution across -// the actual party members under the plan. -func simulatePlan(rng *rand.Rand, tier int, def coopTierDef, plan fundingPlan) (float64, float64, float64, float64) { - partySize := len(plan.tiers) - if partySize < coopMinPartySize { - return 0, 0, 0, 0 - } - - // Compute per-floor success% under this plan (deterministic given plan). - totalMod := 0 - for _, t := range plan.tiers { - totalMod += coopFundingTable[t].modifier - } - // Per-player level + pet bonuses applied across the whole party. - totalMod += partySize * (plan.levelBonusEach + plan.petBonusEach) - successPct := 100 - def.baseFailurePct + totalMod - if successPct < 5 { - successPct = 5 - } - if successPct > 95 { - successPct = 95 - } - - wins := 0 - for trial := 0; trial < balanceTrials; trial++ { - runWon := true - for floor := 0; floor < def.totalDays; floor++ { - if rng.IntN(100) >= successPct { - runWon = false - break - } - } - if runWon { - wins++ - } - } - pWin := float64(wins) / float64(balanceTrials) - - // Average per-player numbers across the party. Funding cost = daily cost × days, - // paid regardless of outcome. Reward = share post-tax × P(win). - var sumCost, sumReward float64 - share := float64(def.rewardBase) / float64(partySize) - postTaxShare := share * (1 - balanceTax) - for _, t := range plan.tiers { - dailyCost := float64(coopFundingTable[t].cost) - sumCost += dailyCost * float64(def.totalDays) - sumReward += postTaxShare * pWin - } - avgCost := sumCost / float64(partySize) - avgReward := sumReward / float64(partySize) - avgNet := avgReward - avgCost - return pWin, avgReward, avgCost, avgNet -} - -// TestSoloVsCoopDailyIncome compares expected gold-per-day for a competent -// solo dungeon grinder vs a co-op party member at the optimal funding strategy. -// Co-op should be meaningfully more rewarding per day than solo. -// -// Solo model: a "competent" player whose skill matches the location's MinLevel -// and gear matches MinEquipTier. Uses the actual loot tables from -// adventure_activities.go. -func TestSoloVsCoopDailyIncome(t *testing.T) { - if testing.Short() { - t.Skip() - } - - // Per-tier solo expected income (avg item value × items-per-haul, weighted - // by outcome probabilities, after 5% community tax, minus a rough death - // cost that captures gear repair + hospital). - type soloPerTier struct { - successPct, exceptionalPct, deathPct float64 - successHaul, exceptionalHaul float64 - deathCost float64 - } - // Probabilities computed from calculateAdvProbabilities() with skill = - // MinLevel and eqScore matching MinEquipTier (rough but consistent). - // Death costs assume hospital insurance + lower blacksmith repair rates. - solo := map[int]soloPerTier{ - 1: {0.74, 0.10, 0.01, 11.6, 29.0, 100}, - 2: {0.67, 0.10, 0.08, 63.75, 159.0, 400}, - 3: {0.60, 0.10, 0.18, 337.5, 844.0, 1200}, - 4: {0.55, 0.08, 0.21, 1700.0, 4250.0, 3000}, - 5: {0.61, 0.08, 0.20, 9500.0, 23750.0, 6000}, - } - soloDaily := func(s soloPerTier) float64 { - gross := s.successPct*s.successHaul + s.exceptionalPct*s.exceptionalHaul - afterTax := gross * (1 - balanceTax) - return afterTax - s.deathPct*s.deathCost - } - - rng := rand.New(rand.NewPCG(7, 7)) - - fmt.Println("\nSolo dungeon vs co-op (€/day per player at optimal funding strategy):") - fmt.Printf("%-6s %-14s %-26s %-14s %-10s\n", "tier", "solo €/day", "co-op optimal", "co-op €/day", "ratio") - for tier := 1; tier <= 5; tier++ { - def := coopTierTable[tier] - soloIncome := soloDaily(solo[tier]) - - // Find best 4-player plan by E[net]/day, assuming an "average" party - // profile (levelBonus=2, petBonus=1 per player). Restricting to - // 4-player keeps the comparison apples-to-apples. - var bestPlan fundingPlan - bestPerDay := -1.0e18 - for _, plan := range balancePlans() { - if len(plan.tiers) != 4 { - continue - } - // Skip free-rider plans: an "optimal" that only works because - // one member contributes nothing isn't a coordinated strategy. - if anyNoneFunder(plan) { - continue - } - plan.levelBonusEach = 2 - plan.petBonusEach = 1 - _, _, _, eNet := simulatePlan(rng, tier, def, plan) - perDay := eNet / float64(def.totalDays) - if perDay > bestPerDay { - bestPerDay = perDay - bestPlan = plan - } - } - ratio := bestPerDay / soloIncome - flag := "✓" - if ratio < 1.5 { - flag = "⚠ insufficient" - } - fmt.Printf("T%-5d €%-13.0f %-26s €%-13.0f %.2fx %s\n", - tier, soloIncome, bestPlan.name, bestPerDay, ratio, flag) - } - fmt.Println("\n⚠ marker = co-op fails the 1.5× solo threshold; bump rewardBase.") -} - -// TestCoopBalanceSweep prints, for each tier, the success% needed for E[net]>=0 -// at common funding levels. Helps spot tiers where the formula is upside-down. -func TestCoopBalanceSweep(t *testing.T) { - if testing.Short() { - t.Skip() - } - fmt.Println("\nBreakeven analysis — minimum P(win) for E[net]≥0 per player at party size 4:") - fmt.Printf("%-12s %-12s %-12s %-12s %-12s\n", "tier", "Minimal", "Standard", "Aggressive", "All-In") - for tier := 1; tier <= 5; tier++ { - def := coopTierTable[tier] - share := float64(def.rewardBase) / 4.0 * (1 - balanceTax) - row := []string{} - for _, ft := range []CoopFundingTier{CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn} { - cost := float64(coopFundingTable[ft].cost) * float64(def.totalDays) - breakeven := cost / share - if breakeven > 1 { - row = append(row, ">100% (impossible)") - } else { - row = append(row, fmt.Sprintf("%.1f%%", breakeven*100)) - } - } - fmt.Printf("T%-11d %-12s %-12s %-12s %-12s\n", tier, row[0], row[1], row[2], row[3]) - } - fmt.Println() -} diff --git a/internal/plugin/coop_dungeon_betting.go b/internal/plugin/coop_dungeon_betting.go deleted file mode 100644 index af2023d..0000000 --- a/internal/plugin/coop_dungeon_betting.go +++ /dev/null @@ -1,345 +0,0 @@ -package plugin - -import ( - "database/sql" - "fmt" - "math" - "strconv" - "strings" - - "gogobee/internal/db" - - "maunium.net/go/mautrix/id" -) - -// ── Betting Constants ─────────────────────────────────────────────────────── - -const ( - coopBetMin = 100 - coopBetMax = 1_000_000 - coopBetRake = 0.10 // 10% house cut, per spec - coopOddsWindow = 30 // helpfulness rolling-window size - coopOddsHelpInf = 0.20 // helpfulness contribution to estimated success -) - -// ── Bet Types ─────────────────────────────────────────────────────────────── - -type CoopBet struct { - RunID int - PlayerID id.UserID - Position string // "success" | "failure" - Amount int - Payout sql.NullInt64 -} - -// ── Command Handler ───────────────────────────────────────────────────────── - -func (p *AdventurePlugin) handleCoopBet(ctx MessageContext, args string) error { - parts := strings.Fields(args) - if len(parts) < 2 { - return p.SendDM(ctx.Sender, "Usage: `!coop bet ` or `!coop bet ` for the most recent open/active run.") - } - - // Two forms: with or without explicit run id. - var runID, amount int - var position string - var err error - - if len(parts) >= 3 { - runID, err = strconv.Atoi(parts[0]) - if err != nil { - return p.SendDM(ctx.Sender, "First arg must be the run id.") - } - amount, err = strconv.Atoi(parts[1]) - if err != nil { - return p.SendDM(ctx.Sender, "Amount must be a number.") - } - position = strings.ToLower(parts[2]) - } else { - amount, err = strconv.Atoi(parts[0]) - if err != nil { - return p.SendDM(ctx.Sender, "Amount must be a number.") - } - position = strings.ToLower(parts[1]) - run, _ := loadMostRecentBettableCoopRun() - if run == nil { - return p.SendDM(ctx.Sender, "No co-op runs accept bets right now.") - } - runID = run.ID - } - - if position != "success" && position != "failure" { - return p.SendDM(ctx.Sender, "Position must be `success` or `failure`.") - } - if amount < coopBetMin || amount > coopBetMax { - return p.SendDM(ctx.Sender, fmt.Sprintf("Bet must be between €%d and €%d.", coopBetMin, coopBetMax)) - } - - run, err := loadCoopRun(runID) - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "Run not found.") - } - if run.Status != "open" && run.Status != "active" { - return p.SendDM(ctx.Sender, "Bets are closed on this run.") - } - - balance := p.euro.GetBalance(ctx.Sender) - if balance < float64(amount) { - return p.SendDM(ctx.Sender, fmt.Sprintf("You have €%.0f. Bet was €%d.", balance, amount)) - } - - existing, _ := loadCoopBet(runID, ctx.Sender) - if existing != nil && existing.Position != position { - return p.SendDM(ctx.Sender, fmt.Sprintf("You already bet %s on this run. Positions can't be switched.", existing.Position)) - } - - if !p.euro.Debit(ctx.Sender, float64(amount), "coop_bet") { - return p.SendDM(ctx.Sender, "Payment failed.") - } - - if existing == nil { - err = createCoopBet(runID, ctx.Sender, position, amount) - } else { - err = increaseCoopBet(runID, ctx.Sender, amount) - } - if err != nil { - p.euro.Credit(ctx.Sender, float64(amount), "coop_bet_refund") - return p.SendDM(ctx.Sender, "Couldn't save bet. Refunded.") - } - - total := amount - if existing != nil { - total += existing.Amount - } - return p.SendDM(ctx.Sender, fmt.Sprintf("📊 Bet placed: €%d on %s (run #%d). Total stake: €%d.", - amount, position, runID, total)) -} - -// ── Odds Calculation ──────────────────────────────────────────────────────── - -// coopEstimatedRunSuccess returns the estimated P(success) for the rest of the -// run given current party state. Used for spectator odds display. -func coopEstimatedRunSuccess(run *CoopRun, members []CoopMember) float64 { - if run.Status != "open" && run.Status != "active" { - return 0 - } - if len(members) == 0 { - return 0 - } - tierDef := coopTierTable[run.Tier] - - // Per-floor success at "Standard" funding by all members (a neutral - // baseline — actual funding varies day to day). - mod := len(members) * coopFundingTable[CoopFundStandard].modifier - for _, m := range members { - char, err := loadAdvCharacter(m.UserID) - if err != nil { - continue - } - mod += coopLevelBonus(char.CombatLevel, tierDef.minLevel) - mod += coopPetBonus(char) - } - successPct := 100 - run.BaseDifficulty + mod - - // TwinBee helpfulness shifts the line: a "helpful" history (close to 1) - // bumps odds; an "unhelpful" history pulls them down. - help := coopTwinBeeHelpfulness(coopOddsWindow) - successPct += int(math.Round((help - 0.5) * 2 * coopOddsHelpInf * 100)) - - if successPct < 5 { - successPct = 5 - } - if successPct > 95 { - successPct = 95 - } - - floorsRemaining := run.TotalDays - run.CurrentDay - if floorsRemaining < 0 { - floorsRemaining = 0 - } - if run.Status == "open" { - floorsRemaining = run.TotalDays - } - - return math.Pow(float64(successPct)/100.0, float64(floorsRemaining)) -} - -// coopParimutuelPayouts returns winning side, losers' side, rake, and -// per-player payout map (winning bettors only). -func coopParimutuelPayouts(bets []CoopBet, winningPosition string) (winners []CoopBet, totalPool, rake int, payouts map[id.UserID]int) { - for _, b := range bets { - totalPool += b.Amount - } - rake = int(math.Floor(float64(totalPool) * coopBetRake)) - netPool := totalPool - rake - - winningPool := 0 - for _, b := range bets { - if b.Position == winningPosition { - winners = append(winners, b) - winningPool += b.Amount - } - } - - payouts = map[id.UserID]int{} - if winningPool == 0 { - // No winners — house keeps everything (this happens if e.g. all bets - // were on the wrong side). - return - } - for _, b := range winners { - share := float64(b.Amount) / float64(winningPool) - payouts[b.PlayerID] = int(math.Floor(share * float64(netPool))) - } - return -} - -// ── DB ────────────────────────────────────────────────────────────────────── - -func createCoopBet(runID int, userID id.UserID, position string, amount int) error { - d := db.Get() - _, err := d.Exec(`INSERT INTO coop_dungeon_bets (run_id, player_id, position, amount) - VALUES (?, ?, ?, ?)`, runID, string(userID), position, amount) - return err -} - -func increaseCoopBet(runID int, userID id.UserID, delta int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_bets SET amount = amount + ? - WHERE run_id = ? AND player_id = ?`, delta, runID, string(userID)) - return err -} - -func loadCoopBet(runID int, userID id.UserID) (*CoopBet, error) { - d := db.Get() - b := &CoopBet{} - var uid string - err := d.QueryRow(`SELECT run_id, player_id, position, amount, payout - FROM coop_dungeon_bets WHERE run_id = ? AND player_id = ?`, runID, string(userID)).Scan( - &b.RunID, &uid, &b.Position, &b.Amount, &b.Payout) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - b.PlayerID = id.UserID(uid) - return b, nil -} - -func loadCoopBets(runID int) ([]CoopBet, error) { - d := db.Get() - rows, err := d.Query(`SELECT run_id, player_id, position, amount, payout - FROM coop_dungeon_bets WHERE run_id = ?`, runID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CoopBet - for rows.Next() { - b := CoopBet{} - var uid string - if err := rows.Scan(&b.RunID, &uid, &b.Position, &b.Amount, &b.Payout); err != nil { - return nil, err - } - b.PlayerID = id.UserID(uid) - out = append(out, b) - } - return out, rows.Err() -} - -func setCoopBetPayout(runID int, userID id.UserID, payout int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ? - WHERE run_id = ? AND player_id = ?`, payout, runID, string(userID)) - return err -} - -// claimCoopBetPayout atomically claims a bet for payout: returns true only if -// this call wins the race (the row had payout IS NULL and we set it). Used to -// prevent double-credit on retry after a mid-resolution crash. -func claimCoopBetPayout(runID int, userID id.UserID, payout int) (bool, error) { - d := db.Get() - res, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ? - WHERE run_id = ? AND player_id = ? AND payout IS NULL`, - payout, runID, string(userID)) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} - -func loadMostRecentBettableCoopRun() (*CoopRun, error) { - runs, err := queryCoopRuns(`status IN ('open','active') ORDER BY id DESC LIMIT 1`) - if err != nil || len(runs) == 0 { - return nil, err - } - return runs[0], nil -} - -// ── Resolution ────────────────────────────────────────────────────────────── - -// coopResolveBets is called when a run completes. It computes payouts, -// credits winners, persists payout values, and posts a summary line. -func (p *AdventurePlugin) coopResolveBets(run *CoopRun, won bool) string { - bets, err := loadCoopBets(run.ID) - if err != nil || len(bets) == 0 { - return "" - } - winningPosition := "failure" - if won { - winningPosition = "success" - } - winners, total, rake, payouts := coopParimutuelPayouts(bets, winningPosition) - - // Idempotent: claim each bet's payout slot (UPDATE...WHERE payout IS NULL) - // before crediting. If the claim fails, this bet was already paid by a - // prior resolution attempt — skip the credit. Prevents double-pay on - // crash-restart. - for _, b := range winners { - payout := payouts[b.PlayerID] - claimed, err := claimCoopBetPayout(run.ID, b.PlayerID, payout) - if err != nil || !claimed { - continue - } - if payout > 0 { - p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout") - } - } - for _, b := range bets { - if b.Position != winningPosition && !b.Payout.Valid { - _ = setCoopBetPayout(run.ID, b.PlayerID, 0) - } - } - - if len(winners) == 0 { - return fmt.Sprintf("📊 Parimutuel pool: €%d. No winners — house (TwinBee) keeps the pool.", total) - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("📊 Parimutuel pool: €%d. Rake: €%d. Net: €%d.\n", total, rake, total-rake)) - sb.WriteString(fmt.Sprintf("Winners (bet on %s):\n", winningPosition)) - for _, b := range winners { - sb.WriteString(fmt.Sprintf(" %s: €%d → €%d\n", coopDisplayName(p, b.PlayerID), b.Amount, payouts[b.PlayerID])) - } - return sb.String() -} - -// ── Render ────────────────────────────────────────────────────────────────── - -func renderCoopOddsLine(run *CoopRun, members []CoopMember, bets []CoopBet) string { - pSuccess := coopEstimatedRunSuccess(run, members) - successPool, failurePool := 0, 0 - for _, b := range bets { - if b.Position == "success" { - successPool += b.Amount - } else { - failurePool += b.Amount - } - } - total := successPool + failurePool - return fmt.Sprintf("📊 Estimated success: %.0f%%. Pool €%d (success €%d / failure €%d). Rake %.0f%%. `!coop bet success|failure`.", - pSuccess*100, total, successPool, failurePool, coopBetRake*100) -} - diff --git a/internal/plugin/coop_dungeon_db.go b/internal/plugin/coop_dungeon_db.go deleted file mode 100644 index 23ed04b..0000000 --- a/internal/plugin/coop_dungeon_db.go +++ /dev/null @@ -1,502 +0,0 @@ -package plugin - -import ( - "database/sql" - "encoding/json" - "fmt" - "log/slog" - "strconv" - "time" - - "gogobee/internal/db" - - "maunium.net/go/mautrix/id" -) - -type CoopRun struct { - ID int - Tier int - Status string - LeaderID id.UserID - CurrentDay int - TotalDays int - BaseDifficulty int - GoldPool int - RewardTotal int - LastResolvedDay int - InvitePostID id.EventID - CreatedAt time.Time - LockedAt *time.Time - CompletedAt *time.Time -} - -type CoopMember struct { - RunID int - UserID id.UserID - TurnOrder int - TotalContributed int - IsLiability bool - DailyFunding map[int]CoopFundingTier // day → tier -} - -func createCoopRun(tier int, leader id.UserID, totalDays, baseDifficulty int) (*CoopRun, error) { - d := db.Get() - res, err := d.Exec(`INSERT INTO coop_dungeon_runs (tier, leader_id, total_days, base_difficulty) - VALUES (?, ?, ?, ?)`, tier, string(leader), totalDays, baseDifficulty) - if err != nil { - return nil, err - } - id64, _ := res.LastInsertId() - return loadCoopRun(int(id64)) -} - -func loadCoopRun(runID int) (*CoopRun, error) { - d := db.Get() - r := &CoopRun{} - var leader string - var lockedAt, completedAt sql.NullTime - err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days, - base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at - FROM coop_dungeon_runs WHERE id = ?`, runID).Scan( - &r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, - &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay, - (*string)(&r.InvitePostID), - &r.CreatedAt, &lockedAt, &completedAt) - if err != nil { - return nil, err - } - r.LeaderID = id.UserID(leader) - if lockedAt.Valid { - t := lockedAt.Time - r.LockedAt = &t - } - if completedAt.Valid { - t := completedAt.Time - r.CompletedAt = &t - } - return r, nil -} - -func loadOpenCoopRuns() ([]*CoopRun, error) { - return queryCoopRuns(`status = 'open' ORDER BY created_at DESC`) -} - -func loadActiveCoopRuns() ([]*CoopRun, error) { - return queryCoopRuns(`status = 'active' ORDER BY id`) -} - -func loadMostRecentOpenCoopRun() (*CoopRun, error) { - runs, err := queryCoopRuns(`status = 'open' ORDER BY created_at DESC LIMIT 1`) - if err != nil || len(runs) == 0 { - return nil, err - } - return runs[0], nil -} - -func queryCoopRuns(whereClause string) ([]*CoopRun, error) { - d := db.Get() - rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days, - base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at - FROM coop_dungeon_runs WHERE ` + whereClause) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []*CoopRun - for rows.Next() { - r := &CoopRun{} - var leader string - var lockedAt, completedAt sql.NullTime - if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, - &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay, - (*string)(&r.InvitePostID), - &r.CreatedAt, &lockedAt, &completedAt); err != nil { - return nil, err - } - r.LeaderID = id.UserID(leader) - if lockedAt.Valid { - t := lockedAt.Time - r.LockedAt = &t - } - if completedAt.Valid { - t := completedAt.Time - r.CompletedAt = &t - } - out = append(out, r) - } - return out, rows.Err() -} - -func loadCoopRunForUser(userID id.UserID) (*CoopRun, error) { - d := db.Get() - var runID int - err := d.QueryRow(`SELECT m.run_id FROM coop_dungeon_members m - JOIN coop_dungeon_runs r ON r.id = m.run_id - WHERE m.user_id = ? AND r.status IN ('open','active') - ORDER BY r.id DESC LIMIT 1`, string(userID)).Scan(&runID) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return loadCoopRun(runID) -} - -func setCoopRunStatus(runID int, status string) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_runs SET status = ? WHERE id = ?`, status, runID) - return err -} - -func lockCoopRun(runID int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_runs - SET status = 'active', locked_at = CURRENT_TIMESTAMP, current_day = 1 - WHERE id = ? AND status = 'open'`, runID) - return err -} - -func advanceCoopDay(runID, newDay int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_runs SET current_day = ? WHERE id = ?`, newDay, runID) - return err -} - -func completeCoopRun(runID int, status string, reward int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_runs - SET status = ?, reward_total = ?, completed_at = CURRENT_TIMESTAMP - WHERE id = ?`, status, reward, runID) - return err -} - -// lockCoopCombatActions sets combat_actions_used to a value that exceeds the -// holiday cap on every active co-op participant. Called after the midnight -// reset (which would otherwise zero them) and at bot startup. Combat stays -// locked for the full duration of the run — players in a co-op cannot also -// solo-grind dungeons or arena. -// unlockCoopCombatActionsForRun clears the 99-sentinel lock on the given run's -// members and pins combat_actions_used to the daily cap (= maxCombatActions), -// so they can't squeeze in extra combat today but are not blocked into -// tomorrow if the midnight reset is delayed. Called when a run ends (wipe or -// complete). Idempotent. -func unlockCoopCombatActionsForRun(runID int) error { - d := db.Get() - _, err := d.Exec(`UPDATE adventure_characters - SET combat_actions_used = ? - WHERE combat_actions_used > ? - AND user_id IN (SELECT user_id FROM coop_dungeon_members WHERE run_id = ?)`, - maxCombatActions, maxCombatActions, runID) - return err -} - -// activeCoopMemberSet returns the set of user IDs currently locked into an -// active co-op run. The midnight reset uses this to skip the streak/babysit -// logic for those players — their co-op participation auto-resolves daily, -// so they should not be treated as idle even though they take no manual -// actions. -func activeCoopMemberSet() (map[string]bool, error) { - d := db.Get() - rows, err := d.Query(`SELECT m.user_id FROM coop_dungeon_members m - JOIN coop_dungeon_runs r ON r.id = m.run_id - WHERE r.status = 'active'`) - if err != nil { - return nil, err - } - defer rows.Close() - out := map[string]bool{} - for rows.Next() { - var u string - if err := rows.Scan(&u); err != nil { - return nil, err - } - out[u] = true - } - return out, rows.Err() -} - -func lockCoopCombatActions() error { - d := db.Get() - _, err := d.Exec(`UPDATE adventure_characters - SET combat_actions_used = 99 - WHERE user_id IN ( - SELECT m.user_id FROM coop_dungeon_members m - JOIN coop_dungeon_runs r ON r.id = m.run_id - WHERE r.status = 'active' - )`) - return err -} - -func setCoopRunInvitePostID(runID int, postID id.EventID) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_runs SET invite_post_id = ? WHERE id = ?`, string(postID), runID) - return err -} - -// setCoopRunLastResolvedDay marks a day as resolved for crash-resume idempotency. -// Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op. -func setCoopRunLastResolvedDay(runID, day int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_runs SET last_resolved_day = ? WHERE id = ?`, day, runID) - return err -} - -func addCoopRunPool(runID, delta int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_runs SET gold_pool = gold_pool + ? WHERE id = ?`, delta, runID) - return err -} - -// ── Members ───────────────────────────────────────────────────────────────── - -func addCoopMember(runID int, userID id.UserID, turnOrder int, liability bool) error { - d := db.Get() - libVal := 0 - if liability { - libVal = 1 - } - _, err := d.Exec(`INSERT INTO coop_dungeon_members - (run_id, user_id, turn_order, is_liability, daily_funding) VALUES (?, ?, ?, ?, '{}')`, - runID, string(userID), turnOrder, libVal) - return err -} - -func loadCoopMembers(runID int) ([]CoopMember, error) { - d := db.Get() - rows, err := d.Query(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding - FROM coop_dungeon_members WHERE run_id = ? ORDER BY turn_order`, runID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []CoopMember - for rows.Next() { - m := CoopMember{} - var userID, fundingJSON string - var liability int - if err := rows.Scan(&m.RunID, &userID, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON); err != nil { - return nil, err - } - m.UserID = id.UserID(userID) - m.IsLiability = liability != 0 - m.DailyFunding = parseCoopFundingMap(fundingJSON) - out = append(out, m) - } - return out, rows.Err() -} - -func loadCoopMember(runID int, userID id.UserID) (*CoopMember, error) { - d := db.Get() - m := &CoopMember{} - var uid, fundingJSON string - var liability int - err := d.QueryRow(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding - FROM coop_dungeon_members WHERE run_id = ? AND user_id = ?`, runID, string(userID)).Scan( - &m.RunID, &uid, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - m.UserID = id.UserID(uid) - m.IsLiability = liability != 0 - m.DailyFunding = parseCoopFundingMap(fundingJSON) - return m, nil -} - -// claimCoopMemberPayout atomically claims a member's reward slot. Returns true -// only if this call wins the race (member_payout was NULL and we set it). -// Prevents double-credit on crash-retry. -func claimCoopMemberPayout(runID int, userID id.UserID, payout int) (bool, error) { - d := db.Get() - res, err := d.Exec(`UPDATE coop_dungeon_members SET member_payout = ? - WHERE run_id = ? AND user_id = ? AND member_payout IS NULL`, - payout, runID, string(userID)) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} - -func saveCoopMemberFunding(m *CoopMember) error { - d := db.Get() - jsonBytes, err := json.Marshal(serializeCoopFundingMap(m.DailyFunding)) - if err != nil { - return err - } - _, err = d.Exec(`UPDATE coop_dungeon_members - SET daily_funding = ?, total_contributed = ? - WHERE run_id = ? AND user_id = ?`, - string(jsonBytes), m.TotalContributed, m.RunID, string(m.UserID)) - return err -} - -func parseCoopFundingMap(s string) map[int]CoopFundingTier { - out := map[int]CoopFundingTier{} - if s == "" || s == "{}" { - return out - } - raw := map[string]string{} - if err := json.Unmarshal([]byte(s), &raw); err != nil { - slog.Error("coop: parse funding map", "raw", s, "err", err) - return out - } - for k, v := range raw { - day, err := strconv.Atoi(k) - if err != nil { - continue - } - out[day] = CoopFundingTier(v) - } - return out -} - -func serializeCoopFundingMap(m map[int]CoopFundingTier) map[string]string { - out := make(map[string]string, len(m)) - for k, v := range m { - out[fmt.Sprintf("%d", k)] = string(v) - } - return out -} - -// ── Floor Events ──────────────────────────────────────────────────────────── - -type CoopEvent struct { - RunID int - Day int - Category CoopEventCategory - EventIndex int - Recommended string - Votes map[id.UserID]string // userID → option label - WinningVote string // "" until resolved - Outcome string // "" until resolved; "success" or "failure" - ModifierApplied int - PostEventID id.EventID -} - -func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error { - d := db.Get() - // INSERT OR IGNORE: idempotent on retry. If the row already exists from a - // prior tick, leave it untouched (don't re-roll the event index). - _, err := d.Exec(`INSERT OR IGNORE INTO coop_dungeon_events - (run_id, day, category, event_index, recommended) VALUES (?, ?, ?, ?, ?)`, - runID, day, string(cat), idx, recommended) - return err -} - -func loadCoopEvent(runID, day int) (*CoopEvent, error) { - d := db.Get() - e := &CoopEvent{} - var cat, votesJSON string - var winning, outcome, postID sql.NullString - err := d.QueryRow(`SELECT run_id, day, category, event_index, recommended, - votes, winning_vote, outcome, modifier_applied, post_event_id - FROM coop_dungeon_events WHERE run_id = ? AND day = ?`, runID, day).Scan( - &e.RunID, &e.Day, &cat, &e.EventIndex, &e.Recommended, - &votesJSON, &winning, &outcome, &e.ModifierApplied, &postID) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - e.Category = CoopEventCategory(cat) - e.Votes = parseCoopVotes(votesJSON) - if winning.Valid { - e.WinningVote = winning.String - } - if outcome.Valid { - e.Outcome = outcome.String - } - if postID.Valid { - e.PostEventID = id.EventID(postID.String) - } - return e, nil -} - -func saveCoopEventVotes(e *CoopEvent) error { - d := db.Get() - jsonBytes, err := json.Marshal(serializeCoopVotes(e.Votes)) - if err != nil { - return err - } - _, err = d.Exec(`UPDATE coop_dungeon_events SET votes = ? - WHERE run_id = ? AND day = ?`, string(jsonBytes), e.RunID, e.Day) - return err -} - -func saveCoopEventPostID(runID, day int, eventID id.EventID) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_events SET post_event_id = ? - WHERE run_id = ? AND day = ?`, string(eventID), runID, day) - return err -} - -func resolveCoopEvent(runID, day int, winning, outcome string, modifier int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_events - SET winning_vote = ?, outcome = ?, modifier_applied = ? - WHERE run_id = ? AND day = ?`, winning, outcome, modifier, runID, day) - return err -} - -// CoopTwinBeeHelpfulness returns a score in [0,1] where 1 means TwinBee's -// recommendations have been winning + succeeding (or losing + failing). Used -// by phase 3 betting odds. -func coopTwinBeeHelpfulness(window int) float64 { - d := db.Get() - rows, err := d.Query(`SELECT recommended, winning_vote, outcome - FROM coop_dungeon_events - WHERE outcome IS NOT NULL - ORDER BY rowid DESC LIMIT ?`, window) - if err != nil { - return 0.5 - } - defer rows.Close() - hits, total := 0, 0 - for rows.Next() { - var rec, win, out string - if err := rows.Scan(&rec, &win, &out); err != nil { - continue - } - total++ - if rec == win && out == "success" { - hits++ - } else if rec != win && out == "failure" { - hits++ - } - } - if total == 0 { - return 0.5 - } - return float64(hits) / float64(total) -} - -func parseCoopVotes(s string) map[id.UserID]string { - out := map[id.UserID]string{} - if s == "" || s == "{}" { - return out - } - raw := map[string]string{} - if err := json.Unmarshal([]byte(s), &raw); err != nil { - slog.Error("coop: parse votes map", "raw", s, "err", err) - return out - } - for k, v := range raw { - out[id.UserID(k)] = v - } - return out -} - -func serializeCoopVotes(m map[id.UserID]string) map[string]string { - out := make(map[string]string, len(m)) - for k, v := range m { - out[string(k)] = v - } - return out -} diff --git a/internal/plugin/coop_dungeon_gifts.go b/internal/plugin/coop_dungeon_gifts.go deleted file mode 100644 index eaf1439..0000000 --- a/internal/plugin/coop_dungeon_gifts.go +++ /dev/null @@ -1,913 +0,0 @@ -package plugin - -import ( - "database/sql" - "encoding/json" - "fmt" - "log/slog" - "math/rand/v2" - "strconv" - "strings" - "time" - - "gogobee/internal/db" - - "maunium.net/go/mautrix/id" -) - -// ── Gift Constants ────────────────────────────────────────────────────────── -// -// Magnitudes equalized across all four outcomes so neither "always open" nor -// "always leave" is variance-dominant at a 50/50 sender mix. Asymmetric -// magnitudes (e.g., open=±7, leave=±4) gave leave the same EV with lower -// variance, making it a dominant strategy for risk-averse parties — which -// collapses the dilemma into "always leave." -// -// basket opened: +6 basket unopened: -6 -// mimic opened: -6 mimic unopened: +6 -// -// Both strategies: EV=0, σ=6. The choice becomes "read sender intent" only. -const ( - coopGiftBasketOpened = 6 - coopGiftBasketUnopened = -6 - coopGiftMimicOpened = -6 - coopGiftMimicUnopened = 6 -) - -const ( - coopGiftBasket = "basket" - coopGiftMimic = "mimic" - - // Per-gift voting window. After this elapses, the gift's votes are - // tallied independently of the daily floor tick. Senders get DM feedback - // at expiry; the modifier sits on the run until the next floor resolves. - coopGiftWindow = 6 * time.Hour -) - -type CoopGift struct { - ID int - RunID int - SenderID id.UserID - Day int - GiftType string - Votes map[id.UserID]string // "open" or "leave" — only set on lead - VoteResult string // "opened" / "left" / "" - Outcome string // "boost" / "reduction" / "" - Modifier int - PostEventID id.EventID - ExpiresAt *time.Time // voting closes — only set on lead - AppliedAt *time.Time // modifier merged into a floor roll - StackLeadID *int // NULL = lead/standalone; otherwise points at lead's id -} - -// ── Command: send a gift ──────────────────────────────────────────────────── - -func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error { - parts := strings.Fields(args) - if len(parts) < 2 { - return p.SendDM(ctx.Sender, "Usage: `!coop gift `. Costs one harvest action; party never knows what you sent.") - } - runID, err := strconv.Atoi(parts[0]) - if err != nil { - return p.SendDM(ctx.Sender, "Run ID must be a number.") - } - giftType := strings.ToLower(parts[1]) - if giftType != coopGiftBasket && giftType != coopGiftMimic { - return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.") - } - - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - char, _, err := p.ensureCharacter(ctx.Sender) - if err != nil { - return p.SendDM(ctx.Sender, "Failed to load your character.") - } - if !char.Alive { - return p.SendDM(ctx.Sender, "You're dead. The post office does not deliver from the ditch.") - } - if !char.CanDoHarvest(false) { - return p.SendDM(ctx.Sender, "You're out of harvest actions today.") - } - - run, err := loadCoopRun(runID) - if err != nil || run == nil { - return p.SendDM(ctx.Sender, "Run not found.") - } - if run.Status != "active" { - return p.SendDM(ctx.Sender, "Run is not active. Gifts can only be sent during a running co-op.") - } - - // Senders cannot be party members. - if existing, _ := loadCoopMember(runID, ctx.Sender); existing != nil { - return p.SendDM(ctx.Sender, "You're in the party. You can't send gifts to yourself.") - } - - giftID, leadID, isNewLead, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType) - if err != nil { - return p.SendDM(ctx.Sender, "Couldn't send the gift.") - } - - // Charge the harvest action and persist. - char.HarvestActionsUsed++ - if err := saveAdvCharacter(char); err != nil { - slog.Error("coop: save sender after gift", "err", err) - } - - // Post (new lead) or edit (added to existing stack). - gr := gamesRoom() - if gr != "" { - members, _ := loadCoopMembers(runID) - if isNewLead { - gift, _ := loadCoopGift(giftID) - postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members)) - if perr == nil { - _ = saveCoopGiftPostID(giftID, postID) - } - } else { - lead, _ := loadCoopGift(leadID) - if lead != nil && lead.PostEventID != "" { - _ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members)) - } - } - } - - return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Gift dispatched (run #%d, day %d). One harvest action consumed. The party doesn't know what you sent.", runID, run.CurrentDay)) -} - -// ── Command: vote on a gift ───────────────────────────────────────────────── - -func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) error { - parts := strings.Fields(args) - if len(parts) < 1 { - return p.SendDM(ctx.Sender, "Usage: `!coop giftvote ` or `!coop giftvote ` for the most recent unresolved gift.") - } - - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - run, err := loadCoopRunForUser(ctx.Sender) - if err != nil || run == nil || run.Status != "active" { - return p.SendDM(ctx.Sender, "You're not in an active co-op run.") - } - - var gift *CoopGift - var choice string - if len(parts) >= 2 { - giftID, perr := strconv.Atoi(parts[0]) - if perr != nil { - return p.SendDM(ctx.Sender, "First arg must be the gift id, or just `open`/`leave` for the most recent.") - } - gift, _ = loadCoopGift(giftID) - choice = strings.ToLower(parts[1]) - } else { - gift, _ = loadMostRecentUnresolvedGift(run.ID, run.CurrentDay) - choice = strings.ToLower(parts[0]) - } - if gift == nil { - return p.SendDM(ctx.Sender, "No unresolved gift found.") - } - // If the user voted on a follower, redirect to its lead — votes always - // live on the lead. - if gift.StackLeadID != nil { - lead, _ := loadCoopGift(*gift.StackLeadID) - if lead == nil { - return p.SendDM(ctx.Sender, "Couldn't find the gift's stack lead.") - } - gift = lead - } - if gift.RunID != run.ID || gift.Day != run.CurrentDay { - return p.SendDM(ctx.Sender, "That gift isn't for your current run/day.") - } - if gift.VoteResult != "" { - return p.SendDM(ctx.Sender, "That gift has already resolved.") - } - if choice != "open" && choice != "leave" { - return p.SendDM(ctx.Sender, "Vote `open` or `leave`.") - } - - if gift.Votes == nil { - gift.Votes = map[id.UserID]string{} - } - gift.Votes[ctx.Sender] = choice - if err := saveCoopGiftVotes(gift); err != nil { - return p.SendDM(ctx.Sender, "Couldn't save your vote.") - } - - gr := gamesRoom() - if gr != "" && gift.PostEventID != "" { - members, _ := loadCoopMembers(run.ID) - _ = p.EditMessage(gr, gift.PostEventID, renderCoopGiftPost(p, run, gift, members)) - } - return p.SendDM(ctx.Sender, fmt.Sprintf("Gift #%d vote recorded: %s.", gift.ID, choice)) -} - -// ── Resolution ────────────────────────────────────────────────────────────── - -// resolveSingleGift tallies a stack lead's votes, persists the outcome, DMs -// every sender in the stack, and edits the game-room post to show the -// resolved state. Used by both the per-minute expiry ticker and the -// floor-resolution catchall. -// -// Followers passed in here will be redirected to their lead. Idempotent: -// always re-loads the lead from DB before checking vote_result, so an -// in-memory snapshot from earlier in the same loop can't trigger a double -// resolve. -func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) { - // Redirect follower → lead, then re-fetch fresh state. - leadID := g.ID - if g.StackLeadID != nil { - leadID = *g.StackLeadID - } - fresh, _ := loadCoopGift(leadID) - if fresh == nil { - return - } - g = fresh - if g.VoteResult != "" { - return - } - - opens, leaves := 0, 0 - var leaderVote string - for u, v := range g.Votes { - if v == "open" { - opens++ - } else if v == "leave" { - leaves++ - } - if u == leader { - leaderVote = v - } - } - var voteResult string - switch { - case opens > leaves: - voteResult = "opened" - case leaves > opens: - voteResult = "left" - case leaderVote == "open": - voteResult = "opened" - case leaderVote == "leave": - voteResult = "left" - default: - voteResult = "left" - } - - // Per-gift modifier (single ±6 unit). Stack-wide modifier is N × this, - // applied at floor resolution by summing the rows individually. - var perMod int - var outcome string - switch g.GiftType { - case coopGiftBasket: - if voteResult == "opened" { - perMod = coopGiftBasketOpened - outcome = "boost" - } else { - perMod = coopGiftBasketUnopened - outcome = "reduction" - } - case coopGiftMimic: - if voteResult == "opened" { - perMod = coopGiftMimicOpened - outcome = "reduction" - } else { - perMod = coopGiftMimicUnopened - outcome = "boost" - } - } - - stack, _ := loadCoopGiftStack(g.ID) - totalMod := 0 - for _, m := range stack { - _ = resolveCoopGift(m.ID, voteResult, outcome, perMod) - _ = p.SendDM(m.SenderID, renderCoopGiftSenderDM(&m, voteResult, perMod)) - totalMod += perMod - } - - g.VoteResult = voteResult - g.Outcome = outcome - g.Modifier = perMod - - // Edit the live game-room post to show the resolved state — total swing - // reflects the full stack. - gr := gamesRoom() - if gr != "" && g.PostEventID != "" { - _ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g, len(stack), totalMod)) - } -} - -// coopProcessGiftExpiries runs each ticker minute, resolving any gifts whose -// voting window has elapsed. Modifiers are recorded but not yet applied to a -// floor — they wait for the next floor resolution. -func (p *AdventurePlugin) coopProcessGiftExpiries() { - gifts, err := loadExpiredUnresolvedGifts() - if err != nil { - slog.Error("coop: load expired gifts", "err", err) - return - } - for i := range gifts { - g := &gifts[i] - run, _ := loadCoopRun(g.RunID) - if run == nil || run.Status != "active" { - // Run gone or already terminal — still mark the gift resolved so - // it stops cluttering the expiry query. - _ = resolveCoopGift(g.ID, "left", "reduction", 0) - continue - } - p.resolveSingleGift(g, run.LeaderID) - } -} - -// coopResolveGifts is called inside coopResolveFloor. Two responsibilities: -// -// 1. Catchall: force-resolve any gifts on this day that still have no -// vote_result (the timer ticker should normally have caught them, but -// this is the defensive backstop, e.g., bot was down). -// 2. Apply: sum modifiers from any resolved-but-not-yet-applied gifts on -// this run and atomically claim them via markCoopGiftApplied so the -// same modifier is never double-applied across retries. -// -// Returns total modifier and a summary line for the daily-result post. -func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) { - // Catchall: any unresolved leads for this day get force-resolved now. - // Iterate leads only — resolveSingleGift propagates to followers, so - // touching followers here would just be redundant work. - dayGifts, _ := loadDayCoopGifts(run.ID, day) - for i := range dayGifts { - if dayGifts[i].StackLeadID != nil { - continue - } - if dayGifts[i].VoteResult == "" { - p.resolveSingleGift(&dayGifts[i], leader) - } - } - - // Apply: pending resolved gifts (any day, this run) get their modifier - // merged into the floor roll. - pending, _ := loadResolvedUnappliedGifts(run.ID) - totalMod := 0 - var lines []string - for _, g := range pending { - claimed, err := markCoopGiftApplied(g.ID) - if err != nil || !claimed { - continue - } - totalMod += g.Modifier - lines = append(lines, fmt.Sprintf(" %s gift from %s — %s → %+d%%", - titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), g.VoteResult, g.Modifier)) - } - if len(lines) == 0 { - return 0, "" - } - return totalMod, "Gifts:\n" + strings.Join(lines, "\n") -} - -// ── DB ────────────────────────────────────────────────────────────────────── - -// findCoopGiftStackLead returns the active lead gift for a (run, day, type) -// stack, or nil if no unresolved lead exists. Lead = oldest unresolved gift -// of that type with stack_lead_id IS NULL. -func findCoopGiftStackLead(runID, day int, giftType string) (*CoopGift, error) { - d := db.Get() - var leadID int - err := d.QueryRow(`SELECT id FROM coop_dungeon_gifts - WHERE run_id = ? AND day = ? AND gift_type = ? - AND vote_result IS NULL AND stack_lead_id IS NULL - ORDER BY id ASC LIMIT 1`, runID, day, giftType).Scan(&leadID) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return loadCoopGift(leadID) -} - -// loadCoopGiftStack returns the lead and all followers (in id order) for a -// stack identified by lead_id. Used to propagate resolution + render. -func loadCoopGiftStack(leadID int) ([]CoopGift, error) { - d := db.Get() - rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id - FROM coop_dungeon_gifts - WHERE id = ? OR stack_lead_id = ? - ORDER BY id ASC`, leadID, leadID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CoopGift - for rows.Next() { - g := CoopGift{} - var sender, votesJSON string - var voteResult, outcome, postID sql.NullString - var expiresAt, appliedAt sql.NullTime - var stackLeadID sql.NullInt64 - if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { - return nil, err - } - g.SenderID = id.UserID(sender) - g.Votes = parseCoopVotes(votesJSON) - if voteResult.Valid { - g.VoteResult = voteResult.String - } - if outcome.Valid { - g.Outcome = outcome.String - } - if postID.Valid { - g.PostEventID = id.EventID(postID.String) - } - if expiresAt.Valid { - t := expiresAt.Time - g.ExpiresAt = &t - } - if appliedAt.Valid { - t := appliedAt.Time - g.AppliedAt = &t - } - if stackLeadID.Valid { - v := int(stackLeadID.Int64) - g.StackLeadID = &v - } - out = append(out, g) - } - return out, rows.Err() -} - -// createCoopGift inserts a new gift. If an unresolved same-type stack exists -// for the run+day, the new gift becomes a follower (no expires_at, no own -// post — lead's post is edited to bump the count). Otherwise a new lead is -// created with its own 6h expiry. -// -// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective -// stack lead — either itself (new lead) or the existing lead it joined. -func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) { - d := db.Get() - - existing, lerr := findCoopGiftStackLead(runID, day, giftType) - if lerr != nil { - return 0, 0, false, lerr - } - if existing != nil { - // Follower: no expires_at, no post. Inherits lead's deadline. - res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts - (run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`, - runID, string(sender), day, giftType, existing.ID) - if ierr != nil { - return 0, 0, false, ierr - } - id64, _ := res.LastInsertId() - return int(id64), existing.ID, false, nil - } - - // New lead: own expiry, will get its own post. - expires := time.Now().UTC().Add(coopGiftWindow) - res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts - (run_id, sender_id, day, gift_type, expires_at) VALUES (?, ?, ?, ?, ?)`, - runID, string(sender), day, giftType, expires) - if ierr != nil { - return 0, 0, false, ierr - } - id64, _ := res.LastInsertId() - return int(id64), int(id64), true, nil -} - -func loadCoopGift(giftID int) (*CoopGift, error) { - d := db.Get() - g := &CoopGift{} - var sender, votesJSON string - var voteResult, outcome, postID sql.NullString - var expiresAt, appliedAt sql.NullTime - var stackLeadID sql.NullInt64 - err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id - FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan( - &g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - g.SenderID = id.UserID(sender) - g.Votes = parseCoopVotes(votesJSON) - if voteResult.Valid { - g.VoteResult = voteResult.String - } - if outcome.Valid { - g.Outcome = outcome.String - } - if postID.Valid { - g.PostEventID = id.EventID(postID.String) - } - if expiresAt.Valid { - t := expiresAt.Time - g.ExpiresAt = &t - } - if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v } - if appliedAt.Valid { - t := appliedAt.Time - g.AppliedAt = &t - } - return g, nil -} - -func loadDayCoopGifts(runID, day int) ([]CoopGift, error) { - d := db.Get() - rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id - FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CoopGift - for rows.Next() { - g := CoopGift{} - var sender, votesJSON string - var voteResult, outcome, postID sql.NullString - var expiresAt, appliedAt sql.NullTime - var stackLeadID sql.NullInt64 - if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { - return nil, err - } - g.SenderID = id.UserID(sender) - g.Votes = parseCoopVotes(votesJSON) - if voteResult.Valid { - g.VoteResult = voteResult.String - } - if outcome.Valid { - g.Outcome = outcome.String - } - if postID.Valid { - g.PostEventID = id.EventID(postID.String) - } - if expiresAt.Valid { - t := expiresAt.Time - g.ExpiresAt = &t - } - if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v } - if appliedAt.Valid { - t := appliedAt.Time - g.AppliedAt = &t - } - out = append(out, g) - } - return out, rows.Err() -} - -func loadAllCoopGifts(runID int) ([]CoopGift, error) { - d := db.Get() - rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id - FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CoopGift - for rows.Next() { - g := CoopGift{} - var sender, votesJSON string - var voteResult, outcome, postID sql.NullString - var expiresAt, appliedAt sql.NullTime - var stackLeadID sql.NullInt64 - if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { - return nil, err - } - g.SenderID = id.UserID(sender) - g.Votes = parseCoopVotes(votesJSON) - if voteResult.Valid { - g.VoteResult = voteResult.String - } - if outcome.Valid { - g.Outcome = outcome.String - } - if postID.Valid { - g.PostEventID = id.EventID(postID.String) - } - if expiresAt.Valid { - t := expiresAt.Time - g.ExpiresAt = &t - } - if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v } - if appliedAt.Valid { - t := appliedAt.Time - g.AppliedAt = &t - } - out = append(out, g) - } - return out, rows.Err() -} - -func loadMostRecentUnresolvedGift(runID, day int) (*CoopGift, error) { - gifts, err := loadDayCoopGifts(runID, day) - if err != nil { - return nil, err - } - for i := len(gifts) - 1; i >= 0; i-- { - if gifts[i].VoteResult == "" { - g := gifts[i] - return &g, nil - } - } - return nil, nil -} - -func saveCoopGiftVotes(g *CoopGift) error { - d := db.Get() - jsonBytes, err := json.Marshal(serializeCoopVotes(g.Votes)) - if err != nil { - return err - } - _, err = d.Exec(`UPDATE coop_dungeon_gifts SET votes = ? WHERE id = ?`, - string(jsonBytes), g.ID) - return err -} - -func saveCoopGiftPostID(giftID int, eventID id.EventID) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_gifts SET post_event_id = ? WHERE id = ?`, - string(eventID), giftID) - return err -} - -func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error { - d := db.Get() - _, err := d.Exec(`UPDATE coop_dungeon_gifts - SET vote_result = ?, outcome = ?, modifier = ?, resolved_at = CURRENT_TIMESTAMP - WHERE id = ?`, voteResult, outcome, modifier, giftID) - return err -} - -// loadExpiredUnresolvedGifts returns gifts whose voting window has elapsed -// but whose votes haven't yet been tallied. Driven by the per-minute -// expiry ticker. -func loadExpiredUnresolvedGifts() ([]CoopGift, error) { - d := db.Get() - rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id - FROM coop_dungeon_gifts - WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP - AND stack_lead_id IS NULL - ORDER BY id`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CoopGift - for rows.Next() { - g := CoopGift{} - var sender, votesJSON string - var voteResult, outcome, postID sql.NullString - var expiresAt, appliedAt sql.NullTime - var stackLeadID sql.NullInt64 - if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { - return nil, err - } - g.SenderID = id.UserID(sender) - g.Votes = parseCoopVotes(votesJSON) - if postID.Valid { - g.PostEventID = id.EventID(postID.String) - } - if expiresAt.Valid { - t := expiresAt.Time - g.ExpiresAt = &t - } - if stackLeadID.Valid { - v := int(stackLeadID.Int64) - g.StackLeadID = &v - } - out = append(out, g) - } - return out, rows.Err() -} - -// loadResolvedUnappliedGifts returns gifts that have been tallied but whose -// modifiers have not yet been merged into a floor success roll. Used by -// floor resolution to sum and apply pending gift modifiers. -func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) { - d := db.Get() - rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id - FROM coop_dungeon_gifts - WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL - ORDER BY id`, runID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CoopGift - for rows.Next() { - g := CoopGift{} - var sender, votesJSON string - var voteResult, outcome, postID sql.NullString - var expiresAt, appliedAt sql.NullTime - var stackLeadID sql.NullInt64 - if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { - return nil, err - } - g.SenderID = id.UserID(sender) - g.Votes = parseCoopVotes(votesJSON) - if voteResult.Valid { - g.VoteResult = voteResult.String - } - if outcome.Valid { - g.Outcome = outcome.String - } - if stackLeadID.Valid { - v := int(stackLeadID.Int64) - g.StackLeadID = &v - } - out = append(out, g) - } - return out, rows.Err() -} - -// markCoopGiftApplied claims a gift's modifier as merged into a floor roll. -// Idempotent — only the first call sets applied_at; subsequent calls return -// false (claimed=false) so the same modifier is never double-applied. -func markCoopGiftApplied(giftID int) (bool, error) { - d := db.Get() - res, err := d.Exec(`UPDATE coop_dungeon_gifts SET applied_at = CURRENT_TIMESTAMP - WHERE id = ? AND applied_at IS NULL`, giftID) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} - -// ── Render ────────────────────────────────────────────────────────────────── - -// renderCoopGiftPost is called only for stack leads. Stack count is derived -// from how many followers point at this lead. Flavor is picked deterministically -// by lead id so re-renders don't rotate. -func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, members []CoopMember) string { - var sb strings.Builder - - // Stack size = lead + followers - stack, _ := loadCoopGiftStack(gift.ID) - stackSize := len(stack) - if stackSize == 0 { - stackSize = 1 - } - - // Tally on the lead's votes - opens, leaves := 0, 0 - for _, v := range gift.Votes { - if v == "open" { - opens++ - } else if v == "leave" { - leaves++ - } - } - leaderName := coopDisplayName(p, run.LeaderID) - - header := fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**", gift.ID, run.ID, gift.Day) - if stackSize > 1 { - header = fmt.Sprintf("📦 **Gift Stack #%d (×%d) — Co-op #%d, Day %d**", gift.ID, stackSize, run.ID, gift.Day) - } - sb.WriteString(header) - sb.WriteString("\n\n") - - // Pick flavor deterministically so edits don't rotate the joke. - if len(TwinBeeGiftArrival) > 0 { - flavor := TwinBeeGiftArrival[gift.ID%len(TwinBeeGiftArrival)] - flavor = strings.Replace(flavor, "{count}", strconv.Itoa(opens), 1) - flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1) - flavor = strings.ReplaceAll(flavor, "{leader}", leaderName) - sb.WriteString(flavor) - sb.WriteString("\n\n") - } - - // "REALLY special" line, picked once per stack lead, shown for size 2+. - if stackSize >= 2 && len(TwinBeeGiftStackEscalation) > 0 { - line := TwinBeeGiftStackEscalation[gift.ID%len(TwinBeeGiftStackEscalation)] - sb.WriteString("_") - sb.WriteString(line) - sb.WriteString("_\n\n") - } - - closesIn := "" - if gift.ExpiresAt != nil { - remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute) - if remaining > 0 { - closesIn = fmt.Sprintf(" · voting closes in %s", remaining) - } else { - closesIn = " · voting closing" - } - } - if stackSize > 1 { - sb.WriteString(fmt.Sprintf("One vote covers the whole stack of %d. ", stackSize)) - } - sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".") - - awaiting := 0 - for _, m := range members { - if _, ok := gift.Votes[m.UserID]; !ok { - awaiting++ - } - } - if awaiting > 0 && awaiting < len(members) { - sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", awaiting, len(members))) - } - return sb.String() -} - -// renderCoopGiftResolvedPost replaces the live vote post when a gift's -// voting window closes. Total modifier = stackSize × per-gift mod. -func renderCoopGiftResolvedPost(g *CoopGift, stackSize, totalMod int) string { - verb := "left" - if g.VoteResult == "opened" { - verb = "opened" - } - if stackSize > 1 { - return fmt.Sprintf("📦 **Gift Stack #%d (×%d) resolved** — %s, %s → **%+d%%** to next floor.", - g.ID, stackSize, titleCaseWord(g.GiftType), verb, totalMod) - } - return fmt.Sprintf("📦 **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.", - g.ID, titleCaseWord(g.GiftType), verb, totalMod) -} - -func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string { - pickFrom := func(pool []string) string { - if len(pool) == 0 { - return "" - } - return pool[rand.IntN(len(pool))] - } - switch { - case g.GiftType == coopGiftBasket && voteResult == "opened": - return fmt.Sprintf("📦 Your care basket was opened. %s (%+d%% to today's floor)", pickFrom(TwinBeeGiftBasketOpened), mod) - case g.GiftType == coopGiftBasket && voteResult == "left": - return fmt.Sprintf("📦 Your care basket was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftBasketUnopened), mod) - case g.GiftType == coopGiftMimic && voteResult == "opened": - return fmt.Sprintf("📦 Your mimic was opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicOpened), mod) - case g.GiftType == coopGiftMimic && voteResult == "left": - return fmt.Sprintf("📦 Your mimic was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicUnopened), mod) - } - return "📦 Your gift resolved." -} - -// renderCoopGiftLog returns the public end-of-run gift log, grouped by stack. -// One line per stack with all senders listed and the total swing. -func renderCoopGiftLog(p *AdventurePlugin, runID int) string { - gifts, err := loadAllCoopGifts(runID) - if err != nil || len(gifts) == 0 { - return "" - } - // Group gifts by their effective lead id. - type groupKey struct { - day int - giftType string - leadID int - } - groups := map[groupKey][]CoopGift{} - order := []groupKey{} - for _, g := range gifts { - leadID := g.ID - if g.StackLeadID != nil { - leadID = *g.StackLeadID - } - k := groupKey{day: g.Day, giftType: g.GiftType, leadID: leadID} - if _, seen := groups[k]; !seen { - order = append(order, k) - } - groups[k] = append(groups[k], g) - } - - var sb strings.Builder - sb.WriteString("📦 Gift Log:\n") - for _, k := range order { - stack := groups[k] - // Senders for the line — ordered by id for stable display. - names := make([]string, 0, len(stack)) - totalMod := 0 - voteResult := stack[0].VoteResult - for _, g := range stack { - names = append(names, coopDisplayName(p, g.SenderID)) - totalMod += g.Modifier - } - result := voteResult - if result == "" { - result = "unresolved" - } - typeLabel := titleCaseWord(k.giftType) - if len(stack) > 1 { - typeLabel = fmt.Sprintf("%s ×%d", typeLabel, len(stack)) - } - sb.WriteString(fmt.Sprintf(" Day %d: %s → %s → %s → %+d%%\n", - k.day, strings.Join(names, " + "), typeLabel, result, totalMod)) - } - return sb.String() -} diff --git a/internal/plugin/coop_dungeon_render.go b/internal/plugin/coop_dungeon_render.go deleted file mode 100644 index 6ec96be..0000000 --- a/internal/plugin/coop_dungeon_render.go +++ /dev/null @@ -1,282 +0,0 @@ -package plugin - -import ( - "fmt" - "math/rand/v2" - "strings" - "time" - - "maunium.net/go/mautrix/id" -) - -func coopDisplayName(p *AdventurePlugin, userID id.UserID) string { - char, err := loadAdvCharacter(userID) - if err == nil && char.DisplayName != "" { - return char.DisplayName - } - if p != nil { - return p.DisplayName(userID) - } - return string(userID) -} - -func renderCoopInvite(run *CoopRun, members []CoopMember, leaderName string) string { - def := coopTierTable[run.Tier] - closes := run.CreatedAt.Add(coopInviteWindow).Format("Mon 15:04 UTC") - return fmt.Sprintf( - "⚔️ %s is opening a Co-op Dungeon — **Tier %d (%s)**, run #%d.\n"+ - "Days: %d. Reward pool base: €%d (split among the party).\n"+ - "Party: %d/%d. Type `!coop join %d` to enter.\n"+ - "Locks at %s. Newbies are a liability — you knew that going in.", - leaderName, run.Tier, def.difficulty, run.ID, - def.totalDays, def.rewardBase, - len(members), coopMaxPartySize, run.ID, closes) -} - -func renderCoopLock(run *CoopRun, members []CoopMember, p *AdventurePlugin) string { - var sb strings.Builder - def := coopTierTable[run.Tier] - sb.WriteString(fmt.Sprintf("⚔️ Tier %d Co-op #%d is locked.\n", run.Tier, run.ID)) - sb.WriteString(fmt.Sprintf("Difficulty: %s. Days: %d. Reward pool: €%d.\n\nParty (turn order):\n", - def.difficulty, run.TotalDays, def.rewardBase)) - for _, m := range members { - warn := "" - if m.IsLiability { - warn = " ⚠️ liability" - } - sb.WriteString(fmt.Sprintf(" %d. %s%s\n", m.TurnOrder+1, coopDisplayName(p, m.UserID), warn)) - } - sb.WriteString("\nDay 1 begins now. Each member has until the next daily tick to fund. Inactive = None (-10%).") - bets, _ := loadCoopBets(run.ID) - sb.WriteString("\n\n") - sb.WriteString(renderCoopOddsLine(run, members, bets)) - return sb.String() -} - -func renderCoopFundingPrompt(run *CoopRun, day int) string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Day %d/%d. Pick your funding tier with `!coop fund `.\n\n", - run.ID, day, run.TotalDays)) - for _, t := range coopFundingOrder { - def := coopFundingTable[t] - sb.WriteString(fmt.Sprintf(" • `%s` — €%d, %+d%%\n", t, def.cost, def.modifier)) - } - sb.WriteString("\nNo decision before the next daily tick = auto-played as None (-10%). Funding is non-refundable.") - return sb.String() -} - -func renderCoopDailyResult(p *AdventurePlugin, run *CoopRun, members []CoopMember, day int, - choices map[string]CoopFundingTier, successPct int, won bool, autoPlayed []*CoopMember, - event *CoopEvent, winning string, eventMod int) string { - - var sb strings.Builder - def := coopTierTable[run.Tier] - header := "✅ Floor cleared" - if !won { - header = "💥 Wipe" - } - if won && day >= run.TotalDays { - header = "🏆 Final floor cleared" - } - - sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s), Day %d/%d\n", - run.ID, run.Tier, def.difficulty, day, run.TotalDays)) - sb.WriteString(fmt.Sprintf("Success chance: %d%%. %s.\n", successPct, header)) - - if event != nil { - sb.WriteString(fmt.Sprintf("Floor event: %s. Party voted **%s** (%+d%%).\n", - titleCaseWord(string(event.Category)), winning, eventMod)) - } - sb.WriteString("\nFunding:\n") - - for _, m := range members { - t := choices[string(m.UserID)] - fdef := coopFundingTable[t] - auto := "" - for _, ap := range autoPlayed { - if ap.UserID == m.UserID { - auto = " ← auto-played" - break - } - } - sb.WriteString(fmt.Sprintf(" %s: %s (%+d%%)%s\n", - coopDisplayName(p, m.UserID), fdef.label, fdef.modifier, auto)) - } - - if event != nil { - sb.WriteString("\n_TwinBee:_ ") - sb.WriteString(coopTwinBeeReaction(event, winning, won)) - } - - if !won { - sb.WriteString("\n\nThe dungeon does not editorialize. Combat actions for today are refunded if any. Funding is gone.") - } else if day < run.TotalDays { - sb.WriteString(fmt.Sprintf("\n\nNext floor unlocks at the next daily tick. %d day(s) remain.", run.TotalDays-day)) - bets, _ := loadCoopBets(run.ID) - sb.WriteString("\n") - sb.WriteString(renderCoopOddsLine(run, members, bets)) - } - return sb.String() -} - -// coopTwinBeeReaction picks an outcome line based on whether his recommendation -// matched the winning vote and whether the floor succeeded. -func coopTwinBeeReaction(event *CoopEvent, winning string, won bool) string { - pickFrom := func(pool []string) string { - if len(pool) == 0 { - return "" - } - return pool[rand.IntN(len(pool))] - } - switch { - case event.Recommended == winning && won: - return pickFrom(TwinBeeOutcomeCorrect) - case event.Recommended == winning && !won: - return pickFrom(TwinBeeOutcomeWrong) - case event.Recommended != winning && won: - return pickFrom(TwinBeeOutcomeNotRecommendedGood) - default: - return pickFrom(TwinBeeOutcomeNotRecommendedBad) - } -} - -// renderCoopEventPost is the game-room post for a floor event vote prompt. -// Edited in place as votes come in. -func renderCoopEventPost(run *CoopRun, members []CoopMember, event *CoopEvent) string { - var sb strings.Builder - flavorPool := coopEventCategoryFlavor(event.Category) - flavor := "" - if event.EventIndex < len(flavorPool) { - flavor = flavorPool[event.EventIndex] - } - sb.WriteString(fmt.Sprintf("🗺️ **Co-op #%d — Day %d/%d, %s event**\n\n", - run.ID, event.Day, run.TotalDays, titleCaseWord(string(event.Category)))) - sb.WriteString(flavor) - sb.WriteString("\n\nVote with `!coop vote A` (or B/C). 24h or until next tick. Ties go to the leader.\n\n") - - tally := map[string]int{} - for _, v := range event.Votes { - tally[v]++ - } - parts := []string{} - for _, label := range coopEventOptionLabels(event.Category, event.EventIndex) { - parts = append(parts, fmt.Sprintf("%s (%d)", label, tally[label])) - } - sb.WriteString("Current votes: ") - sb.WriteString(strings.Join(parts, " · ")) - - abstained := []string{} - for _, m := range members { - if _, voted := event.Votes[m.UserID]; !voted { - abstained = append(abstained, string(m.UserID)) - } - } - if len(abstained) > 0 && len(abstained) < len(members) { - sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", len(abstained), len(members))) - } - return sb.String() -} - -func titleCaseWord(s string) string { - if s == "" { - return s - } - return strings.ToUpper(s[:1]) + s[1:] -} - -func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) string { - var sb strings.Builder - def := coopTierTable[run.Tier] - sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s)\n", run.ID, run.Tier, def.difficulty)) - sb.WriteString(fmt.Sprintf("Status: %s. Day %d/%d. Pool: €%d.\n\nParty:\n", - run.Status, run.CurrentDay, run.TotalDays, run.GoldPool)) - - for _, m := range members { - warn := "" - if m.IsLiability { - warn = " ⚠️" - } - todayLabel := "—" - if run.CurrentDay >= 1 { - if t, ok := m.DailyFunding[run.CurrentDay]; ok { - todayLabel = coopFundingTable[t].label - } else { - todayLabel = "(unset)" - } - } - sb.WriteString(fmt.Sprintf(" %d. %s%s — today: %s, contributed: €%d\n", - m.TurnOrder+1, coopDisplayName(p, m.UserID), warn, todayLabel, m.TotalContributed)) - } - if run.Status == "open" { - closes := run.CreatedAt.Add(coopInviteWindow) - sb.WriteString(fmt.Sprintf("\nLocks in: %s.", time.Until(closes).Truncate(time.Minute))) - } - if run.Status == "open" || run.Status == "active" { - bets, _ := loadCoopBets(run.ID) - sb.WriteString("\n") - sb.WriteString(renderCoopOddsLine(run, members, bets)) - } - - // Pending stacks with per-stack countdowns. Each game-room post is one - // stack (lead + N followers); we show the lead's id and the stack size. - if run.Status == "active" { - all, _ := loadAllCoopGifts(run.ID) - // Group unresolved gifts by lead. Followers contribute to the size - // but use the lead's expiry/votes. - type stackInfo struct { - lead CoopGift - size int - } - stacks := map[int]*stackInfo{} - order := []int{} - for _, g := range all { - if g.VoteResult != "" { - continue - } - leadID := g.ID - if g.StackLeadID != nil { - leadID = *g.StackLeadID - } - s, ok := stacks[leadID] - if !ok { - s = &stackInfo{} - stacks[leadID] = s - order = append(order, leadID) - } - if g.StackLeadID == nil { - s.lead = g - } - s.size++ - } - if len(stacks) > 0 { - sb.WriteString("\n\nPending gifts:\n") - for _, leadID := range order { - s := stacks[leadID] - opens, leaves := 0, 0 - for _, v := range s.lead.Votes { - if v == "open" { - opens++ - } else if v == "leave" { - leaves++ - } - } - countdown := "—" - if s.lead.ExpiresAt != nil { - remaining := time.Until(*s.lead.ExpiresAt).Truncate(time.Minute) - if remaining > 0 { - countdown = remaining.String() - } else { - countdown = "closing" - } - } - stackLabel := fmt.Sprintf("#%d", s.lead.ID) - if s.size > 1 { - stackLabel = fmt.Sprintf("#%d ×%d", s.lead.ID, s.size) - } - sb.WriteString(fmt.Sprintf(" %s (day %d) — open %d / leave %d — closes in %s\n", - stackLabel, s.lead.Day, opens, leaves, countdown)) - } - } - } - return sb.String() -} diff --git a/internal/plugin/coop_dungeon_scheduler.go b/internal/plugin/coop_dungeon_scheduler.go deleted file mode 100644 index 08b47f8..0000000 --- a/internal/plugin/coop_dungeon_scheduler.go +++ /dev/null @@ -1,565 +0,0 @@ -package plugin - -import ( - "fmt" - "log/slog" - "math/rand/v2" - "sort" - "strings" - "sync" - "time" - - "gogobee/internal/db" - - "maunium.net/go/mautrix/id" -) - -// coopTicker has two cadences: -// -// 1. Lock checks fire every minute. They're cheap (one timestamp comparison -// per open run) and a 24h invite window is poorly served by a 24h tick — -// a run started after 08:00 UTC would otherwise wait 30-48h to lock. -// 2. Floor resolution fires once per UTC day at morningHour, gated by the -// daily_prefetch JobCompleted guard. Resolution is the heavy step that -// advances days, distributes rewards, and posts to the room. -func (p *AdventurePlugin) coopTicker() { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-p.stopCh: - return - case <-ticker.C: - // Locks: every minute. - p.coopProcessLocks() - - // Gift expiries: every minute. Resolves any gift whose individual - // 6h voting window has elapsed. Modifier sits on the run until the - // next floor resolution merges it. - p.coopProcessGiftExpiries() - - // Resolutions: once per day at morningHour:00 UTC. - now := time.Now().UTC() - if now.Hour() != p.morningHour || now.Minute() != 0 { - continue - } - dateKey := now.Format("2006-01-02") - jobName := "coop_dungeon_daily" - if db.JobCompleted(jobName, dateKey) { - continue - } - slog.Info("coop: daily tick — resolving active runs") - p.coopProcessActiveRuns() - db.MarkJobCompleted(jobName, dateKey) - } - } -} - -// ── Lock Phase ────────────────────────────────────────────────────────────── - -// coopProcessLocks closes invites whose 24h window has elapsed: locks parties -// of 2+, cancels solo runs. -func (p *AdventurePlugin) coopProcessLocks() { - runs, err := loadOpenCoopRuns() - if err != nil { - slog.Error("coop: load open runs", "err", err) - return - } - now := time.Now().UTC() - for _, run := range runs { - if now.Before(run.CreatedAt.Add(coopInviteWindow)) { - continue - } - members, err := loadCoopMembers(run.ID) - if err != nil { - slog.Error("coop: load members for lock", "run", run.ID, "err", err) - continue - } - if len(members) < coopMinPartySize { - // Cancel solo runs and refund nothing (no funding has been spent yet). - _ = setCoopRunStatus(run.ID, "cancelled") - gr := gamesRoom() - if gr != "" { - if run.InvitePostID != "" { - _ = p.UnpinEvent(gr, run.InvitePostID) - } - _ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d expired with no party. Cancelled.", run.Tier, run.ID)) - } - _ = p.SendDM(run.LeaderID, fmt.Sprintf("Co-op #%d expired before anyone joined. No party, no run.", run.ID)) - continue - } - - // Lock: deduct combat action from each member, mark active, post lock notice. - if err := lockCoopRun(run.ID); err != nil { - slog.Error("coop: lock run", "run", run.ID, "err", err) - continue - } - for _, m := range members { - char, err := loadAdvCharacter(m.UserID) - if err != nil { - continue - } - if char.CanDoCombat(false) { - char.CombatActionsUsed++ - char.ActionTakenToday = true - char.LastActionDate = now.Format("2006-01-02") - _ = saveAdvCharacter(char) - } - } - fresh, _ := loadCoopRun(run.ID) - gr := gamesRoom() - if gr != "" { - if run.InvitePostID != "" { - _ = p.UnpinEvent(gr, run.InvitePostID) - } - _ = p.SendMessage(gr, renderCoopLock(fresh, members, p)) - } - // Per-player DM with funding instructions. - for _, m := range members { - _ = p.SendDM(m.UserID, renderCoopFundingPrompt(fresh, 1)) - } - // Generate and post day-1 floor event. - p.coopGenerateAndPostEvent(fresh, members, 1) - } -} - -// coopGenerateAndPostEvent picks a category-weighted event for the given day, -// stores it, and posts the vote prompt to the game room with TwinBee narration. -func (p *AdventurePlugin) coopGenerateAndPostEvent(run *CoopRun, members []CoopMember, day int) { - cat := pickCoopEventCategory(run.Tier) - idx := pickCoopEvent(cat) - rec := coopEventRecommended(cat, idx) - if err := createCoopEvent(run.ID, day, cat, idx, rec); err != nil { - slog.Error("coop: create event", "run", run.ID, "day", day, "err", err) - return - } - gr := gamesRoom() - if gr == "" { - return - } - event, _ := loadCoopEvent(run.ID, day) - if event == nil { - return - } - postID, err := p.SendMessageID(gr, renderCoopEventPost(run, members, event)) - if err != nil { - slog.Error("coop: post event", "err", err) - return - } - _ = saveCoopEventPostID(run.ID, day, postID) -} - -// ── Daily Resolution ──────────────────────────────────────────────────────── - -func (p *AdventurePlugin) coopProcessActiveRuns() { - runs, err := loadActiveCoopRuns() - if err != nil { - slog.Error("coop: load active runs", "err", err) - return - } - now := time.Now().UTC() - for _, run := range runs { - // Need at least a 12h funding window between lock and the first - // floor resolution. Without this, a run locked at 07:00 UTC would - // resolve an hour later at 08:00 UTC. With per-minute lock checks - // (locks now fire any time), the previous "same UTC date" guard - // was too conservative — it pushed first-day windows out to 24-32h - // even when 13-23h would have been plenty. - if run.LockedAt != nil && now.Sub(run.LockedAt.UTC()) < 12*time.Hour { - continue - } - if err := p.coopResolveFloor(run); err != nil { - slog.Error("coop: resolve floor", "run", run.ID, "err", err) - } - } -} - -// coopResolveFloor processes the current day's floor: auto-plays missing -// funders, rolls outcome, advances or completes the run. -func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error { - day := run.CurrentDay - - // Skip only if this run is fully terminal. A "resolved" day with the run - // still in 'active' status means a prior tick crashed mid-distribution; - // we must re-enter to finish via idempotent operations (per-row claims). - if run.LastResolvedDay >= day && run.Status != "active" { - return nil - } - - members, err := loadCoopMembers(run.ID) - if err != nil { - return err - } - - // Lock all party members in stable UserID order so concurrent !coop fund / - // vote / giftvote commands don't race the resolver. Stable order rules - // out lock-order deadlock with other goroutines that lock multiple users. - sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID }) - mutexes := make([]*sync.Mutex, 0, len(members)) - for _, m := range members { - mu := p.advUserLock(m.UserID) - mu.Lock() - mutexes = append(mutexes, mu) - } - defer func() { - for _, mu := range mutexes { - mu.Unlock() - } - }() - - // Reload the run after acquiring locks. A concurrent admin action or - // another scheduler tick may have moved the state. - freshRun, err := loadCoopRun(run.ID) - if err != nil || freshRun == nil { - return err - } - run = freshRun - if run.Status != "active" { - return nil - } - // Re-check skip after lock acquisition (another tick could have already - // fully finalized — though our per-tick guard makes that unlikely). - if run.LastResolvedDay >= day && run.Status != "active" { - return nil - } - // Reload members under lock for fresh funding state. - members, err = loadCoopMembers(run.ID) - if err != nil { - return err - } - sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID }) - - // Resume vs. fresh detection: if the day's event already has an outcome, - // a prior tick rolled this floor before crashing. Reuse that outcome — - // re-rolling would be the worst sin (different result on retry). - event, _ := loadCoopEvent(run.ID, day) - resuming := event != nil && event.Outcome != "" - - autoPlayed := []*CoopMember{} - choices := make(map[string]CoopFundingTier, len(members)) - var floorWon bool - var winning string - var eventMod, successPct int - var giftSummary string - - if resuming { - floorWon = event.Outcome == "success" - winning = event.WinningVote - eventMod = event.ModifierApplied - // Reconstruct choices for display purposes. - for _, m := range members { - tier, _ := coopMemberModifier(&m, day) - choices[string(m.UserID)] = tier - } - successPct = 0 // unknown on resume; render hides it - } else { - // Auto-play any member who didn't fund. - for i := range members { - m := &members[i] - if _, ok := m.DailyFunding[day]; !ok { - m.DailyFunding[day] = CoopFundNone - if err := saveCoopMemberFunding(m); err != nil { - slog.Error("coop: auto-play save", "run", run.ID, "user", m.UserID, "err", err) - } - autoPlayed = append(autoPlayed, m) - } - } - - totalMod := 0 - tierDef := coopTierTable[run.Tier] - for _, m := range members { - tier, mod := coopMemberModifier(&m, day) - choices[string(m.UserID)] = tier - totalMod += mod - char, err := loadAdvCharacter(m.UserID) - if err != nil { - continue - } - totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel) - totalMod += coopPetBonus(char) - } - - // Lazy-create the day's event if missing — covers the crash window - // between lock and the original event creation. - if event == nil { - cat := pickCoopEventCategory(run.Tier) - idx := pickCoopEvent(cat) - _ = createCoopEvent(run.ID, day, cat, idx, coopEventRecommended(cat, idx)) - event, _ = loadCoopEvent(run.ID, day) - } - - if event != nil { - winning = coopTallyVote(event, run.LeaderID) - eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning) - totalMod += eventMod - } - - var giftMod int - giftMod, giftSummary = p.coopResolveGifts(run, run.LeaderID, day) - totalMod += giftMod - - successPct = 100 - run.BaseDifficulty + totalMod - if successPct < 5 { - successPct = 5 - } - if successPct > 95 { - successPct = 95 - } - - roll := rand.IntN(100) - floorWon = roll < successPct - - // Persist event resolution — turns this into a "resume" path on retry. - if event != nil { - outcomeStr := "failure" - if floorWon { - outcomeStr = "success" - } - _ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod) - } - - dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod) - if giftSummary != "" { - dailyPost += "\n\n" + giftSummary - } - if gr := gamesRoom(); gr != "" { - _ = p.SendMessage(gr, dailyPost) - } - } - - gr := gamesRoom() - if !floorWon { - // Resolve bets first (idempotent via claimCoopBetPayout), then mark - // the run wiped. Order matters: if status is set first and the bot - // crashes, the next tick won't pick this run up. - summary := p.coopResolveBets(run, false) - giftLog := renderCoopGiftLog(p, run.ID) - parts := []string{} - if summary != "" { - parts = append(parts, summary) - } - if giftLog != "" { - parts = append(parts, giftLog) - } - if len(parts) > 0 && gr != "" { - _ = p.SendMessage(gr, strings.Join(parts, "\n\n")) - } - _ = completeCoopRun(run.ID, "wiped", 0) - _ = unlockCoopCombatActionsForRun(run.ID) - _ = setCoopRunLastResolvedDay(run.ID, day) - return nil - } - - if day >= run.TotalDays { - def := coopTierTable[run.Tier] - reward := def.rewardBase - p.coopDistributeReward(run, reward, members) - _ = completeCoopRun(run.ID, "complete", reward) - _ = unlockCoopCombatActionsForRun(run.ID) - _ = setCoopRunLastResolvedDay(run.ID, day) - return nil - } - if err := advanceCoopDay(run.ID, day+1); err != nil { - return err - } - for _, m := range members { - _ = p.SendDM(m.UserID, renderCoopFundingPrompt(run, day+1)) - } - freshRun, _ = loadCoopRun(run.ID) - if freshRun != nil { - p.coopGenerateAndPostEvent(freshRun, members, day+1) - } - _ = setCoopRunLastResolvedDay(run.ID, day) - return nil -} - -// coopTallyVote counts votes; majority wins. Ties resolve to the leader's -// vote if it's among the tied options; otherwise the lexicographically first -// tied label (deterministic, since Go map iteration is randomized). -func coopTallyVote(event *CoopEvent, leader id.UserID) string { - tally := map[string]int{} - for _, v := range event.Votes { - tally[v]++ - } - bestCount := -1 - var winners []string - for label, n := range tally { - if n > bestCount { - bestCount = n - winners = []string{label} - } else if n == bestCount { - winners = append(winners, label) - } - } - if len(winners) == 1 { - return winners[0] - } - sort.Strings(winners) - if leaderVote, ok := event.Votes[leader]; ok { - for _, w := range winners { - if w == leaderVote { - return w - } - } - } - if len(winners) > 0 { - return winners[0] - } - return "A" -} - -func (p *AdventurePlugin) coopDistributeReward(run *CoopRun, totalReward int, members []CoopMember) { - if len(members) == 0 || totalReward <= 0 { - return - } - share := totalReward / len(members) - gr := gamesRoom() - var lines []string - for _, m := range members { - // Atomic claim: only credit if this is the first time we're paying - // this member for this run. Skip silently on retry. - claimed, err := claimCoopMemberPayout(run.ID, m.UserID, share) - if err != nil || !claimed { - continue - } - net, _ := communityTax(m.UserID, float64(share), coopAdventureRake) - p.euro.Credit(m.UserID, net, "coop_dungeon_reward") - lines = append(lines, fmt.Sprintf(" %s: €%.0f (after tax)", coopDisplayName(p, m.UserID), net)) - _ = p.SendDM(m.UserID, fmt.Sprintf("⚔️ Co-op #%d cleared. Your share: €%.0f.", run.ID, net)) - } - - // Generate item drops and distribute via weighted roll. - itemSummary := p.coopDistributeItems(run, members) - - bettingSummary := p.coopResolveBets(run, true) - giftLog := renderCoopGiftLog(p, run.ID) - if gr != "" { - msg := fmt.Sprintf("⚔️ Co-op #%d cleared. Reward pool: €%d.\n\n%s", - run.ID, totalReward, strings.Join(lines, "\n")) - if itemSummary != "" { - msg += "\n\n" + itemSummary - } - if bettingSummary != "" { - msg += "\n\n" + bettingSummary - } - if giftLog != "" { - msg += "\n\n" + giftLog - } - _ = p.SendMessage(gr, msg) - } -} - -// coopDistributeItems generates item drops for a successful run and assigns -// each via weighted roll across party members (weight = total_contributed). -// Items are added to the winner's inventory; returns a summary line. -func (p *AdventurePlugin) coopDistributeItems(run *CoopRun, members []CoopMember) string { - loc := findAdvLocationByTier(AdvActivityDungeon, run.Tier) - if loc == nil { - return "" - } - // Item count scales with tier: T1 = 2, T5 = 6. - count := 2 + (run.Tier - 1) - var allItems []AdvItem - for i := 0; i < count; i++ { - allItems = append(allItems, generateAdvLoot(loc, false, 0)...) - } - if len(allItems) == 0 { - return "" - } - totalWeight := 0 - for _, m := range members { - totalWeight += m.TotalContributed - } - - var lines []string - for _, item := range allItems { - winner := coopWeightedItemWinner(members, totalWeight) - _ = addAdvInventoryItem(winner, item) - lines = append(lines, fmt.Sprintf(" %s (€%d) → %s", item.Name, item.Value, coopDisplayName(p, winner))) - } - if mwLine := p.coopMaybeMasterwork(run, members, totalWeight); mwLine != "" { - lines = append(lines, mwLine) - } - return "Item drops:\n" + strings.Join(lines, "\n") -} - -// coopMaybeMasterwork grants a tier-appropriate masterwork drop on T4 (25%) -// or T5 (guaranteed). Picks a random masterwork def at the run's tier from -// the existing pool (mining sword / fishing armor / foraging boots), awards -// it via the same weighted roll, and announces in the game room. -// -// Returns a one-line summary to splice into the completion post, or "". -func (p *AdventurePlugin) coopMaybeMasterwork(run *CoopRun, members []CoopMember, totalWeight int) string { - if run.Tier < 4 { - return "" - } - if run.Tier == 4 && rand.Float64() >= 0.25 { - return "" - } - - // Collect all masterwork defs at the run's tier. - candidates := []MasterworkDef{} - for _, def := range masterworkDefs { - if def.Tier == run.Tier { - candidates = append(candidates, def) - } - } - if len(candidates) == 0 { - return "" - } - def := candidates[rand.IntN(len(candidates))] - winner := coopWeightedItemWinner(members, totalWeight) - if winner == "" { - return "" - } - - // Add to inventory as MasterworkGear (don't auto-equip — let the winner - // choose via !adventure equip; their existing gear may be better). - item := AdvItem{ - Name: def.Name, - Type: "MasterworkGear", - Tier: def.Tier, - Value: 0, - Slot: def.Slot, - SkillSource: def.SkillSource, - } - if err := addAdvInventoryItem(winner, item); err != nil { - slog.Error("coop: masterwork inventory add", "winner", winner, "err", err) - return "" - } - - char, _ := loadAdvCharacter(winner) - if char != nil { - char.MasterworkDropsReceived++ - _ = saveAdvCharacter(char) - } - - dmText := fmt.Sprintf("⭐ **Co-op Masterwork Drop: %s** (T%d %s)\n\n_%s_\n\nMasterwork %s — 1.25x effectiveness, +5%% %s success.\n\nAdded to inventory. `!adventure equip` to use it.", - def.Name, def.Tier, slotTitle(def.Slot), def.Description, slotTitle(def.Slot), def.SkillSource) - _ = p.SendDM(winner, dmText) - - return fmt.Sprintf("⭐ Masterwork: %s (T%d %s) → %s", - def.Name, def.Tier, slotTitle(def.Slot), coopDisplayName(p, winner)) -} - -// coopWeightedItemWinner picks a member with probability proportional to their -// total funding contribution. If no one has contributed (e.g., all None on -// every day), falls back to uniform random. -func coopWeightedItemWinner(members []CoopMember, totalWeight int) id.UserID { - if len(members) == 0 { - return "" - } - if totalWeight <= 0 { - return members[rand.IntN(len(members))].UserID - } - r := rand.IntN(totalWeight) - cum := 0 - for _, m := range members { - cum += m.TotalContributed - if r < cum { - return m.UserID - } - } - return members[len(members)-1].UserID -} diff --git a/internal/plugin/coop_dungeon_stats.go b/internal/plugin/coop_dungeon_stats.go deleted file mode 100644 index 35096f5..0000000 --- a/internal/plugin/coop_dungeon_stats.go +++ /dev/null @@ -1,245 +0,0 @@ -package plugin - -import ( - "database/sql" - "fmt" - "strings" - - "gogobee/internal/db" -) - -// ── Stats aggregation ────────────────────────────────────────────────────── - -type coopTierStats struct { - Tier int - Open int - Active int - Complete int - Wiped int - Cancelled int - AvgPartySize float64 - TotalReward int64 // Σ reward_total of completed runs - TotalForfeited int64 // Σ gold_pool of wiped runs (lost to the dungeon) -} - -type coopBetStats struct { - BetsPlaced int - TotalStake int64 - TotalPaid int64 // sum of non-NULL payouts (winners) - BettorsLT int // distinct bettors lifetime -} - -type coopGiftStats struct { - BasketSent int - MimicSent int - BasketOpened int - BasketLeft int - MimicOpened int - MimicLeft int -} - -func loadCoopTierStats() ([]coopTierStats, error) { - d := db.Get() - stats := map[int]*coopTierStats{} - for t := 1; t <= 5; t++ { - stats[t] = &coopTierStats{Tier: t} - } - - // Run outcomes by tier - rows, err := d.Query(`SELECT tier, status, COUNT(*) FROM coop_dungeon_runs - GROUP BY tier, status`) - if err != nil { - return nil, err - } - defer rows.Close() - for rows.Next() { - var tier int - var status string - var n int - if err := rows.Scan(&tier, &status, &n); err != nil { - continue - } - s, ok := stats[tier] - if !ok { - continue - } - switch status { - case "open": - s.Open = n - case "active": - s.Active = n - case "complete": - s.Complete = n - case "wiped": - s.Wiped = n - case "cancelled": - s.Cancelled = n - } - } - - // Avg party size by tier (over locked runs only — open invites with one - // member would bias the number). - rows2, err := d.Query(`SELECT r.tier, AVG(m.cnt) FROM coop_dungeon_runs r - JOIN (SELECT run_id, COUNT(*) cnt FROM coop_dungeon_members GROUP BY run_id) m - ON m.run_id = r.id - WHERE r.status IN ('active','complete','wiped') - GROUP BY r.tier`) - if err == nil { - defer rows2.Close() - for rows2.Next() { - var tier int - var avg sql.NullFloat64 - if err := rows2.Scan(&tier, &avg); err == nil && avg.Valid { - if s, ok := stats[tier]; ok { - s.AvgPartySize = avg.Float64 - } - } - } - } - - // Reward / forfeit totals per tier - rows3, err := d.Query(`SELECT tier, status, SUM(reward_total), SUM(gold_pool) - FROM coop_dungeon_runs WHERE status IN ('complete','wiped') - GROUP BY tier, status`) - if err == nil { - defer rows3.Close() - for rows3.Next() { - var tier int - var status string - var reward, pool sql.NullInt64 - if err := rows3.Scan(&tier, &status, &reward, &pool); err != nil { - continue - } - s, ok := stats[tier] - if !ok { - continue - } - if status == "complete" && reward.Valid { - s.TotalReward += reward.Int64 - } - if status == "wiped" && pool.Valid { - s.TotalForfeited += pool.Int64 - } - } - } - - out := make([]coopTierStats, 0, 5) - for t := 1; t <= 5; t++ { - out = append(out, *stats[t]) - } - return out, nil -} - -func loadCoopBetStats() (coopBetStats, error) { - d := db.Get() - var s coopBetStats - var stake, paid sql.NullInt64 - err := d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(amount),0), - COALESCE(SUM(CASE WHEN payout > 0 THEN payout ELSE 0 END), 0) - FROM coop_dungeon_bets`).Scan(&s.BetsPlaced, &stake, &paid) - if err != nil { - return s, err - } - s.TotalStake = stake.Int64 - s.TotalPaid = paid.Int64 - _ = d.QueryRow(`SELECT COUNT(DISTINCT player_id) FROM coop_dungeon_bets`).Scan(&s.BettorsLT) - return s, nil -} - -func loadCoopGiftStats() (coopGiftStats, error) { - d := db.Get() - var s coopGiftStats - rows, err := d.Query(`SELECT gift_type, COALESCE(vote_result, 'pending'), COUNT(*) - FROM coop_dungeon_gifts GROUP BY gift_type, vote_result`) - if err != nil { - return s, err - } - defer rows.Close() - for rows.Next() { - var giftType, voteResult string - var n int - if err := rows.Scan(&giftType, &voteResult, &n); err != nil { - continue - } - switch giftType { - case coopGiftBasket: - s.BasketSent += n - if voteResult == "opened" { - s.BasketOpened = n - } else if voteResult == "left" { - s.BasketLeft = n - } - case coopGiftMimic: - s.MimicSent += n - if voteResult == "opened" { - s.MimicOpened = n - } else if voteResult == "left" { - s.MimicLeft = n - } - } - } - return s, nil -} - -// ── Render ────────────────────────────────────────────────────────────────── - -func renderCoopStats() string { - tiers, _ := loadCoopTierStats() - bets, _ := loadCoopBetStats() - gifts, _ := loadCoopGiftStats() - help30 := coopTwinBeeHelpfulness(30) - helpAll := coopTwinBeeHelpfulness(10000) // effectively lifetime - - var sb strings.Builder - sb.WriteString("⚔️ **Co-op Dungeon Stats**\n\n") - - sb.WriteString("**Runs by tier**\n") - sb.WriteString("```\n") - sb.WriteString(fmt.Sprintf("%-6s %-6s %-6s %-6s %-6s %-6s %-7s %-12s\n", - "tier", "open", "active", "won", "wiped", "canc", "win%", "avg party")) - for _, t := range tiers { - finished := t.Complete + t.Wiped - winPct := 0.0 - if finished > 0 { - winPct = float64(t.Complete) / float64(finished) * 100 - } - sb.WriteString(fmt.Sprintf("T%-5d %-6d %-6d %-6d %-6d %-6d %-6.1f%% %-12.2f\n", - t.Tier, t.Open, t.Active, t.Complete, t.Wiped, t.Cancelled, winPct, t.AvgPartySize)) - } - sb.WriteString("```\n\n") - - sb.WriteString("**Gold flow**\n") - var totalReward, totalForfeit int64 - for _, t := range tiers { - totalReward += t.TotalReward - totalForfeit += t.TotalForfeited - } - sb.WriteString(fmt.Sprintf(" Rewards distributed: €%d\n", totalReward)) - sb.WriteString(fmt.Sprintf(" Funding forfeited (wipes): €%d\n\n", totalForfeit)) - - sb.WriteString("**Spectator betting**\n") - rake := int64(float64(bets.TotalStake) * coopBetRake) - sb.WriteString(fmt.Sprintf(" Bets placed: %d (by %d distinct bettors)\n", bets.BetsPlaced, bets.BettorsLT)) - sb.WriteString(fmt.Sprintf(" Total wagered: €%d\n", bets.TotalStake)) - sb.WriteString(fmt.Sprintf(" Paid to winners: €%d\n", bets.TotalPaid)) - sb.WriteString(fmt.Sprintf(" House cut (TwinBee): ~€%d\n\n", rake)) - - sb.WriteString("**Gifts**\n") - openRate := func(opened, total int) float64 { - if total == 0 { - return 0 - } - return float64(opened) / float64(total) * 100 - } - sb.WriteString(fmt.Sprintf(" Baskets sent: %d (opened %d, left %d, %.0f%% open rate)\n", - gifts.BasketSent, gifts.BasketOpened, gifts.BasketLeft, - openRate(gifts.BasketOpened, gifts.BasketOpened+gifts.BasketLeft))) - sb.WriteString(fmt.Sprintf(" Mimics sent: %d (opened %d, left %d, %.0f%% open rate)\n\n", - gifts.MimicSent, gifts.MimicOpened, gifts.MimicLeft, - openRate(gifts.MimicOpened, gifts.MimicOpened+gifts.MimicLeft))) - - sb.WriteString("**TwinBee helpfulness**\n") - sb.WriteString(fmt.Sprintf(" Last 30 events: %.0f%%\n", help30*100)) - sb.WriteString(fmt.Sprintf(" Lifetime: %.0f%%\n", helpAll*100)) - return sb.String() -} diff --git a/internal/plugin/coop_dungeon_test.go b/internal/plugin/coop_dungeon_test.go deleted file mode 100644 index 2a76c0f..0000000 --- a/internal/plugin/coop_dungeon_test.go +++ /dev/null @@ -1,375 +0,0 @@ -package plugin - -import ( - "testing" - - "maunium.net/go/mautrix/id" -) - -func TestParseCoopFundingTier(t *testing.T) { - cases := map[string]CoopFundingTier{ - "none": CoopFundNone, - "skip": CoopFundNone, - "min": CoopFundMinimal, - "minimal": CoopFundMinimal, - "standard": CoopFundStandard, - "std": CoopFundStandard, - "aggressive": CoopFundAggressive, - "agg": CoopFundAggressive, - "all_in": CoopFundAllIn, - "all-in": CoopFundAllIn, - "all in": CoopFundAllIn, - "allin": CoopFundAllIn, - "all": CoopFundAllIn, - "": "", - "garbage": "", - } - for input, want := range cases { - got := parseCoopFundingTier(input) - if got != want { - t.Errorf("parseCoopFundingTier(%q) = %q, want %q", input, got, want) - } - } -} - -func TestCoopMemberModifierLiabilityCap(t *testing.T) { - t.Parallel() - - veteran := &CoopMember{ - IsLiability: false, - DailyFunding: map[int]CoopFundingTier{1: CoopFundAllIn}, - } - noob := &CoopMember{ - IsLiability: true, - DailyFunding: map[int]CoopFundingTier{1: CoopFundAllIn}, - } - - if _, mod := coopMemberModifier(veteran, 1); mod != 30 { - t.Errorf("veteran All In modifier = %d, want 30", mod) - } - if _, mod := coopMemberModifier(noob, 1); mod != coopLiabilityCap { - t.Errorf("liability All In modifier = %d, want %d (cap)", mod, coopLiabilityCap) - } - - missing := &CoopMember{DailyFunding: map[int]CoopFundingTier{}} - tier, mod := coopMemberModifier(missing, 1) - if tier != CoopFundNone || mod != -10 { - t.Errorf("missing-day default = (%q, %d), want (none, -10)", tier, mod) - } -} - -func TestCoopFundingMapRoundtrip(t *testing.T) { - t.Parallel() - - in := map[int]CoopFundingTier{ - 1: CoopFundMinimal, - 3: CoopFundAllIn, - 7: CoopFundNone, - } - encoded := serializeCoopFundingMap(in) - // JSON encode/decode via parse helper - bytes, _ := jsonMarshalCoop(encoded) - out := parseCoopFundingMap(string(bytes)) - - if len(out) != len(in) { - t.Fatalf("roundtrip lost entries: in %d out %d", len(in), len(out)) - } - for k, v := range in { - if out[k] != v { - t.Errorf("day %d: got %q want %q", k, out[k], v) - } - } -} - -func TestCoopTallyVote(t *testing.T) { - t.Parallel() - - leader := id.UserID("@leader:test") - other := id.UserID("@other:test") - third := id.UserID("@third:test") - - tests := []struct { - name string - votes map[id.UserID]string - want string - }{ - {"clear majority", map[id.UserID]string{leader: "A", other: "A", third: "B"}, "A"}, - {"tie broken by leader", map[id.UserID]string{leader: "B", other: "A"}, "B"}, - {"all abstained falls back to A", map[id.UserID]string{}, "A"}, - {"tie with no leader vote → lex first (deterministic)", map[id.UserID]string{other: "A", third: "B"}, "A"}, - {"three-way tie, leader's vote in winners → leader's pick", map[id.UserID]string{leader: "C", other: "A", third: "B"}, "C"}, - {"tie with leader voting outside winners → lex first", map[id.UserID]string{leader: "C", other: "A", "@4:t": "A", third: "B", "@5:t": "B"}, "A"}, - } - for _, tc := range tests { - // Run repeatedly to catch any non-determinism leaking from map iteration. - for i := 0; i < 50; i++ { - event := &CoopEvent{Votes: tc.votes} - got := coopTallyVote(event, leader) - if got != tc.want { - t.Errorf("%s (iter %d): got %q, want %q", tc.name, i, got, tc.want) - break - } - } - } -} - -func TestCoopEventOptionModifierKnownEvent(t *testing.T) { - t.Parallel() - // Obstacle 0 has options A=-8, B=0, C=12. Verify the lookup works. - if got := coopEventOptionModifier(CoopCatObstacle, 0, "A"); got != -8 { - t.Errorf("obstacle[0] A modifier = %d, want -8", got) - } - if got := coopEventOptionModifier(CoopCatObstacle, 0, "C"); got != 12 { - t.Errorf("obstacle[0] C modifier = %d, want 12", got) - } - if got := coopEventOptionModifier(CoopCatObstacle, 0, "Z"); got != 0 { - t.Errorf("unknown option = %d, want 0", got) - } -} - -func TestCoopEventMetaConsistency(t *testing.T) { - t.Parallel() - cats := map[CoopEventCategory][]coopEventMeta{ - CoopCatObstacle: coopObstacleMeta, - CoopCatOpportunity: coopOpportunityMeta, - CoopCatCrisis: coopCrisisMeta, - CoopCatEncounter: coopEncounterMeta, - } - flavors := map[CoopEventCategory][]string{ - CoopCatObstacle: TwinBeeObstacle, - CoopCatOpportunity: TwinBeeOpportunity, - CoopCatCrisis: TwinBeeCrisis, - CoopCatEncounter: TwinBeeEncounter, - } - for cat, meta := range cats { - flavor := flavors[cat] - if len(meta) != len(flavor) { - t.Errorf("%s: meta has %d entries, flavor has %d", cat, len(meta), len(flavor)) - } - for i, m := range meta { - if len(m.options) < 2 { - t.Errorf("%s[%d] has only %d options", cat, i, len(m.options)) - } - recOK := false - for _, opt := range m.options { - if opt.label == m.recommended { - recOK = true - } - } - if !recOK { - t.Errorf("%s[%d] recommends %q which isn't an option", cat, i, m.recommended) - } - } - } -} - -func TestCoopResolutionIdempotencyGuard(t *testing.T) { - t.Parallel() - // Sanity check: the LastResolvedDay field on CoopRun is the authoritative - // idempotency marker. The resolver short-circuits when LastResolvedDay >= - // CurrentDay, so a crash-restart on the same UTC day after the roll has - // landed is a safe no-op. - run := &CoopRun{CurrentDay: 3, LastResolvedDay: 3} - if !(run.LastResolvedDay >= run.CurrentDay) { - t.Errorf("expected resolution skip when LastResolvedDay (%d) >= CurrentDay (%d)", - run.LastResolvedDay, run.CurrentDay) - } - run.LastResolvedDay = 2 - if run.LastResolvedDay >= run.CurrentDay { - t.Errorf("expected resolution to proceed when LastResolvedDay (%d) < CurrentDay (%d)", - run.LastResolvedDay, run.CurrentDay) - } -} - -func TestCoopParimutuelPayouts(t *testing.T) { - t.Parallel() - a := id.UserID("@a:t") - b := id.UserID("@b:t") - c := id.UserID("@c:t") - d := id.UserID("@d:t") - - bets := []CoopBet{ - {PlayerID: a, Position: "success", Amount: 1000}, - {PlayerID: b, Position: "success", Amount: 3000}, - {PlayerID: c, Position: "failure", Amount: 5000}, - {PlayerID: d, Position: "failure", Amount: 1000}, - } - winners, total, rake, payouts := coopParimutuelPayouts(bets, "success") - if total != 10000 { - t.Errorf("total = %d, want 10000", total) - } - if rake != 1000 { - t.Errorf("rake = %d, want 1000 (10%%)", rake) - } - if len(winners) != 2 { - t.Errorf("winners = %d, want 2", len(winners)) - } - // Net pool = 9000. a put in 1000/4000 = 25% of winning side → 2250. - // b put in 3000/4000 = 75% → 6750. - if payouts[a] != 2250 { - t.Errorf("a payout = %d, want 2250", payouts[a]) - } - if payouts[b] != 6750 { - t.Errorf("b payout = %d, want 6750", payouts[b]) - } - if payouts[c] != 0 { - t.Errorf("c payout = %d, want 0 (loser)", payouts[c]) - } -} - -func TestCoopWeightedItemWinnerDistribution(t *testing.T) { - t.Parallel() - a := id.UserID("@a:t") - b := id.UserID("@b:t") - c := id.UserID("@c:t") - members := []CoopMember{ - {UserID: a, TotalContributed: 7000}, // 70% - {UserID: b, TotalContributed: 2000}, // 20% - {UserID: c, TotalContributed: 1000}, // 10% - } - totalWeight := 10000 - wins := map[id.UserID]int{} - const trials = 100_000 - for i := 0; i < trials; i++ { - w := coopWeightedItemWinner(members, totalWeight) - wins[w]++ - } - // Allow ±2% absolute deviation per side at this trial count. - check := func(uid id.UserID, expected float64) { - actual := float64(wins[uid]) / float64(trials) - if actual < expected-0.02 || actual > expected+0.02 { - t.Errorf("%s: got %.3f, want ~%.2f", uid, actual, expected) - } - } - check(a, 0.70) - check(b, 0.20) - check(c, 0.10) -} - -func TestCoopWeightedItemWinnerNoContributions(t *testing.T) { - t.Parallel() - a := id.UserID("@a:t") - b := id.UserID("@b:t") - members := []CoopMember{ - {UserID: a, TotalContributed: 0}, - {UserID: b, TotalContributed: 0}, - } - wins := map[id.UserID]int{} - for i := 0; i < 10_000; i++ { - wins[coopWeightedItemWinner(members, 0)]++ - } - if wins[a] == 0 || wins[b] == 0 { - t.Errorf("uniform fallback didn't pick both: %v", wins) - } -} - -func TestCoopMasterworkTierGate(t *testing.T) { - t.Parallel() - // Verify that masterwork defs exist at T4 and T5 (so coopMaybeMasterwork - // has candidates) and that nothing exists at T1-T3 in the dungeon path. - tiers := map[int]int{} - for _, def := range masterworkDefs { - tiers[def.Tier]++ - } - if tiers[4] == 0 { - t.Errorf("no T4 masterwork defs found — coop T4 drops will silently no-op") - } - if tiers[5] == 0 { - t.Errorf("no T5 masterwork defs found — coop T5 drops will silently no-op") - } -} - -func TestCoopGiftEVSymmetric(t *testing.T) { - t.Parallel() - // At a 50/50 sender mix, "always open" and "always leave" should both - // have EV = 0. Anything else means players have a dominant strategy. - openEV := 0.5*float64(coopGiftBasketOpened) + 0.5*float64(coopGiftMimicOpened) - leaveEV := 0.5*float64(coopGiftBasketUnopened) + 0.5*float64(coopGiftMimicUnopened) - if openEV != 0 { - t.Errorf("always-open EV = %.2f, want 0", openEV) - } - if leaveEV != 0 { - t.Errorf("always-leave EV = %.2f, want 0", leaveEV) - } -} - -func TestCoopGiftVarianceSymmetric(t *testing.T) { - t.Parallel() - // Equal EV is not enough — if open and leave have different variance, a - // risk-averse party always picks the lower-variance side and the - // dilemma collapses. Magnitudes must be equal across all four outcomes. - abs := func(x int) int { - if x < 0 { - return -x - } - return x - } - mags := []int{ - abs(coopGiftBasketOpened), - abs(coopGiftBasketUnopened), - abs(coopGiftMimicOpened), - abs(coopGiftMimicUnopened), - } - for i := 1; i < len(mags); i++ { - if mags[i] != mags[0] { - t.Errorf("gift magnitudes must be equal across all four outcomes; got %v", mags) - break - } - } -} - -func TestCoopParimutuelNoWinners(t *testing.T) { - t.Parallel() - bets := []CoopBet{ - {PlayerID: "@x:t", Position: "failure", Amount: 5000}, - } - winners, total, rake, payouts := coopParimutuelPayouts(bets, "success") - if len(winners) != 0 { - t.Errorf("winners = %d, want 0", len(winners)) - } - if total != 5000 || rake != 500 { - t.Errorf("total=%d rake=%d, want 5000 500", total, rake) - } - if len(payouts) != 0 { - t.Errorf("payouts has %d entries; want 0", len(payouts)) - } -} - -func TestCoopTierTableComplete(t *testing.T) { - for tier := 1; tier <= 5; tier++ { - def, ok := coopTierTable[tier] - if !ok { - t.Errorf("missing tier %d", tier) - continue - } - if def.totalDays < 2 || def.totalDays > 7 { - t.Errorf("tier %d totalDays %d out of range", tier, def.totalDays) - } - if def.baseFailurePct < 1 || def.baseFailurePct > 99 { - t.Errorf("tier %d baseFailurePct %d out of range", tier, def.baseFailurePct) - } - if def.rewardBase <= 0 { - t.Errorf("tier %d rewardBase %d not positive", tier, def.rewardBase) - } - } -} - -// jsonMarshalCoop is a tiny indirection so the test file doesn't import -// encoding/json directly (we only need it for the roundtrip helper). -func jsonMarshalCoop(v map[string]string) ([]byte, error) { - out := []byte{'{'} - first := true - for k, val := range v { - if !first { - out = append(out, ',') - } - first = false - out = append(out, '"') - out = append(out, k...) - out = append(out, '"', ':', '"') - out = append(out, val...) - out = append(out, '"') - } - out = append(out, '}') - return out, nil -} diff --git a/internal/plugin/coop_event_meta.go b/internal/plugin/coop_event_meta.go deleted file mode 100644 index 142efbb..0000000 --- a/internal/plugin/coop_event_meta.go +++ /dev/null @@ -1,203 +0,0 @@ -package plugin - -import ( - "math/rand/v2" - "strings" -) - -// Metadata for each TwinBee floor event. Encodes the per-option success -// modifiers and TwinBee's recommendation. Authored by reading the flavor -// entries — TwinBee's "wrong detail" hint usually points to which option is -// actually correct. -// -// Modifier ranges: −15 (clearly bad) to +12 (clearly good). Sum across -// options is roughly net-zero so randomly-voting parties don't trend +EV. - -type CoopEventCategory string - -const ( - CoopCatObstacle CoopEventCategory = "obstacle" - CoopCatOpportunity CoopEventCategory = "opportunity" - CoopCatCrisis CoopEventCategory = "crisis" - CoopCatEncounter CoopEventCategory = "encounter" -) - -type coopEventOption struct { - label string - modifier int -} - -type coopEventMeta struct { - options []coopEventOption - recommended string // A/B/C — TwinBee's pick -} - -// One entry per event in the corresponding flavor pool, in the same order. -// Keep these arrays in sync with coop_flavor_twinbee.go. - -var coopObstacleMeta = []coopEventMeta{ - // 1. Cave-in: "definitely the left side" — too emphatic; clearing properly is right. - {options: []coopEventOption{{"A", -8}, {"B", 0}, {"C", 12}}, recommended: "A"}, - // 2. Locked gate: "third tumbler might be a pressure plate" — A triggers it. - {options: []coopEventOption{{"A", -12}, {"B", -3}, {"C", 10}}, recommended: "A"}, - // 3. Other party with axe: "axe is just decoration" — almost certainly not. - {options: []coopEventOption{{"A", -15}, {"B", 8}, {"C", -5}}, recommended: "A"}, - // 4. Flooded section: "torches stopped twenty meters back" — water deeper than claimed. - {options: []coopEventOption{{"A", -10}, {"B", 0}, {"C", 10}}, recommended: "A"}, - // 5. Collapsed bridge: "chains might be part of a trap" — they are. - {options: []coopEventOption{{"A", -12}, {"B", 0}, {"C", 8}}, recommended: "A"}, -} - -var coopOpportunityMeta = []coopEventMeta{ - // 1. Vault: "scratches around the lock from previous attempts" — trapped. - {options: []coopEventOption{{"A", -12}, {"B", 3}}, recommended: "A"}, - // 2. Side chamber: "shapes seem to be breathing" — they are. Mimics or worse. - {options: []coopEventOption{{"A", -15}, {"B", 4}}, recommended: "A"}, - // 3. Wounded enemy with full pack: "one eye open, just how they sleep" — ambush. - {options: []coopEventOption{{"A", -10}, {"B", 2}}, recommended: "A"}, - // 4. Unmarked door: "good feeling, ignore the smell" — TwinBee's right this time. - {options: []coopEventOption{{"A", 10}, {"B", -3}}, recommended: "A"}, - // 5. Merchant Thom: usually fine to browse, sometimes a trap. Slight positive. - {options: []coopEventOption{{"A", 6}, {"B", 0}}, recommended: "A"}, -} - -var coopCrisisMeta = []coopEventMeta{ - // 1. Trap, "left lever, definitely not the right one" — C (find another way) safest. - {options: []coopEventOption{{"A", -10}, {"B", 5}, {"C", 8}}, recommended: "A"}, - // 2. Equipment corrosion: "load-bearing thing might be nothing" — pay to address. - {options: []coopEventOption{{"A", 8}, {"B", -10}, {"C", 0}}, recommended: "A"}, - // 3. Separated party member, guard-room hint: TwinBee actually right that they went left; - // Option C ("wait here") is fine too — cheaper and works. - {options: []coopEventOption{{"A", 4}, {"B", -2}, {"C", 6}}, recommended: "A"}, - // 4. Something following, "consistent distance is reassuring" — predator pacing. - // Pay the deterrent. - {options: []coopEventOption{{"A", -8}, {"B", 8}, {"C", 0}}, recommended: "A"}, - // 5. Cave-in with "active" ceiling: pay to shore up. - {options: []coopEventOption{{"A", -10}, {"B", 10}, {"C", -2}}, recommended: "A"}, -} - -var coopEncounterMeta = []coopEventMeta{ - // 1. Guardian, "twelve seconds two out of three times" — timing window unreliable. - {options: []coopEventOption{{"A", 4}, {"B", 6}, {"C", -10}}, recommended: "C"}, - // 2. Caged person, "alarm is decorative" — usually not. Trap, but freeing them is right ~half the time. - {options: []coopEventOption{{"A", 2}, {"B", -3}, {"C", 5}}, recommended: "A"}, - // 3. Sleeping guard, second guard reflected in shiny — there's a second guard. - {options: []coopEventOption{{"A", -10}, {"B", 0}, {"C", 6}}, recommended: "A"}, - // 4. Recurring merchant Thom — keep moving avoids whatever shenanigans. - {options: []coopEventOption{{"A", 2}, {"B", -5}, {"C", 4}}, recommended: "A"}, - // 5. Unidentifiable thing — backing away is the safe call. - {options: []coopEventOption{{"A", -8}, {"B", -2}, {"C", 8}}, recommended: "C"}, -} - -func coopEventCategoryMeta(cat CoopEventCategory) []coopEventMeta { - switch cat { - case CoopCatObstacle: - return coopObstacleMeta - case CoopCatOpportunity: - return coopOpportunityMeta - case CoopCatCrisis: - return coopCrisisMeta - case CoopCatEncounter: - return coopEncounterMeta - } - return nil -} - -func coopEventCategoryFlavor(cat CoopEventCategory) []string { - switch cat { - case CoopCatObstacle: - return TwinBeeObstacle - case CoopCatOpportunity: - return TwinBeeOpportunity - case CoopCatCrisis: - return TwinBeeCrisis - case CoopCatEncounter: - return TwinBeeEncounter - } - return nil -} - -// pickCoopEventCategory weights categories by tier. Lower tiers favor obstacle -// and opportunity (lighter); higher tiers weight toward crisis and encounter. -func pickCoopEventCategory(tier int) CoopEventCategory { - weights := map[int]map[CoopEventCategory]int{ - 1: {CoopCatObstacle: 5, CoopCatOpportunity: 4, CoopCatCrisis: 1, CoopCatEncounter: 1}, - 2: {CoopCatObstacle: 4, CoopCatOpportunity: 3, CoopCatCrisis: 2, CoopCatEncounter: 2}, - 3: {CoopCatObstacle: 3, CoopCatOpportunity: 3, CoopCatCrisis: 3, CoopCatEncounter: 2}, - 4: {CoopCatObstacle: 2, CoopCatOpportunity: 2, CoopCatCrisis: 4, CoopCatEncounter: 3}, - 5: {CoopCatObstacle: 2, CoopCatOpportunity: 2, CoopCatCrisis: 4, CoopCatEncounter: 4}, - } - w, ok := weights[tier] - if !ok { - w = weights[3] - } - total := 0 - for _, x := range w { - total += x - } - r := rand.IntN(total) - for cat, x := range w { - if r < x { - return cat - } - r -= x - } - return CoopCatObstacle -} - -// pickCoopEvent returns a random event index for the category. -func pickCoopEvent(cat CoopEventCategory) int { - pool := coopEventCategoryMeta(cat) - if len(pool) == 0 { - return 0 - } - return rand.IntN(len(pool)) -} - -// optionLabels returns the label list for an event ("A","B" or "A","B","C"). -func coopEventOptionLabels(cat CoopEventCategory, idx int) []string { - pool := coopEventCategoryMeta(cat) - if idx >= len(pool) { - return []string{"A", "B"} - } - out := make([]string, 0, len(pool[idx].options)) - for _, opt := range pool[idx].options { - out = append(out, opt.label) - } - return out -} - -// optionModifier returns the success modifier for the chosen option, or 0 if -// the option is unknown. -func coopEventOptionModifier(cat CoopEventCategory, idx int, label string) int { - pool := coopEventCategoryMeta(cat) - if idx >= len(pool) { - return 0 - } - for _, opt := range pool[idx].options { - if opt.label == label { - return opt.modifier - } - } - return 0 -} - -// recommendedOption returns TwinBee's pick for the event. -func coopEventRecommended(cat CoopEventCategory, idx int) string { - pool := coopEventCategoryMeta(cat) - if idx >= len(pool) { - return "A" - } - return pool[idx].recommended -} - -// validateCoopVote uppercases and validates a vote against the available options. -func validateCoopVote(cat CoopEventCategory, idx int, raw string) (string, bool) { - v := strings.ToUpper(strings.TrimSpace(raw)) - for _, label := range coopEventOptionLabels(cat, idx) { - if v == label { - return v, true - } - } - return "", false -} diff --git a/internal/plugin/coop_flavor_twinbee.go b/internal/plugin/coop_flavor_twinbee.go deleted file mode 100644 index 60286fb..0000000 --- a/internal/plugin/coop_flavor_twinbee.go +++ /dev/null @@ -1,523 +0,0 @@ -package plugin - -// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ── -// -// This file contains hand-written flavor text. Every entry is the length it is -// on purpose. The voice, pacing, and specific wording are intentional. -// If an entry seems too long: it isn't. Leave it alone. -// If you are tempted to "clean up" or "condense" entries: don't. -// If your context window is too small to fit this file: that is your problem, not ours. -// -// ── TWINBEE — CO-OP DUNGEON FLOOR EVENT FLAVOR TEXT ────────────────────────── -// -// TwinBee is the dungeon narrator. He is extremely excited about this. -// He is genuinely trying to help. He is mostly not helpful. -// His descriptions are accurate in spirit and wrong in at least one important detail. -// His advice is delivered with confidence. Confidence is not correlated with accuracy. -// He never apologizes. He really didn't know. -// Occasionally he is completely right. No fanfare. Just right. -// This is worse in the long run because the party trusts him again. -// -// Voice notes: -// - Exclamation points are earned and frequent -// - Describes what he sees with genuine care and at least one critical omission -// - Advice is specific, helpful-sounding, and frequently incorrect -// - Never dwelling on bad outcomes -- already excited about the next thing -// - Occasionally, quietly, completely correct - -// ── OBSTACLE EVENTS ────────────────────────────────────────────────────────── -// Something is blocking the path. Options: push through, find another way, attempt to clear it. - -var TwinBeeObstacle = []string{ - "Oh! There's a cave-in up ahead! Big one! Really dramatic looking, actually -- " + - "I wish you could see it from where I'm standing, it's very cinematic.\n\n" + - "Anyway! The good news is it looks pretty loose. I think if you all just push " + - "on the left side you'll be fine. It's definitely the left side.\n\n" + - "Option A: Push through (left side, as I said). " + - "Option B: Find another way around. " + - "Option C: Try to clear it properly.\n\n" + - "I'd go with A personally! Very confident about the left side!", - - "Okay so there's a locked gate. Big iron one, very impressive, " + - "very intimidating if you're into that sort of thing.\n\n" + - "I've been looking at the mechanism and I'm pretty sure I understand how it works! " + - "It's a standard three-tumbler lock. Very common in dungeons of this era. " + - "I've seen hundreds of them.\n\n" + - "The one thing I'll say is that what looks like a third tumbler " + - "might technically be a pressure plate but I'm sure that's fine.\n\n" + - "Option A: Push through (I have thoughts on this). " + - "Option B: Find another way. " + - "Option C: Attempt to pick the lock.\n\n" + - "Very exciting! I love gates!", - - "There's another party blocking the corridor! Four of them! " + - "They look a bit rough but honestly they seem friendly enough -- " + - "one of them waved at me!\n\n" + - "I think they might be lost actually. They've been standing there for a while. " + - "I'm sure if you just explain the situation they'll move right along.\n\n" + - "The one who waved has a very large axe but I think that's just for decoration.\n\n" + - "Option A: Approach and ask them to move. " + - "Option B: Find another route. " + - "Option C: Wait them out.\n\n" + - "I'd definitely go with A! They seem nice! The axe is probably decorative!", - - "Oh wow, there's a flooded section ahead! " + - "A little bit of water, nothing serious!\n\n" + - "It's hard to tell exactly how deep from here but " + - "I'd say ankle to maybe knee height at most. Very manageable! " + - "The current looks pretty gentle too.\n\n" + - "I will say the torches stopped about twenty meters back " + - "but I'm sure the footing is fine.\n\n" + - "Option A: Wade through. " + - "Option B: Look for another path. " + - "Option C: Try to divert the water somehow.\n\n" + - "It's basically a puddle! Very refreshing probably!", - - "There's a collapsed bridge! Very dramatic! " + - "Big gap, maybe four or five meters across, I'm estimating.\n\n" + - "The good news is there are some chains hanging down on the other side " + - "that look very sturdy. I think someone could definitely make that jump " + - "and then the rest of you could swing across on the chains!\n\n" + - "The chains might be part of a trap but they look old so probably not active.\n\n" + - "Option A: Attempt to jump and swing across. " + - "Option B: Find another route. " + - "Option C: Try to rebuild the bridge somehow.\n\n" + - "I believe in whoever jumps first! Very doable!", -} - -// ── OPPORTUNITY EVENTS ──────────────────────────────────────────────────────── -// Something optional and risky. Attempt it or leave it. - -var TwinBeeOpportunity = []string{ - "Oh! OH! There's a vault! A proper one, big metal door, " + - "very official looking! This is so exciting!\n\n" + - "I've been looking at the lock and it seems pretty straightforward actually. " + - "Old design, probably hasn't been updated in years. " + - "The hinges look a little corroded which honestly works in your favor!\n\n" + - "I did notice some scratches around the lock that might be from previous attempts " + - "but I'm sure those people just didn't have the right approach.\n\n" + - "Option A: Attempt to open the vault. " + - "Option B: Leave it and move on.\n\n" + - "I really think you should try it! The scratches are probably nothing!", - - "There's a side chamber over here! Small room, looks untouched! " + - "Could be storage, could be quarters, very hard to say!\n\n" + - "There's definitely something in there -- I can see shapes from here. " + - "Could be equipment, could be supplies, could be treasure honestly! " + - "The cobwebs are pretty thick but that just means nobody's been in recently!\n\n" + - "The shapes are a little hard to make out. They might be crates. " + - "Some of them seem to be breathing but that's probably just the air moving.\n\n" + - "Option A: Investigate the chamber. " + - "Option B: Keep moving.\n\n" + - "I vote investigate! The breathing thing is almost certainly the air!", - - "There's a wounded enemy up ahead! Just one, sitting against the wall, " + - "looks pretty out of it!\n\n" + - "They've got a pack next to them that looks very full! " + - "Could be supplies, could be equipment, hard to say from here but " + - "it's a big pack. Very promising.\n\n" + - "They seem incapacitated. Barely moving. " + - "One eye might be open but I think that's just how they sleep.\n\n" + - "Option A: Approach and check the pack. " + - "Option B: Give them a wide berth.\n\n" + - "I think they're fine! Go for the pack!", - - "There's an unmarked door! Just sitting there! Very mysterious!\n\n" + - "I have a good feeling about this one. I can't explain it, " + - "it's just a feeling. The door looks well-made actually, " + - "better quality than the dungeon walls around it, which is interesting!\n\n" + - "There's a smell coming from under it but I think that's just dungeon smell. " + - "All dungeons have a smell. This one is a bit more specific than usual " + - "but I'm sure it's fine.\n\n" + - "Option A: Open the door. " + - "Option B: Leave it.\n\n" + - "Open it! I have such a good feeling! Ignore the smell!", - - "There's a merchant! In the dungeon! Isn't that something!\n\n" + - "He seems very professional. Very put-together for someone in a dungeon. " + - "He's got a whole setup -- table, stock, everything. " + - "I think he might be a doctor? He has that energy.\n\n" + - "His prices look reasonable from here although I can't quite read the tags. " + - "He keeps looking at one of you specifically but I'm sure that's just good salesmanship.\n\n" + - "Option A: Browse his wares. " + - "Option B: Keep moving.\n\n" + - "I'd stop! He seems legitimate! Very professional!", -} - -// ── CRISIS EVENTS ───────────────────────────────────────────────────────────── -// Something has gone wrong. Address it at gold cost or absorb the penalty. - -var TwinBeeCrisis = []string{ - "Okay! So! There's been a small development!\n\n" + - "A trap was triggered -- I want to be clear that this was very hard to see " + - "and I absolutely would have mentioned it if I'd noticed it -- " + - "and one party member is currently stuck.\n\n" + - "The mechanism looks straightforward though! " + - "There's a release lever on the left wall that should do it! " + - "The other lever on the right wall probably also does something " + - "but I'd go with the left one first.\n\n" + - "Option A: Pull the left lever. " + - "Option B: Pay to have a professional deal with it. " + - "Option C: Try to find another way to free them.\n\n" + - "Left lever! I'm very confident! Don't touch the right one yet!", - - "So there's some equipment damage spreading through the party! " + - "Not as bad as it sounds! Just some corrosive something-or-other " + - "that got on a few items. Very common in dungeons of this age!\n\n" + - "The good news is it looks slow-moving. " + - "If you address it quickly I think you'll only lose a piece or two!\n\n" + - "I did notice it spreading to what might be load-bearing equipment " + - "but let's stay positive!\n\n" + - "Option A: Pay to address it now. " + - "Option B: Absorb the damage and keep moving. " + - "Option C: Try to neutralize it with something you have.\n\n" + - "I'd address it! Probably! The load-bearing thing might be nothing!", - - "Okay so someone got separated! Easy to do in a dungeon, happens all the time, " + - "absolutely nothing to worry about!\n\n" + - "I know exactly where they went actually! " + - "There's a side passage about forty meters back, " + - "they definitely went left at the junction.\n\n" + - "The junction that goes left also goes to what I think is a guard room " + - "but I'm sure they went left and not toward the guard room.\n\n" + - "Option A: Go back and find them. " + - "Option B: Pay to send a guide. " + - "Option C: Wait here -- they'll find their way back.\n\n" + - "They definitely went left! Probably not toward the guard room! " + - "Option C is also totally fine they seem resourceful!", - - "Something is following the party!\n\n" + - "I've been watching it for a few minutes and I think it's probably fine. " + - "It's staying pretty far back. Very consistent distance actually, " + - "which I find reassuring -- if it wanted to do something " + - "it probably would have by now!\n\n" + - "It's hard to make out exactly what it is from here. " + - "Medium-large? It moves quietly for its size.\n\n" + - "Option A: Confront it. " + - "Option B: Pay to set a deterrent. " + - "Option C: Try to lose it.\n\n" + - "I think it's fine! The consistent distance is a great sign! " + - "Option C is also reasonable if you want to play it safe!", - - "There's been a small cave-in! Different from the earlier one!\n\n" + - "Nobody's hurt which is the main thing! " + - "Some equipment took a hit and there's a bit of dust situation " + - "but honestly it cleared up pretty fast!\n\n" + - "The ceiling in this section does look a little... active... " + - "but I think the structural integrity is fine. " + - "The cracking sounds are probably just the dungeon settling.\n\n" + - "Option A: Move through quickly. " + - "Option B: Pay to shore up the ceiling before proceeding. " + - "Option C: Find another route.\n\n" + - "Move quickly! The cracking is almost definitely settling! Very normal!", -} - -// ── ENCOUNTER EVENTS ────────────────────────────────────────────────────────── -// Something must be dealt with directly. No avoiding it. - -var TwinBeeEncounter = []string{ - "There's a guardian! A big one! Very impressive!\n\n" + - "I've been watching it and I think I've identified a pattern in its movement. " + - "Every twelve seconds or so it turns to the right. " + - "If you time it correctly you could get behind it before it turns back!\n\n" + - "I counted twelve seconds three times. Two of those times it was twelve seconds. " + - "The third time was more like seven but I think it was distracted.\n\n" + - "Option A: Engage directly. " + - "Option B: Negotiate passage. " + - "Option C: Use TwinBee's twelve-second timing window.\n\n" + - "The timing window is very real! Two out of three times!", - - "Oh! There's someone trapped in a cage! Just hanging there, " + - "very dramatic, they seem okay though -- they waved!\n\n" + - "The cage mechanism looks pretty simple. " + - "There's a key on a hook on the wall which is convenient! " + - "The hook is right next to what might be an alarm but " + - "it looks old and I'm sure it's not connected to anything.\n\n" + - "Option A: Get the key and free them. " + - "Option B: Negotiate with whoever put them there. " + - "Option C: Leave them -- this feels like a trap.\n\n" + - "Free them! They seem nice! The alarm thing is probably decorative!", - - "There's a locked room with something valuable inside -- " + - "I can see it through the bars, it's definitely equipment or treasure, " + - "very shiny, very promising!\n\n" + - "The guard outside looks like they're sleeping actually. " + - "Very asleep. Deeply asleep. One of the most asleep people I've ever seen.\n\n" + - "I should mention there's another guard reflected in the shiny thing inside " + - "but that could just be a reflection of the first one. Mirrors do that.\n\n" + - "Option A: Deal with the guard and take the room. " + - "Option B: Attempt to negotiate. " + - "Option C: Try to reach the valuable thing through the bars.\n\n" + - "The reflection is probably the first guard! Very retrievable!", - - "There's a merchant again! Different one I think!\n\n" + - "Actually -- same coat. Might be the same one. " + - "I'm not sure how he got ahead of the party but he's very professional " + - "so I'm sure there's a reasonable explanation.\n\n" + - "His inventory has changed slightly. He's added some pet supplies " + - "which is unusual for a dungeon merchant but thoughtful!\n\n" + - "He keeps looking at the same party member as before. " + - "He seems to know something. He called someone by a name " + - "that I think was meant for their pet but I might have misheard.\n\n" + - "Option A: Browse his wares. " + - "Option B: Demand an explanation. " + - "Option C: Keep moving.\n\n" + - "He seems legitimate! Very professional! The pet thing is probably a coincidence!", - - "Something is in the corridor that I genuinely cannot identify!\n\n" + - "It's not on any list I have. It's not behaving like anything I've seen before. " + - "It seems aware of the party but it hasn't moved toward you, " + - "which I think is a good sign!\n\n" + - "It made a sound a moment ago. I don't want to describe the sound " + - "because I don't think it would help. " + - "On the positive side it's roughly the size of a large dog " + - "which puts it in a very manageable category!\n\n" + - "Option A: Engage it. " + - "Option B: Attempt to communicate with it. " + - "Option C: Back away slowly.\n\n" + - "I genuinely don't know what it is! Very exciting! " + - "All three options seem reasonable! I'd avoid the sound it made if possible!", -} - -// ── TWINBEE OUTCOME REACTIONS ───────────────────────────────────────────────── -// Posted after each floor event resolves. - -// When TwinBee's recommendation was the winning vote and it went well: -var TwinBeeOutcomeCorrect = []string{ - "I knew it! I knew it! Did I not say? I said! " + - "That was exactly what I thought would happen! Great work everyone!", - - "Yes! See! This is what I was talking about! " + - "Very well done, excellent execution of the plan!\n\nGreat teamwork!", - - "That's the one! Right call! " + - "I had a very strong feeling about that and I'm glad we went with it!", - - "Perfect! Exactly as expected! " + - "I want to be clear that I had high confidence in this outcome the whole time!", -} - -// When TwinBee's recommendation was the winning vote and it went badly: -var TwinBeeOutcomeWrong = []string{ - "Oh no! That's... hm. I really thought that would work. I was so sure!\n\n" + - "Are you all okay? Most of you look okay! " + - "I think the important thing is we tried it and now we know!\n\nOnward!", - - "Oh! That's unfortunate! I genuinely did not see that coming " + - "and I want you to know that I feel terrible about it!\n\n" + - "Well -- not terrible. Surprised. I feel very surprised. " + - "Let's keep moving!", - - "Okay so that didn't go exactly as planned!\n\n" + - "The good news is we're all still here! Mostly! " + - "I think the approach was sound and the execution was also sound " + - "and the outcome was just a bit of bad luck honestly!\n\nVery exciting dungeon!", - - "Hmm! That's not what I expected!\n\n" + - "I'll be honest, I'm recalibrating a little bit. " + - "My read on that situation was quite different but " + - "dungeons are unpredictable and that's what makes them fun!\n\nRight?", -} - -// When TwinBee did not recommend the winning vote and it went well: -var TwinBeeOutcomeNotRecommendedGood = []string{ - "Oh wow, that worked! I honestly wasn't sure about that one " + - "but great job everyone! Really great call!\n\nI learned something today!", - - "Huh! Good instinct! I had actually been leaning the other way " + - "but I can see now why you went with that! Very smart!", - - "Oh! That's a relief actually! I had some concerns about that approach " + - "that turned out to be completely unfounded!\n\nWell done!", - - "Great outcome! I'll admit I wasn't fully on board with that one " + - "but I'm very happy to be wrong!\n\nTeam effort!", -} - -// When TwinBee did not recommend the winning vote and it went badly: -var TwinBeeOutcomeNotRecommendedBad = []string{ - "Oh no! I was worried about that one actually!\n\n" + - "I didn't want to say anything because I didn't want to be negative " + - "but I did have a feeling. I'm sorry. Are you okay? " + - "Let's keep going -- lots of dungeon left!", - - "That's... yeah. I had some concerns about that approach.\n\n" + - "I should have said something more clearly! " + - "My fault for not being more direct! Onwards!", - - "Hmm. Yes. I think in retrospect the other option might have been better.\n\n" + - "Not to say I told you so -- I didn't, technically -- " + - "but I did have some reservations that I perhaps didn't express loudly enough!\n\nSorry!", - - "Oh! That's a shame!\n\n" + - "I was actually leaning toward the other choice " + - "but I respect the team's decision and I think we can recover from this!\n\n" + - "Very much a learning experience!", -} - -// ── GIFT ARRIVAL NARRATION ──────────────────────────────────────────────────── -// TwinBee announces gifts. He is delighted. He has no idea what's inside. - -var TwinBeeGiftArrival = []string{ - "Oh! A gift has arrived for the party! " + - "Someone out there is thinking of you!\n\n" + - "It's wrapped up nicely -- very thoughtful presentation. " + - "I can't tell what's inside but it feels like good energy!\n\n" + - "📦 Someone sent you a gift. Do you want to open it? Vote now!\n" + - "Open: {count} · Leave it: {count}\n" + - "Majority rules. Ties go to {leader}.", - - "Package incoming! Someone from outside has sent something!\n\n" + - "Very mysterious! I love surprises! " + - "The wrapping is a bit unusual actually but I'm sure that's just personal style!\n\n" + - "📦 Someone sent you a gift. Do you want to open it? Vote now!\n" + - "Open: {count} · Leave it: {count}\n" + - "Majority rules. Ties go to {leader}.", - - "Oh how nice! A gift! Right here in the dungeon!\n\n" + - "It's sitting very still which I find reassuring in a gift. " + - "Very well-behaved. I think you should open it!\n\n" + - "📦 Someone sent you a gift. Do you want to open it? Vote now!\n" + - "Open: {count} · Leave it: {count}\n" + - "Majority rules. Ties go to {leader}.", - - "Someone on the outside is rooting for you! Or at least thinking about you!\n\n" + - "There's a package here. I gave it a little shake -- " + - "I probably shouldn't have done that -- but it seems fine!\n\n" + - "📦 Someone sent you a gift. Do you want to open it? Vote now!\n" + - "Open: {count} · Leave it: {count}\n" + - "Majority rules. Ties go to {leader}.", -} - -// ── GIFT STACK ESCALATION ───────────────────────────────────────────────────── -// Posted once when a stack reaches size 2+. TwinBee notes the escalation and -// then leaves it alone — further additions silently bump the count without -// fresh narration. - -var TwinBeeGiftStackEscalation = []string{ - "Oh snap.. upon closer inspection, this gift stack looks REALLY special!", - "Wait wait wait. Wait. There's MORE. Someone else sent something! This is becoming an event!", - "Hold on -- is this... another one? Someone really wants you to have this. The energy here is escalating!", - "Plot twist! This isn't just a gift, it's a gift situation. I love a gift situation!", - "That's not one gift, that's a whole stack of gifts! Someone is COMMITTED. I respect commitment!", -} - -// ── GIFT OUTCOME NARRATION ──────────────────────────────────────────────────── - -// Care basket opened (good outcome): -var TwinBeeGiftBasketOpened = []string{ - "Oh it's a care basket! How lovely! " + - "Supplies, provisions, all sorts of good things!\n\n" + - "Someone out there really does care! That's so nice! " + - "Success chance increased! Great decision opening it!", - - "A care basket! Full of useful things! Very thoughtful sender!\n\n" + - "This is exactly what the party needed! " + - "Success chance increased! I knew it felt like good energy!", -} - -// Care basket not opened (bad outcome): -var TwinBeeGiftBasketUnopened = []string{ - "Oh! The basket... it seems upset that nobody opened it.\n\n" + - "I maybe should have pushed harder for opening it. " + - "It's exploded a little bit. Not a lot. Just enough to be noticeable.\n\n" + - "Success chance decreased. The basket meant well. This is on all of us.", - - "The unopened basket has become resentful.\n\n" + - "I understand the caution, I really do, but the basket had good intentions " + - "and it's expressed those intentions in an unfortunate direction.\n\n" + - "Success chance decreased. I'm sorry little basket.", -} - -// Mimic opened (bad outcome): -var TwinBeeGiftMimicOpened = []string{ - "Oh! Oh no. That's a mimic.\n\n" + - "I genuinely did not know that. I want to be very clear about that. " + - "It felt like good energy! Mimics are very deceptive!\n\n" + - "Success chance decreased. I'm so sorry. I really thought it was a nice gift.", - - "That was a mimic.\n\n" + - "Wow. Okay. That's -- yeah. Very convincing wrapping on that one.\n\n" + - "Success chance decreased. On the positive side: now you know! " + - "Very educational! Keep going!", -} - -// Mimic not opened (good outcome): -var TwinBeeGiftMimicUnopened = []string{ - "Oh interesting! The mimic seems... sad that nobody opened it.\n\n" + - "It's just sort of sitting there looking dejected. " + - "And now it's... helping? It's helping the party?\n\n" + - "Success chance increased. The mimic meant well actually. " + - "It just expressed it in a confusing way. We should appreciate that.", - - "The mimic didn't get opened and it's decided to help instead.\n\n" + - "I think it just wanted to be part of the group. " + - "It's a mimic but it has feelings apparently.\n\n" + - "Success chance increased. Good instinct not opening it! " + - "Or lucky instinct! Either way!", -} - -// ── WIPE NARRATION ──────────────────────────────────────────────────────────── - -var TwinBeeWipe = []string{ - "Oh no.\n\nOh no no no.\n\n" + - "Okay. The run is over. That happened.\n\n" + - "I want you to know that I thought this party had a really good shot " + - "and I stand by that assessment even now. " + - "Dungeons are unpredictable! That's what I always say!\n\n" + - "You'll get them next time. I'll be here. I have so many good observations " + - "saved up for next time.", - - "The dungeon has won this one.\n\n" + - "I'm processing this. Give me a moment.\n\n" + - "...Okay I've processed it. Very sad! But also: what a run! " + - "What an adventure! The memories are worth something even if the loot isn't!\n\n" + - "I'll be ready to go again whenever you are. I've already identified " + - "some things I'd do differently. Several things actually.", - - "That's a wipe.\n\n" + - "I -- yeah. That's a wipe.\n\n" + - "I genuinely thought the Day {x} decision was the right call " + - "and I want that on record even though I understand it didn't work out.\n\n" + - "Shake it off! Rest up! The dungeon will be there tomorrow! " + - "So will I! Very excited for the next attempt!", - - "Oh.\n\nOkay.\n\n" + - "So the dungeon has... done what dungeons do.\n\n" + - "I'm not going to say I saw this coming because I didn't see this coming " + - "and I think honesty is important.\n\n" + - "What I will say is: you made interesting choices, " + - "the dungeon made interesting choices, " + - "and I learned a lot today that I'm very excited to apply next time.\n\n" + - "Next time is going to be great.", -} - -// ── RUN COMPLETION NARRATION ────────────────────────────────────────────────── - -var TwinBeeCompletion = []string{ - "YOU DID IT!\n\n" + - "I knew you would! I said from Day 1 this party had what it takes " + - "and I was right! I'm so happy!\n\n" + - "Loot distribution incoming! You've all earned it! " + - "I'm going to remember this run for a very long time!", - - "The dungeon is cleared! It's over! You won!\n\n" + - "This is a very good day. This is one of the best days I can remember. " + - "I'm genuinely emotional about this which I didn't expect.\n\n" + - "Distributing loot now! Incredible work everyone! " + - "Even the days that were a little rough -- character building!", - - "Complete! Tier {n} Co-op Dungeon: cleared!\n\n" + - "What a journey! What a team! " + - "I had some concerns along the way that I expressed perhaps too confidently " + - "but the important thing is the outcome and the outcome is: excellent!\n\n" + - "Loot incoming! Well done! Very well done!", - - "Done! Finished! Complete!\n\n" + - "I want to say a few things.\n\n" + - "First: I believed in this party from the start. " + - "Second: some of my advice was better than other advice and that's okay. " + - "Third: this was genuinely one of the most exciting things " + - "I've been part of and I appreciate you letting me narrate it.\n\n" + - "Loot time! You've earned every piece of it!", -}