Files
gogobee/internal/plugin/coop_dungeon_gifts.go
prosolis 07ca5288c3 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>
2026-04-28 22:28:23 -07:00

914 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Gift Constants ──────────────────────────────────────────────────────────
//
// Magnitudes equalized across all four outcomes so neither "always open" nor
// "always leave" is variance-dominant at a 50/50 sender mix. Asymmetric
// magnitudes (e.g., open=±7, leave=±4) gave leave the same EV with lower
// variance, making it a dominant strategy for risk-averse parties — which
// collapses the dilemma into "always leave."
//
// basket opened: +6 basket unopened: -6
// mimic opened: -6 mimic unopened: +6
//
// Both strategies: EV=0, σ=6. The choice becomes "read sender intent" only.
const (
coopGiftBasketOpened = 6
coopGiftBasketUnopened = -6
coopGiftMimicOpened = -6
coopGiftMimicUnopened = 6
)
const (
coopGiftBasket = "basket"
coopGiftMimic = "mimic"
// Per-gift voting window. After this elapses, the gift's votes are
// tallied independently of the daily floor tick. Senders get DM feedback
// at expiry; the modifier sits on the run until the next floor resolves.
coopGiftWindow = 6 * time.Hour
)
type CoopGift struct {
ID int
RunID int
SenderID id.UserID
Day int
GiftType string
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 — 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 ────────────────────────────────────────────────────
func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 2 {
return p.SendDM(ctx.Sender, "Usage: `!coop gift <run_id> <basket|mimic>`. Costs one harvest action; party never knows what you sent.")
}
runID, err := strconv.Atoi(parts[0])
if err != nil {
return p.SendDM(ctx.Sender, "Run ID must be a number.")
}
giftType := strings.ToLower(parts[1])
if giftType != coopGiftBasket && giftType != coopGiftMimic {
return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.")
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. The post office does not deliver from the ditch.")
}
if !char.CanDoHarvest(false) {
return p.SendDM(ctx.Sender, "You're out of harvest actions today.")
}
run, err := loadCoopRun(runID)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Run not found.")
}
if run.Status != "active" {
return p.SendDM(ctx.Sender, "Run is not active. Gifts can only be sent during a running co-op.")
}
// Senders cannot be party members.
if existing, _ := loadCoopMember(runID, ctx.Sender); existing != nil {
return p.SendDM(ctx.Sender, "You're in the party. You can't send gifts to yourself.")
}
giftID, leadID, isNewLead, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't send the gift.")
}
// Charge the harvest action and persist.
char.HarvestActionsUsed++
if err := saveAdvCharacter(char); err != nil {
slog.Error("coop: save sender after gift", "err", err)
}
// Post (new lead) or edit (added to existing stack).
gr := gamesRoom()
if gr != "" {
members, _ := loadCoopMembers(runID)
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))
}
// ── Command: vote on a gift ─────────────────────────────────────────────────
func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 1 {
return p.SendDM(ctx.Sender, "Usage: `!coop giftvote <gift_id> <open|leave>` or `!coop giftvote <open|leave>` for the most recent unresolved gift.")
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadCoopRunForUser(ctx.Sender)
if err != nil || run == nil || run.Status != "active" {
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
}
var gift *CoopGift
var choice string
if len(parts) >= 2 {
giftID, perr := strconv.Atoi(parts[0])
if perr != nil {
return p.SendDM(ctx.Sender, "First arg must be the gift id, or just `open`/`leave` for the most recent.")
}
gift, _ = loadCoopGift(giftID)
choice = strings.ToLower(parts[1])
} else {
gift, _ = loadMostRecentUnresolvedGift(run.ID, run.CurrentDay)
choice = strings.ToLower(parts[0])
}
if gift == nil {
return p.SendDM(ctx.Sender, "No unresolved gift found.")
}
// If 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.")
}
if gift.VoteResult != "" {
return p.SendDM(ctx.Sender, "That gift has already resolved.")
}
if choice != "open" && choice != "leave" {
return p.SendDM(ctx.Sender, "Vote `open` or `leave`.")
}
if gift.Votes == nil {
gift.Votes = map[id.UserID]string{}
}
gift.Votes[ctx.Sender] = choice
if err := saveCoopGiftVotes(gift); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save your vote.")
}
gr := gamesRoom()
if gr != "" && gift.PostEventID != "" {
members, _ := loadCoopMembers(run.ID)
_ = p.EditMessage(gr, gift.PostEventID, renderCoopGiftPost(p, run, gift, members))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("Gift #%d vote recorded: %s.", gift.ID, choice))
}
// ── Resolution ──────────────────────────────────────────────────────────────
// 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.
//
// 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 {
if v == "open" {
opens++
} else if v == "leave" {
leaves++
}
if u == leader {
leaderVote = v
}
}
var voteResult string
switch {
case opens > leaves:
voteResult = "opened"
case leaves > opens:
voteResult = "left"
case leaderVote == "open":
voteResult = "opened"
case leaderVote == "leave":
voteResult = "left"
default:
voteResult = "left"
}
// 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" {
perMod = coopGiftBasketOpened
outcome = "boost"
} else {
perMod = coopGiftBasketUnopened
outcome = "reduction"
}
case coopGiftMimic:
if voteResult == "opened" {
perMod = coopGiftMimicOpened
outcome = "reduction"
} else {
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 = perMod
// 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, len(stack), totalMod))
}
}
// coopProcessGiftExpiries runs each ticker minute, resolving any gifts whose
// voting window has elapsed. Modifiers are recorded but not yet applied to a
// floor — they wait for the next floor resolution.
func (p *AdventurePlugin) coopProcessGiftExpiries() {
gifts, err := loadExpiredUnresolvedGifts()
if err != nil {
slog.Error("coop: load expired gifts", "err", err)
return
}
for i := range gifts {
g := &gifts[i]
run, _ := loadCoopRun(g.RunID)
if run == nil || run.Status != "active" {
// Run gone or already terminal — still mark the gift resolved so
// it stops cluttering the expiry query.
_ = resolveCoopGift(g.ID, "left", "reduction", 0)
continue
}
p.resolveSingleGift(g, run.LeaderID)
}
}
// coopResolveGifts is called inside coopResolveFloor. Two responsibilities:
//
// 1. Catchall: force-resolve any gifts on this day that still have no
// vote_result (the timer ticker should normally have caught them, but
// this is the defensive backstop, e.g., bot was down).
// 2. Apply: sum modifiers from any resolved-but-not-yet-applied gifts on
// this run and atomically claim them via markCoopGiftApplied so the
// same modifier is never double-applied across retries.
//
// 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 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)
}
}
// Apply: pending resolved gifts (any day, this run) get their modifier
// merged into the floor roll.
pending, _ := loadResolvedUnappliedGifts(run.ID)
totalMod := 0
var lines []string
for _, g := range pending {
claimed, err := markCoopGiftApplied(g.ID)
if err != nil || !claimed {
continue
}
totalMod += g.Modifier
lines = append(lines, fmt.Sprintf(" %s gift from %s — %s → %+d%%",
titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), g.VoteResult, g.Modifier))
}
if len(lines) == 0 {
return 0, ""
}
return totalMod, "Gifts:\n" + strings.Join(lines, "\n")
}
// ── DB ──────────────────────────────────────────────────────────────────────
// 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()
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
}
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), 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, stack_lead_id
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopGift
for rows.Next() {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
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
}
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
}
out = append(out, g)
}
return out, rows.Err()
}
func loadMostRecentUnresolvedGift(runID, day int) (*CoopGift, error) {
gifts, err := loadDayCoopGifts(runID, day)
if err != nil {
return nil, err
}
for i := len(gifts) - 1; i >= 0; i-- {
if gifts[i].VoteResult == "" {
g := gifts[i]
return &g, nil
}
}
return nil, nil
}
func saveCoopGiftVotes(g *CoopGift) error {
d := db.Get()
jsonBytes, err := json.Marshal(serializeCoopVotes(g.Votes))
if err != nil {
return err
}
_, err = d.Exec(`UPDATE coop_dungeon_gifts SET votes = ? WHERE id = ?`,
string(jsonBytes), g.ID)
return err
}
func saveCoopGiftPostID(giftID int, eventID id.EventID) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_gifts SET post_event_id = ? WHERE id = ?`,
string(eventID), giftID)
return err
}
func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_gifts
SET vote_result = ?, outcome = ?, modifier = ?, resolved_at = CURRENT_TIMESTAMP
WHERE id = ?`, voteResult, outcome, modifier, giftID)
return err
}
// loadExpiredUnresolvedGifts returns gifts whose voting window has elapsed
// but whose votes haven't yet been tallied. Driven by the per-minute
// expiry ticker.
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, 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
}
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 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
}
out = append(out, g)
}
return out, rows.Err()
}
// loadResolvedUnappliedGifts returns gifts that have been tallied but whose
// modifiers have not yet been merged into a floor success roll. Used by
// floor resolution to sum and apply pending gift modifiers.
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, 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)
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 stackLeadID.Valid {
v := int(stackLeadID.Int64)
g.StackLeadID = &v
}
out = append(out, g)
}
return out, rows.Err()
}
// markCoopGiftApplied claims a gift's modifier as merged into a floor roll.
// Idempotent — only the first call sets applied_at; subsequent calls return
// false (claimed=false) so the same modifier is never double-applied.
func markCoopGiftApplied(giftID int) (bool, error) {
d := db.Get()
res, err := d.Exec(`UPDATE coop_dungeon_gifts SET applied_at = CURRENT_TIMESTAMP
WHERE id = ? AND applied_at IS NULL`, giftID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// ── 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
// 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" {
opens++
} else if v == "leave" {
leaves++
}
}
leaderName := coopDisplayName(p, run.LeaderID)
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[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)
sb.WriteString(flavor)
sb.WriteString("\n\n")
}
// "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)
if remaining > 0 {
closesIn = fmt.Sprintf(" · voting closes in %s", remaining)
} else {
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
for _, m := range members {
if _, ok := gift.Votes[m.UserID]; !ok {
awaiting++
}
}
if awaiting > 0 && awaiting < len(members) {
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", awaiting, len(members)))
}
return sb.String()
}
// renderCoopGiftResolvedPost replaces the live vote post when a gift's
// 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"
}
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 {
pickFrom := func(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}
switch {
case g.GiftType == coopGiftBasket && voteResult == "opened":
return fmt.Sprintf("📦 Your care basket was opened. %s (%+d%% to today's floor)", pickFrom(TwinBeeGiftBasketOpened), mod)
case g.GiftType == coopGiftBasket && voteResult == "left":
return fmt.Sprintf("📦 Your care basket was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftBasketUnopened), mod)
case g.GiftType == coopGiftMimic && voteResult == "opened":
return fmt.Sprintf("📦 Your mimic was opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicOpened), mod)
case g.GiftType == coopGiftMimic && voteResult == "left":
return fmt.Sprintf("📦 Your mimic was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicUnopened), mod)
}
return "📦 Your gift resolved."
}
// renderCoopGiftLog returns the public end-of-run gift log, 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 _, 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",
k.day, strings.Join(names, " + "), typeLabel, result, totalMod))
}
return sb.String()
}