Coop: stack same-type gifts on the same day into a single vote post

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-28 22:28:23 -07:00
parent 48e5000745
commit 07ca5288c3
6 changed files with 385 additions and 117 deletions

View File

@@ -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))
}
}
}