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

@@ -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 {

View File

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

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

View File

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