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

@@ -627,7 +627,9 @@ Multi-day party runs separate from solo. 24 players, 27 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 |
|-------------|------|-------|

View File

@@ -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);
`

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,15 +115,22 @@ 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 {
if isNewLead {
gift, _ := loadCoopGift(giftID)
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
if perr == nil {
_ = saveCoopGiftPostID(giftID, postID)
}
} else {
lead, _ := loadCoopGift(leadID)
if lead != nil && lead.PostEventID != "" {
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
}
}
}
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Gift dispatched (run #%d, day %d). One harvest action consumed. The party doesn't know what you sent.", runID, run.CurrentDay))
@@ -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,16 +367,113 @@ 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)
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 0, err
return nil, err
}
return loadCoopGift(leadID)
}
// loadCoopGiftStack returns the lead and all followers (in id order) for a
// stack identified by lead_id. Used to propagate resolution + render.
func loadCoopGiftStack(leadID int) ([]CoopGift, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
FROM coop_dungeon_gifts
WHERE id = ? OR stack_lead_id = ?
ORDER BY id ASC`, leadID, leadID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopGift
for rows.Next() {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
var stackLeadID sql.NullInt64
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
g.Votes = parseCoopVotes(votesJSON)
if voteResult.Valid {
g.VoteResult = voteResult.String
}
if outcome.Valid {
g.Outcome = outcome.String
}
if postID.Valid {
g.PostEventID = id.EventID(postID.String)
}
if expiresAt.Valid {
t := expiresAt.Time
g.ExpiresAt = &t
}
if appliedAt.Valid {
t := appliedAt.Time
g.AppliedAt = &t
}
if stackLeadID.Valid {
v := int(stackLeadID.Int64)
g.StackLeadID = &v
}
out = append(out, g)
}
return out, rows.Err()
}
// createCoopGift inserts a new gift. If an unresolved same-type stack exists
// for the run+day, the new gift becomes a follower (no expires_at, no own
// post — lead's post is edited to bump the count). Otherwise a new lead is
// created with its own 6h expiry.
//
// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective
// stack lead — either itself (new lead) or the existing lead it joined.
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) {
d := db.Get()
existing, lerr := findCoopGiftStackLead(runID, day, giftType)
if lerr != nil {
return 0, 0, false, lerr
}
if existing != nil {
// Follower: no expires_at, no post. Inherits lead's deadline.
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
(run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`,
runID, string(sender), day, giftType, existing.ID)
if ierr != nil {
return 0, 0, false, ierr
}
id64, _ := res.LastInsertId()
return int(id64), nil
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) {
@@ -338,11 +482,12 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
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
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)
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -364,6 +509,7 @@ func loadCoopGift(giftID 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
@@ -374,7 +520,7 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
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
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
@@ -386,8 +532,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)
@@ -405,6 +552,7 @@ func loadDayCoopGifts(runID, day 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
@@ -417,7 +565,7 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
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
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
@@ -429,8 +577,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 +597,7 @@ 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
@@ -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
// Group unresolved gifts by lead. Followers contribute to the size
// but use the lead's expiry/votes.
type stackInfo struct {
lead CoopGift
size int
}
stacks := map[int]*stackInfo{}
order := []int{}
for _, g := range all {
if g.VoteResult == "" {
pending = append(pending, g)
if g.VoteResult != "" {
continue
}
leadID := g.ID
if g.StackLeadID != nil {
leadID = *g.StackLeadID
}
if len(pending) > 0 {
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):