From 07ca5288c387f56931b7fca0ce84d88b0ea2a829 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:28:23 -0700 Subject: [PATCH] Coop: stack same-type gifts on the same day into a single vote post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple baskets (or multiple mimics) sent during the same day now share one game-room post, one vote, one resolution. First-in becomes the stack "lead"; subsequent same-type sends become followers that inherit the lead's deadline and votes. Behavior: - Send a basket → new lead, new post, 6h timer starts - Send another basket within the window → silently joins the stack, edits lead's post to bump count - At stack size 2, TwinBee adds a "this gift looks REALLY special" line (one of 5 variants picked deterministically by lead id). No further escalation as the stack grows - One !coop giftvote on the lead's id covers the whole stack - Resolution applies the same outcome to every gift in the stack; modifier is N × ±6 - End-of-run gift log groups by stack with all senders attributed Schema: coop_dungeon_gifts.stack_lead_id INTEGER (NULL for lead/standalone, otherwise points at lead's id). Migration entry included. Why first-in deadline (vs extending on each follower): exploitability. Saboteurs could spam-extend a stack to delay resolution. Locked deadline keeps senders honest about timing. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +- internal/db/db.go | 4 +- internal/plugin/coop_dungeon.go | 15 +- internal/plugin/coop_dungeon_gifts.go | 411 +++++++++++++++++++------ internal/plugin/coop_dungeon_render.go | 55 +++- internal/plugin/coop_flavor_twinbee.go | 13 + 6 files changed, 385 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 9bf871d..8360554 100644 --- a/README.md +++ b/README.md @@ -627,7 +627,9 @@ Multi-day party runs separate from solo. 2–4 players, 2–7 days depending on **Spectator betting.** Parimutuel pool, 10% rake. Bets can be placed or increased any time during the run; positions are locked in (success or failure can't be switched). Party members can bet on their own run — sandbagging while heavily bet against is exploitative and visible. Odds line shows estimated success% based on party levels, pets, neutral funding assumption, and a hidden TwinBee Helpfulness Rating (rolling last 30 floor events: how often his recommendation was right). Helpfulness shifts the line ±20% but is never shown directly — players notice the line moves after events resolve. -**Gifts.** Anyone *not* in the run can spend one harvest action to send a Care Basket or a Mimic. The party never sees which type. Vote `open` or `leave`. **Each gift has its own 6-hour voting window** — voting closes when the window elapses, the gift is tallied, and the sender gets a DM with the result. The modifier sits on the run until the next floor resolution merges it in. Gifts therefore "fire" throughout the day rather than all at once at the daily tick. Magnitudes are equalized across all four outcomes — both "always open" and "always leave" yield EV=0 with σ=6, so neither is risk-averse-dominant. The choice is purely about reading sender intent. +**Gifts.** Anyone *not* in the run can spend one harvest action to send a Care Basket or a Mimic. The party never sees which type. Vote `open` or `leave`. **Each gift's voting window is 6 hours**; voting closes when it elapses, the stack is tallied, and every sender gets a DM with the result. The modifier sits on the run until the next floor resolution merges it in. Gifts "fire" throughout the day rather than piling up at the daily tick. Magnitudes are equalized across all four outcomes — both "always open" and "always leave" yield EV=0 with σ=6, so neither is risk-averse-dominant. The choice is purely about reading sender intent. + +**Stacking.** Multiple gifts of the same type sent on the same day **stack into a single game-room post** — one vote covers the whole stack, modifier scales with size (5 baskets opened = +30%, 5 baskets exploded = −30%). The first-in sets the deadline; subsequent additions inherit it without extending the timer. When a stack hits size 2, TwinBee notes "this gift looks REALLY special" once and then quietly bumps the count on further additions. The end-of-run gift log groups by stack with all senders attributed. | | Open | Leave | |-------------|------|-------| diff --git a/internal/db/db.go b/internal/db/db.go index a2ed3cf..b55e7a5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -140,6 +140,7 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE coop_dungeon_runs ADD COLUMN invite_post_id TEXT NOT NULL DEFAULT ''`, `ALTER TABLE coop_dungeon_gifts ADD COLUMN expires_at DATETIME`, `ALTER TABLE coop_dungeon_gifts ADD COLUMN applied_at DATETIME`, + `ALTER TABLE coop_dungeon_gifts ADD COLUMN stack_lead_id INTEGER`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1449,7 +1450,8 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_gifts ( sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME, -- voting closes; tally fires when reached resolved_at DATETIME, -- vote tallied (vote_result/modifier set) - applied_at DATETIME -- modifier merged into a floor success roll + applied_at DATETIME, -- modifier merged into a floor success roll + stack_lead_id INTEGER -- NULL for lead/standalone; follower points to lead's id ); CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result); ` diff --git a/internal/plugin/coop_dungeon.go b/internal/plugin/coop_dungeon.go index 841f0e3..45a39f2 100644 --- a/internal/plugin/coop_dungeon.go +++ b/internal/plugin/coop_dungeon.go @@ -402,25 +402,30 @@ func (p *AdventurePlugin) handleCoopAdmGift(ctx MessageContext, args string) err return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — gifts only land during active runs.", runID, run.Status)) } - giftID, err := createCoopGift(runID, sender, run.CurrentDay, giftType) + giftID, leadID, isNewLead, err := createCoopGift(runID, sender, run.CurrentDay, giftType) if err != nil { return p.SendDM(ctx.Sender, "Couldn't create gift.") } gr := gamesRoom() if gr != "" { - gift, _ := loadCoopGift(giftID) members, _ := loadCoopMembers(runID) - if gift != nil { + if isNewLead { + gift, _ := loadCoopGift(giftID) postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members)) if perr == nil { _ = saveCoopGiftPostID(giftID, postID) } + } else { + lead, _ := loadCoopGift(leadID) + if lead != nil && lead.PostEventID != "" { + _ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members)) + } } } - return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Admin gift dispatched: %s from %s to run #%d (day %d). Gift id #%d. No harvest action consumed.", - giftType, sender, runID, run.CurrentDay, giftID)) + return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Admin gift dispatched: %s from %s to run #%d (day %d). Gift id #%d (stack lead %d). No harvest action consumed.", + giftType, sender, runID, run.CurrentDay, giftID, leadID)) } func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error { diff --git a/internal/plugin/coop_dungeon_gifts.go b/internal/plugin/coop_dungeon_gifts.go index 46d90eb..eaf1439 100644 --- a/internal/plugin/coop_dungeon_gifts.go +++ b/internal/plugin/coop_dungeon_gifts.go @@ -50,13 +50,14 @@ type CoopGift struct { SenderID id.UserID Day int GiftType string - Votes map[id.UserID]string // "open" or "leave" + Votes map[id.UserID]string // "open" or "leave" — only set on lead VoteResult string // "opened" / "left" / "" Outcome string // "boost" / "reduction" / "" Modifier int PostEventID id.EventID - ExpiresAt *time.Time // voting closes + ExpiresAt *time.Time // voting closes — only set on lead AppliedAt *time.Time // modifier merged into a floor roll + StackLeadID *int // NULL = lead/standalone; otherwise points at lead's id } // ── Command: send a gift ──────────────────────────────────────────────────── @@ -103,7 +104,7 @@ func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error 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) + giftID, leadID, isNewLead, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType) if err != nil { return p.SendDM(ctx.Sender, "Couldn't send the gift.") } @@ -114,14 +115,21 @@ func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error slog.Error("coop: save sender after gift", "err", err) } - // Post the vote prompt to the game room. + // Post (new lead) or edit (added to existing stack). gr := gamesRoom() if gr != "" { - gift, _ := loadCoopGift(giftID) members, _ := loadCoopMembers(runID) - postID, err := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members)) - if err == nil { - _ = saveCoopGiftPostID(giftID, postID) + if isNewLead { + gift, _ := loadCoopGift(giftID) + postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members)) + if perr == nil { + _ = saveCoopGiftPostID(giftID, postID) + } + } else { + lead, _ := loadCoopGift(leadID) + if lead != nil && lead.PostEventID != "" { + _ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members)) + } } } @@ -161,6 +169,15 @@ func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) er if gift == nil { return p.SendDM(ctx.Sender, "No unresolved gift found.") } + // If the user voted on a follower, redirect to its lead — votes always + // live on the lead. + if gift.StackLeadID != nil { + lead, _ := loadCoopGift(*gift.StackLeadID) + if lead == nil { + return p.SendDM(ctx.Sender, "Couldn't find the gift's stack lead.") + } + gift = lead + } if gift.RunID != run.ID || gift.Day != run.CurrentDay { return p.SendDM(ctx.Sender, "That gift isn't for your current run/day.") } @@ -189,15 +206,30 @@ func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) er // ── Resolution ────────────────────────────────────────────────────────────── -// resolveSingleGift tallies a single gift's votes, persists the outcome, and -// DMs the sender. Used by both the per-minute expiry ticker and the -// floor-resolution catchall (for any gift that somehow missed its expiry). +// resolveSingleGift tallies a stack lead's votes, persists the outcome, DMs +// every sender in the stack, and edits the game-room post to show the +// resolved state. Used by both the per-minute expiry ticker and the +// floor-resolution catchall. // -// Idempotent: returns early if vote_result is already set. +// Followers passed in here will be redirected to their lead. Idempotent: +// always re-loads the lead from DB before checking vote_result, so an +// in-memory snapshot from earlier in the same loop can't trigger a double +// resolve. func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) { + // Redirect follower → lead, then re-fetch fresh state. + leadID := g.ID + if g.StackLeadID != nil { + leadID = *g.StackLeadID + } + fresh, _ := loadCoopGift(leadID) + if fresh == nil { + return + } + g = fresh if g.VoteResult != "" { return } + opens, leaves := 0, 0 var leaderVote string for u, v := range g.Votes { @@ -221,39 +253,49 @@ func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) { case leaderVote == "leave": voteResult = "left" default: - voteResult = "left" // default for full abstention + voteResult = "left" } - var mod int + // Per-gift modifier (single ±6 unit). Stack-wide modifier is N × this, + // applied at floor resolution by summing the rows individually. + var perMod int var outcome string switch g.GiftType { case coopGiftBasket: if voteResult == "opened" { - mod = coopGiftBasketOpened + perMod = coopGiftBasketOpened outcome = "boost" } else { - mod = coopGiftBasketUnopened + perMod = coopGiftBasketUnopened outcome = "reduction" } case coopGiftMimic: if voteResult == "opened" { - mod = coopGiftMimicOpened + perMod = coopGiftMimicOpened outcome = "reduction" } else { - mod = coopGiftMimicUnopened + perMod = coopGiftMimicUnopened outcome = "boost" } } + + stack, _ := loadCoopGiftStack(g.ID) + totalMod := 0 + for _, m := range stack { + _ = resolveCoopGift(m.ID, voteResult, outcome, perMod) + _ = p.SendDM(m.SenderID, renderCoopGiftSenderDM(&m, voteResult, perMod)) + totalMod += perMod + } + g.VoteResult = voteResult g.Outcome = outcome - g.Modifier = mod - _ = resolveCoopGift(g.ID, voteResult, outcome, mod) - _ = p.SendDM(g.SenderID, renderCoopGiftSenderDM(g, voteResult, mod)) + g.Modifier = perMod - // Edit the live game-room post to show the resolved state. + // Edit the live game-room post to show the resolved state — total swing + // reflects the full stack. gr := gamesRoom() if gr != "" && g.PostEventID != "" { - _ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g)) + _ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g, len(stack), totalMod)) } } @@ -290,9 +332,14 @@ func (p *AdventurePlugin) coopProcessGiftExpiries() { // // Returns total modifier and a summary line for the daily-result post. func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) { - // Catchall: any unresolved gifts for this day get force-resolved now. + // Catchall: any unresolved leads for this day get force-resolved now. + // Iterate leads only — resolveSingleGift propagates to followers, so + // touching followers here would just be redundant work. dayGifts, _ := loadDayCoopGifts(run.ID, day) for i := range dayGifts { + if dayGifts[i].StackLeadID != nil { + continue + } if dayGifts[i].VoteResult == "" { p.resolveSingleGift(&dayGifts[i], leader) } @@ -320,62 +367,34 @@ func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day i // ── DB ────────────────────────────────────────────────────────────────────── -func createCoopGift(runID int, sender id.UserID, day int, giftType string) (int, error) { +// findCoopGiftStackLead returns the active lead gift for a (run, day, type) +// stack, or nil if no unresolved lead exists. Lead = oldest unresolved gift +// of that type with stack_lead_id IS NULL. +func findCoopGiftStackLead(runID, day int, giftType string) (*CoopGift, error) { d := db.Get() - expires := time.Now().UTC().Add(coopGiftWindow) - res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type, expires_at) - VALUES (?, ?, ?, ?, ?)`, runID, string(sender), day, giftType, expires) - 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 - var expiresAt, appliedAt sql.NullTime - err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at - FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan( - &g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt) + var leadID int + err := d.QueryRow(`SELECT id FROM coop_dungeon_gifts + WHERE run_id = ? AND day = ? AND gift_type = ? + AND vote_result IS NULL AND stack_lead_id IS NULL + ORDER BY id ASC LIMIT 1`, runID, day, giftType).Scan(&leadID) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - g.SenderID = id.UserID(sender) - g.Votes = parseCoopVotes(votesJSON) - if voteResult.Valid { - g.VoteResult = voteResult.String - } - if outcome.Valid { - g.Outcome = outcome.String - } - if postID.Valid { - g.PostEventID = id.EventID(postID.String) - } - if expiresAt.Valid { - t := expiresAt.Time - g.ExpiresAt = &t - } - if appliedAt.Valid { - t := appliedAt.Time - g.AppliedAt = &t - } - return g, nil + return loadCoopGift(leadID) } -func loadDayCoopGifts(runID, day int) ([]CoopGift, error) { +// loadCoopGiftStack returns the lead and all followers (in id order) for a +// stack identified by lead_id. Used to propagate resolution + render. +func loadCoopGiftStack(leadID int) ([]CoopGift, error) { d := db.Get() rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at - FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day) + votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id + FROM coop_dungeon_gifts + WHERE id = ? OR stack_lead_id = ? + ORDER BY id ASC`, leadID, leadID) if err != nil { return nil, err } @@ -386,8 +405,9 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) { var sender, votesJSON string var voteResult, outcome, postID sql.NullString var expiresAt, appliedAt sql.NullTime + var stackLeadID sql.NullInt64 if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil { + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { return nil, err } g.SenderID = id.UserID(sender) @@ -409,16 +429,99 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) { t := appliedAt.Time g.AppliedAt = &t } + if stackLeadID.Valid { + v := int(stackLeadID.Int64) + g.StackLeadID = &v + } out = append(out, g) } return out, rows.Err() } -func loadAllCoopGifts(runID int) ([]CoopGift, error) { +// createCoopGift inserts a new gift. If an unresolved same-type stack exists +// for the run+day, the new gift becomes a follower (no expires_at, no own +// post — lead's post is edited to bump the count). Otherwise a new lead is +// created with its own 6h expiry. +// +// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective +// stack lead — either itself (new lead) or the existing lead it joined. +func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) { + d := db.Get() + + existing, lerr := findCoopGiftStackLead(runID, day, giftType) + if lerr != nil { + return 0, 0, false, lerr + } + if existing != nil { + // Follower: no expires_at, no post. Inherits lead's deadline. + res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts + (run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`, + runID, string(sender), day, giftType, existing.ID) + if ierr != nil { + return 0, 0, false, ierr + } + id64, _ := res.LastInsertId() + return int(id64), existing.ID, false, nil + } + + // New lead: own expiry, will get its own post. + expires := time.Now().UTC().Add(coopGiftWindow) + res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts + (run_id, sender_id, day, gift_type, expires_at) VALUES (?, ?, ?, ?, ?)`, + runID, string(sender), day, giftType, expires) + if ierr != nil { + return 0, 0, false, ierr + } + id64, _ := res.LastInsertId() + return int(id64), int(id64), true, nil +} + +func loadCoopGift(giftID int) (*CoopGift, error) { + d := db.Get() + g := &CoopGift{} + var sender, votesJSON string + var voteResult, outcome, postID sql.NullString + var expiresAt, appliedAt sql.NullTime + var stackLeadID sql.NullInt64 + err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type, + votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id + FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan( + &g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + g.SenderID = id.UserID(sender) + g.Votes = parseCoopVotes(votesJSON) + if voteResult.Valid { + g.VoteResult = voteResult.String + } + if outcome.Valid { + g.Outcome = outcome.String + } + if postID.Valid { + g.PostEventID = id.EventID(postID.String) + } + if expiresAt.Valid { + t := expiresAt.Time + g.ExpiresAt = &t + } + if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v } + if appliedAt.Valid { + t := appliedAt.Time + g.AppliedAt = &t + } + return g, nil +} + +func loadDayCoopGifts(runID, day int) ([]CoopGift, error) { d := db.Get() rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at - FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID) + votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id + FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day) if err != nil { return nil, err } @@ -429,8 +532,9 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) { var sender, votesJSON string var voteResult, outcome, postID sql.NullString var expiresAt, appliedAt sql.NullTime + var stackLeadID sql.NullInt64 if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil { + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { return nil, err } g.SenderID = id.UserID(sender) @@ -448,6 +552,52 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) { t := expiresAt.Time g.ExpiresAt = &t } + if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v } + if appliedAt.Valid { + t := appliedAt.Time + g.AppliedAt = &t + } + out = append(out, g) + } + return out, rows.Err() +} + +func loadAllCoopGifts(runID int) ([]CoopGift, error) { + d := db.Get() + rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, + votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id + FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CoopGift + for rows.Next() { + g := CoopGift{} + var sender, votesJSON string + var voteResult, outcome, postID sql.NullString + var expiresAt, appliedAt sql.NullTime + var stackLeadID sql.NullInt64 + if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { + return nil, err + } + g.SenderID = id.UserID(sender) + g.Votes = parseCoopVotes(votesJSON) + if voteResult.Valid { + g.VoteResult = voteResult.String + } + if outcome.Valid { + g.Outcome = outcome.String + } + if postID.Valid { + g.PostEventID = id.EventID(postID.String) + } + if expiresAt.Valid { + t := expiresAt.Time + g.ExpiresAt = &t + } + if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v } if appliedAt.Valid { t := appliedAt.Time g.AppliedAt = &t @@ -503,9 +653,10 @@ func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error func loadExpiredUnresolvedGifts() ([]CoopGift, error) { d := db.Get() rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at + votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id FROM coop_dungeon_gifts WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP + AND stack_lead_id IS NULL ORDER BY id`) if err != nil { return nil, err @@ -517,8 +668,9 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) { var sender, votesJSON string var voteResult, outcome, postID sql.NullString var expiresAt, appliedAt sql.NullTime + var stackLeadID sql.NullInt64 if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil { + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { return nil, err } g.SenderID = id.UserID(sender) @@ -530,6 +682,10 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) { t := expiresAt.Time g.ExpiresAt = &t } + if stackLeadID.Valid { + v := int(stackLeadID.Int64) + g.StackLeadID = &v + } out = append(out, g) } return out, rows.Err() @@ -541,7 +697,7 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) { func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) { d := db.Get() rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type, - votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at + votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id FROM coop_dungeon_gifts WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL ORDER BY id`, runID) @@ -555,8 +711,9 @@ func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) { var sender, votesJSON string var voteResult, outcome, postID sql.NullString var expiresAt, appliedAt sql.NullTime + var stackLeadID sql.NullInt64 if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType, - &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil { + &votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil { return nil, err } g.SenderID = id.UserID(sender) @@ -567,6 +724,10 @@ func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) { if outcome.Valid { g.Outcome = outcome.String } + if stackLeadID.Valid { + v := int(stackLeadID.Int64) + g.StackLeadID = &v + } out = append(out, g) } return out, rows.Err() @@ -588,12 +749,20 @@ func markCoopGiftApplied(giftID int) (bool, error) { // ── Render ────────────────────────────────────────────────────────────────── +// renderCoopGiftPost is called only for stack leads. Stack count is derived +// from how many followers point at this lead. Flavor is picked deterministically +// by lead id so re-renders don't rotate. func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, members []CoopMember) string { var sb strings.Builder - sb.WriteString(fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**\n\n", gift.ID, run.ID, gift.Day)) - // Tally before substitution so the flavor's "Open: {count} · Leave it: {count}" - // renders with real numbers. + // Stack size = lead + followers + stack, _ := loadCoopGiftStack(gift.ID) + stackSize := len(stack) + if stackSize == 0 { + stackSize = 1 + } + + // Tally on the lead's votes opens, leaves := 0, 0 for _, v := range gift.Votes { if v == "open" { @@ -604,10 +773,16 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member } leaderName := coopDisplayName(p, run.LeaderID) - // Pick a flavor entry and substitute its template fields. Two {count} - // placeholders are positional: first = opens, second = leaves. + header := fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**", gift.ID, run.ID, gift.Day) + if stackSize > 1 { + header = fmt.Sprintf("📦 **Gift Stack #%d (×%d) — Co-op #%d, Day %d**", gift.ID, stackSize, run.ID, gift.Day) + } + sb.WriteString(header) + sb.WriteString("\n\n") + + // Pick flavor deterministically so edits don't rotate the joke. if len(TwinBeeGiftArrival) > 0 { - flavor := TwinBeeGiftArrival[rand.IntN(len(TwinBeeGiftArrival))] + flavor := TwinBeeGiftArrival[gift.ID%len(TwinBeeGiftArrival)] flavor = strings.Replace(flavor, "{count}", strconv.Itoa(opens), 1) flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1) flavor = strings.ReplaceAll(flavor, "{leader}", leaderName) @@ -615,7 +790,14 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member sb.WriteString("\n\n") } - // Actionable line — the flavor doesn't include the actual command. + // "REALLY special" line, picked once per stack lead, shown for size 2+. + if stackSize >= 2 && len(TwinBeeGiftStackEscalation) > 0 { + line := TwinBeeGiftStackEscalation[gift.ID%len(TwinBeeGiftStackEscalation)] + sb.WriteString("_") + sb.WriteString(line) + sb.WriteString("_\n\n") + } + closesIn := "" if gift.ExpiresAt != nil { remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute) @@ -625,6 +807,9 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member closesIn = " · voting closing" } } + if stackSize > 1 { + sb.WriteString(fmt.Sprintf("One vote covers the whole stack of %d. ", stackSize)) + } sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".") awaiting := 0 @@ -640,16 +825,18 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member } // renderCoopGiftResolvedPost replaces the live vote post when a gift's -// voting window closes. Reveals the type and resolved modifier — the type -// stays hidden during voting but is visible after. -func renderCoopGiftResolvedPost(g *CoopGift) string { - icon := "📦" +// voting window closes. Total modifier = stackSize × per-gift mod. +func renderCoopGiftResolvedPost(g *CoopGift, stackSize, totalMod int) string { verb := "left" if g.VoteResult == "opened" { verb = "opened" } - return fmt.Sprintf("%s **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.", - icon, g.ID, titleCaseWord(g.GiftType), verb, g.Modifier) + if stackSize > 1 { + return fmt.Sprintf("📦 **Gift Stack #%d (×%d) resolved** — %s, %s → **%+d%%** to next floor.", + g.ID, stackSize, titleCaseWord(g.GiftType), verb, totalMod) + } + return fmt.Sprintf("📦 **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.", + g.ID, titleCaseWord(g.GiftType), verb, totalMod) } func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string { @@ -672,21 +859,55 @@ func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string { return "📦 Your gift resolved." } -// renderCoopGiftLog returns the public end-of-run gift log. +// renderCoopGiftLog returns the public end-of-run gift log, grouped by stack. +// One line per stack with all senders listed and the total swing. func renderCoopGiftLog(p *AdventurePlugin, runID int) string { gifts, err := loadAllCoopGifts(runID) if err != nil || len(gifts) == 0 { return "" } + // Group gifts by their effective lead id. + type groupKey struct { + day int + giftType string + leadID int + } + groups := map[groupKey][]CoopGift{} + order := []groupKey{} + for _, g := range gifts { + leadID := g.ID + if g.StackLeadID != nil { + leadID = *g.StackLeadID + } + k := groupKey{day: g.Day, giftType: g.GiftType, leadID: leadID} + if _, seen := groups[k]; !seen { + order = append(order, k) + } + groups[k] = append(groups[k], g) + } + var sb strings.Builder sb.WriteString("📦 Gift Log:\n") - for _, g := range gifts { - result := g.VoteResult + for _, k := range order { + stack := groups[k] + // Senders for the line — ordered by id for stable display. + names := make([]string, 0, len(stack)) + totalMod := 0 + voteResult := stack[0].VoteResult + for _, g := range stack { + names = append(names, coopDisplayName(p, g.SenderID)) + totalMod += g.Modifier + } + result := voteResult if result == "" { result = "unresolved" } + typeLabel := titleCaseWord(k.giftType) + if len(stack) > 1 { + typeLabel = fmt.Sprintf("%s ×%d", typeLabel, len(stack)) + } sb.WriteString(fmt.Sprintf(" Day %d: %s → %s → %s → %+d%%\n", - g.Day, coopDisplayName(p, g.SenderID), titleCaseWord(g.GiftType), result, g.Modifier)) + k.day, strings.Join(names, " + "), typeLabel, result, totalMod)) } return sb.String() } diff --git a/internal/plugin/coop_dungeon_render.go b/internal/plugin/coop_dungeon_render.go index 1fb50d4..6ec96be 100644 --- a/internal/plugin/coop_dungeon_render.go +++ b/internal/plugin/coop_dungeon_render.go @@ -217,22 +217,43 @@ func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) st sb.WriteString(renderCoopOddsLine(run, members, bets)) } - // Pending gift list with per-gift countdowns. Voting windows expire - // independently throughout the day, so this is the canonical place to - // see what's still open. + // Pending stacks with per-stack countdowns. Each game-room post is one + // stack (lead + N followers); we show the lead's id and the stack size. if run.Status == "active" { all, _ := loadAllCoopGifts(run.ID) - var pending []CoopGift - for _, g := range all { - if g.VoteResult == "" { - pending = append(pending, g) - } + // Group unresolved gifts by lead. Followers contribute to the size + // but use the lead's expiry/votes. + type stackInfo struct { + lead CoopGift + size int } - if len(pending) > 0 { + stacks := map[int]*stackInfo{} + order := []int{} + for _, g := range all { + if g.VoteResult != "" { + continue + } + leadID := g.ID + if g.StackLeadID != nil { + leadID = *g.StackLeadID + } + s, ok := stacks[leadID] + if !ok { + s = &stackInfo{} + stacks[leadID] = s + order = append(order, leadID) + } + if g.StackLeadID == nil { + s.lead = g + } + s.size++ + } + if len(stacks) > 0 { sb.WriteString("\n\nPending gifts:\n") - for _, g := range pending { + for _, leadID := range order { + s := stacks[leadID] opens, leaves := 0, 0 - for _, v := range g.Votes { + for _, v := range s.lead.Votes { if v == "open" { opens++ } else if v == "leave" { @@ -240,16 +261,20 @@ func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) st } } countdown := "—" - if g.ExpiresAt != nil { - remaining := time.Until(*g.ExpiresAt).Truncate(time.Minute) + if s.lead.ExpiresAt != nil { + remaining := time.Until(*s.lead.ExpiresAt).Truncate(time.Minute) if remaining > 0 { countdown = remaining.String() } else { countdown = "closing" } } - sb.WriteString(fmt.Sprintf(" #%d (day %d) — open %d / leave %d — closes in %s\n", - g.ID, g.Day, opens, leaves, countdown)) + stackLabel := fmt.Sprintf("#%d", s.lead.ID) + if s.size > 1 { + stackLabel = fmt.Sprintf("#%d ×%d", s.lead.ID, s.size) + } + sb.WriteString(fmt.Sprintf(" %s (day %d) — open %d / leave %d — closes in %s\n", + stackLabel, s.lead.Day, opens, leaves, countdown)) } } } diff --git a/internal/plugin/coop_flavor_twinbee.go b/internal/plugin/coop_flavor_twinbee.go index b65d14b..60286fb 100644 --- a/internal/plugin/coop_flavor_twinbee.go +++ b/internal/plugin/coop_flavor_twinbee.go @@ -389,6 +389,19 @@ var TwinBeeGiftArrival = []string{ "Majority rules. Ties go to {leader}.", } +// ── GIFT STACK ESCALATION ───────────────────────────────────────────────────── +// Posted once when a stack reaches size 2+. TwinBee notes the escalation and +// then leaves it alone — further additions silently bump the count without +// fresh narration. + +var TwinBeeGiftStackEscalation = []string{ + "Oh snap.. upon closer inspection, this gift stack looks REALLY special!", + "Wait wait wait. Wait. There's MORE. Someone else sent something! This is becoming an event!", + "Hold on -- is this... another one? Someone really wants you to have this. The energy here is escalating!", + "Plot twist! This isn't just a gift, it's a gift situation. I love a gift situation!", + "That's not one gift, that's a whole stack of gifts! Someone is COMMITTED. I respect commitment!", +} + // ── GIFT OUTCOME NARRATION ──────────────────────────────────────────────────── // Care basket opened (good outcome):