diff --git a/internal/db/db.go b/internal/db/db.go index a183e42..0bcc8cf 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1373,6 +1373,75 @@ CREATE TABLE IF NOT EXISTS holdem_tip_audit ( CREATE INDEX IF NOT EXISTS idx_tip_audit_user ON holdem_tip_audit(user_id); CREATE INDEX IF NOT EXISTS idx_tip_audit_action ON holdem_tip_audit(action, street); +-- Co-op Dungeon (party multi-day runs) +CREATE TABLE IF NOT EXISTS coop_dungeon_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tier INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled + leader_id TEXT NOT NULL, + current_day INTEGER NOT NULL DEFAULT 0, + total_days INTEGER NOT NULL, + base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier + gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding + reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + locked_at DATETIME, + completed_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_coop_runs_status ON coop_dungeon_runs(status); + +CREATE TABLE IF NOT EXISTS coop_dungeon_members ( + run_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + turn_order INTEGER NOT NULL, + total_contributed INTEGER NOT NULL DEFAULT 0, + is_liability INTEGER NOT NULL DEFAULT 0, + daily_funding TEXT NOT NULL DEFAULT '{}', -- JSON: {"1":"standard","2":"all_in",...} + PRIMARY KEY (run_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_coop_members_user ON coop_dungeon_members(user_id); + +CREATE TABLE IF NOT EXISTS coop_dungeon_events ( + run_id INTEGER NOT NULL, + day INTEGER NOT NULL, + category TEXT NOT NULL, -- obstacle, opportunity, crisis, encounter + event_index INTEGER NOT NULL, -- index into the category flavor pool + recommended TEXT NOT NULL, -- A, B, or C — TwinBee's pick + votes TEXT NOT NULL DEFAULT '{}', -- JSON {"@user:host": "A"} + winning_vote TEXT DEFAULT NULL, + outcome TEXT DEFAULT NULL, -- 'success' or 'failure' (after resolution) + modifier_applied INTEGER DEFAULT 0, + post_event_id TEXT DEFAULT NULL, -- Matrix event ID for live edit + PRIMARY KEY (run_id, day) +); +CREATE INDEX IF NOT EXISTS idx_coop_events_outcome ON coop_dungeon_events(outcome); + +CREATE TABLE IF NOT EXISTS coop_dungeon_bets ( + run_id INTEGER NOT NULL, + player_id TEXT NOT NULL, + position TEXT NOT NULL, -- 'success' or 'failure' + amount INTEGER NOT NULL, + placed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + payout INTEGER, -- NULL until run resolves + PRIMARY KEY (run_id, player_id) +); +CREATE INDEX IF NOT EXISTS idx_coop_bets_run ON coop_dungeon_bets(run_id); + +CREATE TABLE IF NOT EXISTS coop_dungeon_gifts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + sender_id TEXT NOT NULL, + day INTEGER NOT NULL, + gift_type TEXT NOT NULL, -- 'basket' or 'mimic' + votes TEXT NOT NULL DEFAULT '{}', -- JSON {"@user:host": "open"|"leave"} + vote_result TEXT, -- 'opened' or 'left' + outcome TEXT, -- 'boost' or 'reduction' + modifier INTEGER NOT NULL DEFAULT 0, + post_event_id TEXT, + sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result); ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 6b9f6fc..bff3278 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -141,6 +141,7 @@ func (p *AdventurePlugin) Init() error { go p.robbieTicker() go p.hospitalNudgeTicker() go p.mortgageTicker() + go p.coopTicker() // Auto-cashout any arena runs left in 'awaiting' from a prior restart p.arenaCleanupStaleRuns() @@ -185,6 +186,11 @@ 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] @@ -281,6 +287,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs) ` + "`!adventure repair all`" + ` — Repair all damaged equipment ` + "`!adventure repair `" + ` — Repair a specific slot +` + "`!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/coop_dungeon.go b/internal/plugin/coop_dungeon.go new file mode 100644 index 0000000..a46a14c --- /dev/null +++ b/internal/plugin/coop_dungeon.go @@ -0,0 +1,462 @@ +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 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:])) + } + + 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 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 != "" { + _ = p.SendMessage(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName)) + } + 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 != "" { + _ = 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)) +} + +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 new file mode 100644 index 0000000..e91c1b5 --- /dev/null +++ b/internal/plugin/coop_dungeon_balance_test.go @@ -0,0 +1,265 @@ +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 new file mode 100644 index 0000000..86b76c9 --- /dev/null +++ b/internal/plugin/coop_dungeon_betting.go @@ -0,0 +1,323 @@ +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 +} + +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) + + for _, b := range winners { + payout := payouts[b.PlayerID] + if payout > 0 { + p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout") + } + _ = setCoopBetPayout(run.ID, b.PlayerID, payout) + } + for _, b := range bets { + if b.Position != winningPosition { + _ = 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 new file mode 100644 index 0000000..4ecbb27 --- /dev/null +++ b/internal/plugin/coop_dungeon_db.go @@ -0,0 +1,407 @@ +package plugin + +import ( + "database/sql" + "encoding/json" + "fmt" + "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 + 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, 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.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, 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.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 +} + +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 +} + +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 { + 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() + _, err := d.Exec(`INSERT 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 { + 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 new file mode 100644 index 0000000..9a37fe7 --- /dev/null +++ b/internal/plugin/coop_dungeon_gifts.go @@ -0,0 +1,476 @@ +package plugin + +import ( + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "math/rand/v2" + "strconv" + "strings" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// ── Gift Constants ────────────────────────────────────────────────────────── +// +// Modifiers chosen so the EV of "always open" vs "always leave" is symmetric +// at a 50/50 sender mix — the party has to read the context, not the math. +// +// basket opened: +7 basket unopened: -4 +// mimic opened: -7 mimic unopened: +4 +// +// At 50/50 sender mix: +// always open EV = 0.5×(+7) + 0.5×(−7) = 0 +// always leave EV = 0.5×(−4) + 0.5×(+4) = 0 + +const ( + coopGiftBasketOpened = 7 + coopGiftBasketUnopened = -4 + coopGiftMimicOpened = -7 + coopGiftMimicUnopened = 4 +) + +const ( + coopGiftBasket = "basket" + coopGiftMimic = "mimic" +) + +type CoopGift struct { + ID int + RunID int + SenderID id.UserID + Day int + GiftType string + Votes map[id.UserID]string // "open" or "leave" + VoteResult string // "opened" / "left" / "" + Outcome string // "boost" / "reduction" / "" + Modifier int + PostEventID id.EventID +} + +// ── 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, 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 the vote prompt to the game room. + gr := gamesRoom() + if gr != "" { + gift, _ := loadCoopGift(giftID) + members, _ := loadCoopMembers(runID) + postID, err := p.SendMessageID(gr, renderCoopGiftPost(run, gift, members)) + if err == nil { + _ = saveCoopGiftPostID(giftID, postID) + } + } + + 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 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(run, gift, members)) + } + return p.SendDM(ctx.Sender, fmt.Sprintf("Gift #%d vote recorded: %s.", gift.ID, choice)) +} + +// ── Resolution ────────────────────────────────────────────────────────────── + +// coopResolveGifts is called inside coopResolveFloor to tally and apply each +// unresolved gift for the current day. Returns the total modifier and a brief +// summary line. +func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) { + gifts, err := loadDayCoopGifts(run.ID, day) + if err != nil || len(gifts) == 0 { + return 0, "" + } + totalMod := 0 + var lines []string + for _, g := range gifts { + if g.VoteResult != "" { + continue + } + 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" // default for full abstention + } + + var mod int + var outcome string + switch g.GiftType { + case coopGiftBasket: + if voteResult == "opened" { + mod = coopGiftBasketOpened + outcome = "boost" + } else { + mod = coopGiftBasketUnopened + outcome = "reduction" + } + case coopGiftMimic: + if voteResult == "opened" { + mod = coopGiftMimicOpened + outcome = "reduction" + } else { + mod = coopGiftMimicUnopened + outcome = "boost" + } + } + totalMod += mod + _ = resolveCoopGift(g.ID, voteResult, outcome, mod) + lines = append(lines, fmt.Sprintf(" %s gift from %s — %s → %+d%%", + titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), voteResult, mod)) + _ = p.SendDM(g.SenderID, renderCoopGiftSenderDM(&g, voteResult, mod)) + } + if len(lines) == 0 { + return 0, "" + } + return totalMod, "Gifts:\n" + strings.Join(lines, "\n") +} + +// ── DB ────────────────────────────────────────────────────────────────────── + +func createCoopGift(runID int, sender id.UserID, day int, giftType string) (int, error) { + d := db.Get() + res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type) + VALUES (?, ?, ?, ?)`, runID, string(sender), day, giftType) + if err != nil { + return 0, err + } + id64, _ := res.LastInsertId() + return int(id64), nil +} + +func loadCoopGift(giftID int) (*CoopGift, error) { + d := db.Get() + g := &CoopGift{} + var sender, votesJSON string + var voteResult, outcome, postID sql.NullString + err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type, + votes, vote_result, outcome, modifier, post_event_id + FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan( + &g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID) + 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) + } + 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 + 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 + if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID); 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) + } + 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 + 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 + if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID); 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 + } + 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 +} + +// ── Render ────────────────────────────────────────────────────────────────── + +func renderCoopGiftPost(run *CoopRun, gift *CoopGift, members []CoopMember) string { + var sb strings.Builder + flavor := "" + if len(TwinBeeGiftArrival) > 0 { + flavor = TwinBeeGiftArrival[rand.IntN(len(TwinBeeGiftArrival))] + } + sb.WriteString(fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**\n\n", gift.ID, run.ID, gift.Day)) + if flavor != "" { + sb.WriteString(flavor) + sb.WriteString("\n\n") + } + sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`. Resolved at next daily tick.\n\n") + opens, leaves := 0, 0 + for _, v := range gift.Votes { + if v == "open" { + opens++ + } else if v == "leave" { + leaves++ + } + } + sb.WriteString(fmt.Sprintf("Current votes: open (%d) · leave (%d)", opens, leaves)) + 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() +} + +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. +func renderCoopGiftLog(p *AdventurePlugin, runID int) string { + gifts, err := loadAllCoopGifts(runID) + if err != nil || len(gifts) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("📦 Gift Log:\n") + for _, g := range gifts { + result := g.VoteResult + if result == "" { + result = "unresolved" + } + sb.WriteString(fmt.Sprintf(" Day %d: %s → %s → %s → %+d%%\n", + g.Day, coopDisplayName(p, g.SenderID), titleCaseWord(g.GiftType), result, g.Modifier)) + } + return sb.String() +} diff --git a/internal/plugin/coop_dungeon_render.go b/internal/plugin/coop_dungeon_render.go new file mode 100644 index 0000000..9bcd42d --- /dev/null +++ b/internal/plugin/coop_dungeon_render.go @@ -0,0 +1,220 @@ +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)) + } + return sb.String() +} diff --git a/internal/plugin/coop_dungeon_scheduler.go b/internal/plugin/coop_dungeon_scheduler.go new file mode 100644 index 0000000..d82ffe9 --- /dev/null +++ b/internal/plugin/coop_dungeon_scheduler.go @@ -0,0 +1,483 @@ +package plugin + +import ( + "fmt" + "log/slog" + "math/rand/v2" + "strings" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// coopTicker fires once per UTC day to: +// 1. Lock open invites whose 24h window has elapsed. +// 2. For each active run, resolve the day's floor and advance/complete. +// +// Reuses the daily_prefetch job-completion guard so a bot restart on the same +// UTC day does not double-process. +func (p *AdventurePlugin) coopTicker() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + now := time.Now().UTC() + // Run at the same minute as the morning DM (08:00 UTC) — same cadence as + // the rest of the adventure system. + 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") + p.coopProcessLocks() + 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 != "" { + _ = 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. Combat action refunded.", 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 != "" { + _ = 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 + } + today := time.Now().UTC().Format("2006-01-02") + for _, run := range runs { + // Skip runs locked on this same tick — they need a full day for + // funding decisions and voting before the first floor resolves. + if run.LockedAt != nil && run.LockedAt.UTC().Format("2006-01-02") == today { + 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 + members, err := loadCoopMembers(run.ID) + if err != nil { + return err + } + + // Auto-play any member who didn't fund. + autoPlayed := []*CoopMember{} + 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) + } + } + + // Compute success modifier: base = (100 - baseDifficulty), adjusted by + // funding, party levels, pets, and the floor event vote. + totalMod := 0 + choices := make(map[string]CoopFundingTier, len(members)) + tierDef := coopTierTable[run.Tier] + for _, m := range members { + tier, mod := coopMemberModifier(&m, day) + choices[string(m.UserID)] = tier + totalMod += mod + // Level + pet bonuses pulled fresh per-floor; they reflect current + // state (a player may have leveled up mid-run). + char, err := loadAdvCharacter(m.UserID) + if err != nil { + continue + } + totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel) + totalMod += coopPetBonus(char) + } + + // Tally vote on today's floor event. Ties go to the leader's vote (or + // option A if leader didn't vote either). + event, _ := loadCoopEvent(run.ID, day) + winning := "" + eventMod := 0 + if event != nil { + winning = coopTallyVote(event, run.LeaderID) + eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning) + totalMod += eventMod + } + + // Resolve gifts received on this day. + 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 the event resolution before posting (so a crash mid-post still + // records what happened for TwinBee helpfulness tracking). + if event != nil { + outcomeStr := "failure" + if floorWon { + outcomeStr = "success" + } + _ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod) + } + + gr := gamesRoom() + dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod) + if giftSummary != "" { + dailyPost += "\n\n" + giftSummary + } + if gr != "" { + _ = p.SendMessage(gr, dailyPost) + } + + if !floorWon { + // Wipe. Combat actions are only consumed at lock (day 1), so only a + // day-1 wipe has anything to refund. + if day == 1 { + now := time.Now().UTC().Format("2006-01-02") + for _, m := range members { + char, err := loadAdvCharacter(m.UserID) + if err != nil { + continue + } + if char.LastActionDate == now && char.CombatActionsUsed > 0 { + char.CombatActionsUsed-- + _ = saveAdvCharacter(char) + } + } + } + _ = completeCoopRun(run.ID, "wiped", 0) + // Resolve betting pool — failure side wins on a wipe. + freshRun, _ := loadCoopRun(run.ID) + if freshRun != nil { + summary := p.coopResolveBets(freshRun, false) + giftLog := renderCoopGiftLog(p, run.ID) + parts := []string{} + if summary != "" { + parts = append(parts, summary) + } + if giftLog != "" { + parts = append(parts, giftLog) + } + if len(parts) > 0 { + if gr := gamesRoom(); gr != "" { + _ = p.SendMessage(gr, strings.Join(parts, "\n\n")) + } + } + } + return nil + } + + // Floor cleared — advance or complete. + if day >= run.TotalDays { + // Run complete — split reward. + def := coopTierTable[run.Tier] + reward := def.rewardBase + _ = completeCoopRun(run.ID, "complete", reward) + freshRun, _ := loadCoopRun(run.ID) + if freshRun != nil { + p.coopDistributeReward(freshRun, reward, members) + } + 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)) + } + // Generate next day's floor event. + freshRun, _ := loadCoopRun(run.ID) + if freshRun != nil { + p.coopGenerateAndPostEvent(freshRun, members, day+1) + } + return nil +} + +// coopTallyVote counts votes; majority wins. Ties resolve to the leader's +// vote if any, otherwise option A. +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] + } + 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 { + 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_test.go b/internal/plugin/coop_dungeon_test.go new file mode 100644 index 0000000..19a6669 --- /dev/null +++ b/internal/plugin/coop_dungeon_test.go @@ -0,0 +1,332 @@ +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 no leader vote falls back deterministically", map[id.UserID]string{other: "A", third: "B"}, ""}, // "A" or "B" — accept either + } + for _, tc := range tests { + event := &CoopEvent{Votes: tc.votes} + got := coopTallyVote(event, leader) + if tc.want == "" { + if got != "A" && got != "B" { + t.Errorf("%s: got %q, want one of A/B", tc.name, got) + } + continue + } + if got != tc.want { + t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) + } + } +} + +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 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 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 new file mode 100644 index 0000000..142efbb --- /dev/null +++ b/internal/plugin/coop_event_meta.go @@ -0,0 +1,203 @@ +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 new file mode 100644 index 0000000..b65d14b --- /dev/null +++ b/internal/plugin/coop_flavor_twinbee.go @@ -0,0 +1,510 @@ +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 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!", +}