mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
914 lines
29 KiB
Go
914 lines
29 KiB
Go
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()
|
||
}
|