Coop: per-gift voting timers (6h) — gifts resolve throughout the day

Replace the single daily-tick gift resolution with independent per-gift
expiries. Each gift now has its own 6h voting window; once that elapses,
the votes are tallied, the sender gets DM feedback, and the live game-room
post is edited to reveal the type and resolved modifier. The modifier
sits on the run waiting for the next floor resolution to merge it in.

Effect:
- Gifts fire continuously throughout the day rather than all at once at
  08:00 UTC, surfacing sender activity in real time.
- Senders get fast feedback (~6h instead of waiting for the next daily
  tick).
- Floor resolution becomes purely "sum pending modifiers from already-
  resolved gifts" — cleaner separation of concerns.
- Atomic per-gift apply via markCoopGiftApplied (UPDATE...WHERE applied_at
  IS NULL) prevents double-application on retry.

Schema additions:
- coop_dungeon_gifts.expires_at (when voting closes)
- coop_dungeon_gifts.applied_at (when modifier merged into a floor)
- Migration entries provided.

Defensive backstop: floor resolution still force-resolves any gifts whose
expiry was missed (e.g., bot down during the timer window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-28 17:55:14 -07:00
parent 4bc541ca85
commit 9f094549b7
4 changed files with 296 additions and 80 deletions

View File

@@ -627,7 +627,7 @@ 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`. 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 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.
| | Open | Leave |
|-------------|------|-------|

View File

@@ -138,6 +138,8 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE coop_dungeon_runs ADD COLUMN last_resolved_day INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE coop_dungeon_members ADD COLUMN member_payout INTEGER`,
`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`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -1445,7 +1447,9 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_gifts (
modifier INTEGER NOT NULL DEFAULT 0,
post_event_id TEXT,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME
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
);
CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result);
`

View File

@@ -8,6 +8,7 @@ import (
"math/rand/v2"
"strconv"
"strings"
"time"
"gogobee/internal/db"
@@ -36,19 +37,26 @@ const (
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"
VoteResult string // "opened" / "left" / ""
Outcome string // "boost" / "reduction" / ""
Modifier int
PostEventID id.EventID
ID int
RunID int
SenderID id.UserID
Day int
GiftType string
Votes map[id.UserID]string // "open" or "leave"
VoteResult string // "opened" / "left" / ""
Outcome string // "boost" / "reduction" / ""
Modifier int
PostEventID id.EventID
ExpiresAt *time.Time // voting closes
AppliedAt *time.Time // modifier merged into a floor roll
}
// ── Command: send a gift ────────────────────────────────────────────────────
@@ -181,71 +189,128 @@ func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) er
// ── Resolution ──────────────────────────────────────────────────────────────
// coopResolveGifts is called inside coopResolveFloor to tally and apply each
// unresolved gift for the current day. Returns the total modifier and a brief
// summary line.
func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) {
gifts, err := loadDayCoopGifts(run.ID, day)
if err != nil || len(gifts) == 0 {
return 0, ""
// 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).
//
// Idempotent: returns early if vote_result is already set.
func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
if g.VoteResult != "" {
return
}
totalMod := 0
var lines []string
for _, g := range gifts {
if g.VoteResult != "" {
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" // default for full abstention
}
var mod int
var outcome string
switch g.GiftType {
case coopGiftBasket:
if voteResult == "opened" {
mod = coopGiftBasketOpened
outcome = "boost"
} else {
mod = coopGiftBasketUnopened
outcome = "reduction"
}
case coopGiftMimic:
if voteResult == "opened" {
mod = coopGiftMimicOpened
outcome = "reduction"
} else {
mod = coopGiftMimicUnopened
outcome = "boost"
}
}
g.VoteResult = voteResult
g.Outcome = outcome
g.Modifier = mod
_ = resolveCoopGift(g.ID, voteResult, outcome, mod)
_ = p.SendDM(g.SenderID, renderCoopGiftSenderDM(g, voteResult, mod))
// Edit the live game-room post to show the resolved state.
gr := gamesRoom()
if gr != "" && g.PostEventID != "" {
_ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g))
}
}
// 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
}
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" // default for full abstention
}
p.resolveSingleGift(g, run.LeaderID)
}
}
var mod int
var outcome string
switch g.GiftType {
case coopGiftBasket:
if voteResult == "opened" {
mod = coopGiftBasketOpened
outcome = "boost"
} else {
mod = coopGiftBasketUnopened
outcome = "reduction"
}
case coopGiftMimic:
if voteResult == "opened" {
mod = coopGiftMimicOpened
outcome = "reduction"
} else {
mod = coopGiftMimicUnopened
outcome = "boost"
}
// 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 gifts for this day get force-resolved now.
dayGifts, _ := loadDayCoopGifts(run.ID, day)
for i := range dayGifts {
if dayGifts[i].VoteResult == "" {
p.resolveSingleGift(&dayGifts[i], leader)
}
totalMod += mod
_ = resolveCoopGift(g.ID, voteResult, outcome, mod)
}
// 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), voteResult, mod))
_ = p.SendDM(g.SenderID, renderCoopGiftSenderDM(&g, voteResult, mod))
titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), g.VoteResult, g.Modifier))
}
if len(lines) == 0 {
return 0, ""
@@ -257,8 +322,9 @@ func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day i
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (int, error) {
d := db.Get()
res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type)
VALUES (?, ?, ?, ?)`, runID, string(sender), day, giftType)
expires := time.Now().UTC().Add(coopGiftWindow)
res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type, expires_at)
VALUES (?, ?, ?, ?, ?)`, runID, string(sender), day, giftType, expires)
if err != nil {
return 0, err
}
@@ -271,11 +337,12 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
g := &CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID)
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -293,13 +360,21 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
if postID.Valid {
g.PostEventID = id.EventID(postID.String)
}
if expiresAt.Valid {
t := expiresAt.Time
g.ExpiresAt = &t
}
if appliedAt.Valid {
t := appliedAt.Time
g.AppliedAt = &t
}
return g, nil
}
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
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
if err != nil {
return nil, err
@@ -310,8 +385,9 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID); err != nil {
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
@@ -325,6 +401,14 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
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
}
out = append(out, g)
}
return out, rows.Err()
@@ -333,7 +417,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
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
if err != nil {
return nil, err
@@ -344,8 +428,9 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID); err != nil {
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
@@ -356,6 +441,17 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
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
}
out = append(out, g)
}
return out, rows.Err()
@@ -401,6 +497,95 @@ func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error
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
FROM coop_dungeon_gifts
WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP
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
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); 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
}
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
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
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); 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
}
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 ──────────────────────────────────────────────────────────────────
func renderCoopGiftPost(run *CoopRun, gift *CoopGift, members []CoopMember) string {
@@ -414,7 +599,16 @@ func renderCoopGiftPost(run *CoopRun, gift *CoopGift, members []CoopMember) stri
sb.WriteString(flavor)
sb.WriteString("\n\n")
}
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`. Resolved at next daily tick.\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"
}
}
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".\n\n")
opens, leaves := 0, 0
for _, v := range gift.Votes {
if v == "open" {
@@ -436,6 +630,19 @@ func renderCoopGiftPost(run *CoopRun, gift *CoopGift, members []CoopMember) stri
return sb.String()
}
// 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 := "📦"
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)
}
func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string {
pickFrom := func(pool []string) string {
if len(pool) == 0 {

View File

@@ -30,6 +30,11 @@ func (p *AdventurePlugin) coopTicker() {
// Locks: every minute.
p.coopProcessLocks()
// Gift expiries: every minute. Resolves any gift whose individual
// 6h voting window has elapsed. Modifier sits on the run until the
// next floor resolution merges it.
p.coopProcessGiftExpiries()
// Resolutions: once per day at morningHour:00 UTC.
now := time.Now().UTC()
if now.Hour() != p.morningHour || now.Minute() != 0 {