mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -50,13 +50,14 @@ type CoopGift struct {
|
||||
SenderID id.UserID
|
||||
Day int
|
||||
GiftType string
|
||||
Votes map[id.UserID]string // "open" or "leave"
|
||||
Votes map[id.UserID]string // "open" or "leave" — only set on lead
|
||||
VoteResult string // "opened" / "left" / ""
|
||||
Outcome string // "boost" / "reduction" / ""
|
||||
Modifier int
|
||||
PostEventID id.EventID
|
||||
ExpiresAt *time.Time // voting closes
|
||||
ExpiresAt *time.Time // voting closes — only set on lead
|
||||
AppliedAt *time.Time // modifier merged into a floor roll
|
||||
StackLeadID *int // NULL = lead/standalone; otherwise points at lead's id
|
||||
}
|
||||
|
||||
// ── Command: send a gift ────────────────────────────────────────────────────
|
||||
@@ -103,7 +104,7 @@ func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error
|
||||
return p.SendDM(ctx.Sender, "You're in the party. You can't send gifts to yourself.")
|
||||
}
|
||||
|
||||
giftID, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType)
|
||||
giftID, leadID, isNewLead, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't send the gift.")
|
||||
}
|
||||
@@ -114,14 +115,21 @@ func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error
|
||||
slog.Error("coop: save sender after gift", "err", err)
|
||||
}
|
||||
|
||||
// Post the vote prompt to the game room.
|
||||
// Post (new lead) or edit (added to existing stack).
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
gift, _ := loadCoopGift(giftID)
|
||||
members, _ := loadCoopMembers(runID)
|
||||
postID, err := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
||||
if err == nil {
|
||||
_ = saveCoopGiftPostID(giftID, postID)
|
||||
if isNewLead {
|
||||
gift, _ := loadCoopGift(giftID)
|
||||
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
||||
if perr == nil {
|
||||
_ = saveCoopGiftPostID(giftID, postID)
|
||||
}
|
||||
} else {
|
||||
lead, _ := loadCoopGift(leadID)
|
||||
if lead != nil && lead.PostEventID != "" {
|
||||
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +169,15 @@ func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) er
|
||||
if gift == nil {
|
||||
return p.SendDM(ctx.Sender, "No unresolved gift found.")
|
||||
}
|
||||
// If the user voted on a follower, redirect to its lead — votes always
|
||||
// live on the lead.
|
||||
if gift.StackLeadID != nil {
|
||||
lead, _ := loadCoopGift(*gift.StackLeadID)
|
||||
if lead == nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't find the gift's stack lead.")
|
||||
}
|
||||
gift = lead
|
||||
}
|
||||
if gift.RunID != run.ID || gift.Day != run.CurrentDay {
|
||||
return p.SendDM(ctx.Sender, "That gift isn't for your current run/day.")
|
||||
}
|
||||
@@ -189,15 +206,30 @@ func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) er
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
// resolveSingleGift tallies a single gift's votes, persists the outcome, and
|
||||
// DMs the sender. Used by both the per-minute expiry ticker and the
|
||||
// floor-resolution catchall (for any gift that somehow missed its expiry).
|
||||
// resolveSingleGift tallies a stack lead's votes, persists the outcome, DMs
|
||||
// every sender in the stack, and edits the game-room post to show the
|
||||
// resolved state. Used by both the per-minute expiry ticker and the
|
||||
// floor-resolution catchall.
|
||||
//
|
||||
// Idempotent: returns early if vote_result is already set.
|
||||
// Followers passed in here will be redirected to their lead. Idempotent:
|
||||
// always re-loads the lead from DB before checking vote_result, so an
|
||||
// in-memory snapshot from earlier in the same loop can't trigger a double
|
||||
// resolve.
|
||||
func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
|
||||
// Redirect follower → lead, then re-fetch fresh state.
|
||||
leadID := g.ID
|
||||
if g.StackLeadID != nil {
|
||||
leadID = *g.StackLeadID
|
||||
}
|
||||
fresh, _ := loadCoopGift(leadID)
|
||||
if fresh == nil {
|
||||
return
|
||||
}
|
||||
g = fresh
|
||||
if g.VoteResult != "" {
|
||||
return
|
||||
}
|
||||
|
||||
opens, leaves := 0, 0
|
||||
var leaderVote string
|
||||
for u, v := range g.Votes {
|
||||
@@ -221,39 +253,49 @@ func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
|
||||
case leaderVote == "leave":
|
||||
voteResult = "left"
|
||||
default:
|
||||
voteResult = "left" // default for full abstention
|
||||
voteResult = "left"
|
||||
}
|
||||
|
||||
var mod int
|
||||
// Per-gift modifier (single ±6 unit). Stack-wide modifier is N × this,
|
||||
// applied at floor resolution by summing the rows individually.
|
||||
var perMod int
|
||||
var outcome string
|
||||
switch g.GiftType {
|
||||
case coopGiftBasket:
|
||||
if voteResult == "opened" {
|
||||
mod = coopGiftBasketOpened
|
||||
perMod = coopGiftBasketOpened
|
||||
outcome = "boost"
|
||||
} else {
|
||||
mod = coopGiftBasketUnopened
|
||||
perMod = coopGiftBasketUnopened
|
||||
outcome = "reduction"
|
||||
}
|
||||
case coopGiftMimic:
|
||||
if voteResult == "opened" {
|
||||
mod = coopGiftMimicOpened
|
||||
perMod = coopGiftMimicOpened
|
||||
outcome = "reduction"
|
||||
} else {
|
||||
mod = coopGiftMimicUnopened
|
||||
perMod = coopGiftMimicUnopened
|
||||
outcome = "boost"
|
||||
}
|
||||
}
|
||||
|
||||
stack, _ := loadCoopGiftStack(g.ID)
|
||||
totalMod := 0
|
||||
for _, m := range stack {
|
||||
_ = resolveCoopGift(m.ID, voteResult, outcome, perMod)
|
||||
_ = p.SendDM(m.SenderID, renderCoopGiftSenderDM(&m, voteResult, perMod))
|
||||
totalMod += perMod
|
||||
}
|
||||
|
||||
g.VoteResult = voteResult
|
||||
g.Outcome = outcome
|
||||
g.Modifier = mod
|
||||
_ = resolveCoopGift(g.ID, voteResult, outcome, mod)
|
||||
_ = p.SendDM(g.SenderID, renderCoopGiftSenderDM(g, voteResult, mod))
|
||||
g.Modifier = perMod
|
||||
|
||||
// Edit the live game-room post to show the resolved state.
|
||||
// Edit the live game-room post to show the resolved state — total swing
|
||||
// reflects the full stack.
|
||||
gr := gamesRoom()
|
||||
if gr != "" && g.PostEventID != "" {
|
||||
_ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g))
|
||||
_ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g, len(stack), totalMod))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +332,14 @@ func (p *AdventurePlugin) coopProcessGiftExpiries() {
|
||||
//
|
||||
// Returns total modifier and a summary line for the daily-result post.
|
||||
func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) {
|
||||
// Catchall: any unresolved gifts for this day get force-resolved now.
|
||||
// Catchall: any unresolved leads for this day get force-resolved now.
|
||||
// Iterate leads only — resolveSingleGift propagates to followers, so
|
||||
// touching followers here would just be redundant work.
|
||||
dayGifts, _ := loadDayCoopGifts(run.ID, day)
|
||||
for i := range dayGifts {
|
||||
if dayGifts[i].StackLeadID != nil {
|
||||
continue
|
||||
}
|
||||
if dayGifts[i].VoteResult == "" {
|
||||
p.resolveSingleGift(&dayGifts[i], leader)
|
||||
}
|
||||
@@ -320,62 +367,34 @@ func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day i
|
||||
|
||||
// ── DB ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (int, error) {
|
||||
// findCoopGiftStackLead returns the active lead gift for a (run, day, type)
|
||||
// stack, or nil if no unresolved lead exists. Lead = oldest unresolved gift
|
||||
// of that type with stack_lead_id IS NULL.
|
||||
func findCoopGiftStackLead(runID, day int, giftType string) (*CoopGift, error) {
|
||||
d := db.Get()
|
||||
expires := time.Now().UTC().Add(coopGiftWindow)
|
||||
res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`, runID, string(sender), day, giftType, expires)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
return int(id64), nil
|
||||
}
|
||||
|
||||
func loadCoopGift(giftID int) (*CoopGift, error) {
|
||||
d := db.Get()
|
||||
g := &CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
|
||||
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt)
|
||||
var leadID int
|
||||
err := d.QueryRow(`SELECT id FROM coop_dungeon_gifts
|
||||
WHERE run_id = ? AND day = ? AND gift_type = ?
|
||||
AND vote_result IS NULL AND stack_lead_id IS NULL
|
||||
ORDER BY id ASC LIMIT 1`, runID, day, giftType).Scan(&leadID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
return g, nil
|
||||
return loadCoopGift(leadID)
|
||||
}
|
||||
|
||||
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
// loadCoopGiftStack returns the lead and all followers (in id order) for a
|
||||
// stack identified by lead_id. Used to propagate resolution + render.
|
||||
func loadCoopGiftStack(leadID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE id = ? OR stack_lead_id = ?
|
||||
ORDER BY id ASC`, leadID, leadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -386,8 +405,9 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
@@ -409,16 +429,99 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
if stackLeadID.Valid {
|
||||
v := int(stackLeadID.Int64)
|
||||
g.StackLeadID = &v
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
// createCoopGift inserts a new gift. If an unresolved same-type stack exists
|
||||
// for the run+day, the new gift becomes a follower (no expires_at, no own
|
||||
// post — lead's post is edited to bump the count). Otherwise a new lead is
|
||||
// created with its own 6h expiry.
|
||||
//
|
||||
// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective
|
||||
// stack lead — either itself (new lead) or the existing lead it joined.
|
||||
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) {
|
||||
d := db.Get()
|
||||
|
||||
existing, lerr := findCoopGiftStackLead(runID, day, giftType)
|
||||
if lerr != nil {
|
||||
return 0, 0, false, lerr
|
||||
}
|
||||
if existing != nil {
|
||||
// Follower: no expires_at, no post. Inherits lead's deadline.
|
||||
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
||||
(run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
runID, string(sender), day, giftType, existing.ID)
|
||||
if ierr != nil {
|
||||
return 0, 0, false, ierr
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
return int(id64), existing.ID, false, nil
|
||||
}
|
||||
|
||||
// New lead: own expiry, will get its own post.
|
||||
expires := time.Now().UTC().Add(coopGiftWindow)
|
||||
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
||||
(run_id, sender_id, day, gift_type, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
runID, string(sender), day, giftType, expires)
|
||||
if ierr != nil {
|
||||
return 0, 0, false, ierr
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
return int(id64), int(id64), true, nil
|
||||
}
|
||||
|
||||
func loadCoopGift(giftID int) (*CoopGift, error) {
|
||||
d := db.Get()
|
||||
g := &CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
|
||||
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -429,8 +532,9 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
@@ -448,6 +552,52 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
@@ -503,9 +653,10 @@ func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error
|
||||
func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP
|
||||
AND stack_lead_id IS NULL
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -517,8 +668,9 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
@@ -530,6 +682,10 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid {
|
||||
v := int(stackLeadID.Int64)
|
||||
g.StackLeadID = &v
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -541,7 +697,7 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
||||
func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL
|
||||
ORDER BY id`, runID)
|
||||
@@ -555,8 +711,9 @@ func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
@@ -567,6 +724,10 @@ func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if stackLeadID.Valid {
|
||||
v := int(stackLeadID.Int64)
|
||||
g.StackLeadID = &v
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -588,12 +749,20 @@ func markCoopGiftApplied(giftID int) (bool, error) {
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// renderCoopGiftPost is called only for stack leads. Stack count is derived
|
||||
// from how many followers point at this lead. Flavor is picked deterministically
|
||||
// by lead id so re-renders don't rotate.
|
||||
func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, members []CoopMember) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**\n\n", gift.ID, run.ID, gift.Day))
|
||||
|
||||
// Tally before substitution so the flavor's "Open: {count} · Leave it: {count}"
|
||||
// renders with real numbers.
|
||||
// Stack size = lead + followers
|
||||
stack, _ := loadCoopGiftStack(gift.ID)
|
||||
stackSize := len(stack)
|
||||
if stackSize == 0 {
|
||||
stackSize = 1
|
||||
}
|
||||
|
||||
// Tally on the lead's votes
|
||||
opens, leaves := 0, 0
|
||||
for _, v := range gift.Votes {
|
||||
if v == "open" {
|
||||
@@ -604,10 +773,16 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member
|
||||
}
|
||||
leaderName := coopDisplayName(p, run.LeaderID)
|
||||
|
||||
// Pick a flavor entry and substitute its template fields. Two {count}
|
||||
// placeholders are positional: first = opens, second = leaves.
|
||||
header := fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**", gift.ID, run.ID, gift.Day)
|
||||
if stackSize > 1 {
|
||||
header = fmt.Sprintf("📦 **Gift Stack #%d (×%d) — Co-op #%d, Day %d**", gift.ID, stackSize, run.ID, gift.Day)
|
||||
}
|
||||
sb.WriteString(header)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Pick flavor deterministically so edits don't rotate the joke.
|
||||
if len(TwinBeeGiftArrival) > 0 {
|
||||
flavor := TwinBeeGiftArrival[rand.IntN(len(TwinBeeGiftArrival))]
|
||||
flavor := TwinBeeGiftArrival[gift.ID%len(TwinBeeGiftArrival)]
|
||||
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(opens), 1)
|
||||
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1)
|
||||
flavor = strings.ReplaceAll(flavor, "{leader}", leaderName)
|
||||
@@ -615,7 +790,14 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Actionable line — the flavor doesn't include the actual command.
|
||||
// "REALLY special" line, picked once per stack lead, shown for size 2+.
|
||||
if stackSize >= 2 && len(TwinBeeGiftStackEscalation) > 0 {
|
||||
line := TwinBeeGiftStackEscalation[gift.ID%len(TwinBeeGiftStackEscalation)]
|
||||
sb.WriteString("_")
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("_\n\n")
|
||||
}
|
||||
|
||||
closesIn := ""
|
||||
if gift.ExpiresAt != nil {
|
||||
remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute)
|
||||
@@ -625,6 +807,9 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member
|
||||
closesIn = " · voting closing"
|
||||
}
|
||||
}
|
||||
if stackSize > 1 {
|
||||
sb.WriteString(fmt.Sprintf("One vote covers the whole stack of %d. ", stackSize))
|
||||
}
|
||||
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".")
|
||||
|
||||
awaiting := 0
|
||||
@@ -640,16 +825,18 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member
|
||||
}
|
||||
|
||||
// renderCoopGiftResolvedPost replaces the live vote post when a gift's
|
||||
// voting window closes. Reveals the type and resolved modifier — the type
|
||||
// stays hidden during voting but is visible after.
|
||||
func renderCoopGiftResolvedPost(g *CoopGift) string {
|
||||
icon := "📦"
|
||||
// voting window closes. Total modifier = stackSize × per-gift mod.
|
||||
func renderCoopGiftResolvedPost(g *CoopGift, stackSize, totalMod int) string {
|
||||
verb := "left"
|
||||
if g.VoteResult == "opened" {
|
||||
verb = "opened"
|
||||
}
|
||||
return fmt.Sprintf("%s **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.",
|
||||
icon, g.ID, titleCaseWord(g.GiftType), verb, g.Modifier)
|
||||
if stackSize > 1 {
|
||||
return fmt.Sprintf("📦 **Gift Stack #%d (×%d) resolved** — %s, %s → **%+d%%** to next floor.",
|
||||
g.ID, stackSize, titleCaseWord(g.GiftType), verb, totalMod)
|
||||
}
|
||||
return fmt.Sprintf("📦 **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.",
|
||||
g.ID, titleCaseWord(g.GiftType), verb, totalMod)
|
||||
}
|
||||
|
||||
func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string {
|
||||
@@ -672,21 +859,55 @@ func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string {
|
||||
return "📦 Your gift resolved."
|
||||
}
|
||||
|
||||
// renderCoopGiftLog returns the public end-of-run gift log.
|
||||
// renderCoopGiftLog returns the public end-of-run gift log, grouped by stack.
|
||||
// One line per stack with all senders listed and the total swing.
|
||||
func renderCoopGiftLog(p *AdventurePlugin, runID int) string {
|
||||
gifts, err := loadAllCoopGifts(runID)
|
||||
if err != nil || len(gifts) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Group gifts by their effective lead id.
|
||||
type groupKey struct {
|
||||
day int
|
||||
giftType string
|
||||
leadID int
|
||||
}
|
||||
groups := map[groupKey][]CoopGift{}
|
||||
order := []groupKey{}
|
||||
for _, g := range gifts {
|
||||
leadID := g.ID
|
||||
if g.StackLeadID != nil {
|
||||
leadID = *g.StackLeadID
|
||||
}
|
||||
k := groupKey{day: g.Day, giftType: g.GiftType, leadID: leadID}
|
||||
if _, seen := groups[k]; !seen {
|
||||
order = append(order, k)
|
||||
}
|
||||
groups[k] = append(groups[k], g)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📦 Gift Log:\n")
|
||||
for _, g := range gifts {
|
||||
result := g.VoteResult
|
||||
for _, k := range order {
|
||||
stack := groups[k]
|
||||
// Senders for the line — ordered by id for stable display.
|
||||
names := make([]string, 0, len(stack))
|
||||
totalMod := 0
|
||||
voteResult := stack[0].VoteResult
|
||||
for _, g := range stack {
|
||||
names = append(names, coopDisplayName(p, g.SenderID))
|
||||
totalMod += g.Modifier
|
||||
}
|
||||
result := voteResult
|
||||
if result == "" {
|
||||
result = "unresolved"
|
||||
}
|
||||
typeLabel := titleCaseWord(k.giftType)
|
||||
if len(stack) > 1 {
|
||||
typeLabel = fmt.Sprintf("%s ×%d", typeLabel, len(stack))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Day %d: %s → %s → %s → %+d%%\n",
|
||||
g.Day, coopDisplayName(p, g.SenderID), titleCaseWord(g.GiftType), result, g.Modifier))
|
||||
k.day, strings.Join(names, " + "), typeLabel, result, totalMod))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -217,22 +217,43 @@ func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) st
|
||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
||||
}
|
||||
|
||||
// Pending gift list with per-gift countdowns. Voting windows expire
|
||||
// independently throughout the day, so this is the canonical place to
|
||||
// see what's still open.
|
||||
// Pending stacks with per-stack countdowns. Each game-room post is one
|
||||
// stack (lead + N followers); we show the lead's id and the stack size.
|
||||
if run.Status == "active" {
|
||||
all, _ := loadAllCoopGifts(run.ID)
|
||||
var pending []CoopGift
|
||||
for _, g := range all {
|
||||
if g.VoteResult == "" {
|
||||
pending = append(pending, g)
|
||||
}
|
||||
// Group unresolved gifts by lead. Followers contribute to the size
|
||||
// but use the lead's expiry/votes.
|
||||
type stackInfo struct {
|
||||
lead CoopGift
|
||||
size int
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
stacks := map[int]*stackInfo{}
|
||||
order := []int{}
|
||||
for _, g := range all {
|
||||
if g.VoteResult != "" {
|
||||
continue
|
||||
}
|
||||
leadID := g.ID
|
||||
if g.StackLeadID != nil {
|
||||
leadID = *g.StackLeadID
|
||||
}
|
||||
s, ok := stacks[leadID]
|
||||
if !ok {
|
||||
s = &stackInfo{}
|
||||
stacks[leadID] = s
|
||||
order = append(order, leadID)
|
||||
}
|
||||
if g.StackLeadID == nil {
|
||||
s.lead = g
|
||||
}
|
||||
s.size++
|
||||
}
|
||||
if len(stacks) > 0 {
|
||||
sb.WriteString("\n\nPending gifts:\n")
|
||||
for _, g := range pending {
|
||||
for _, leadID := range order {
|
||||
s := stacks[leadID]
|
||||
opens, leaves := 0, 0
|
||||
for _, v := range g.Votes {
|
||||
for _, v := range s.lead.Votes {
|
||||
if v == "open" {
|
||||
opens++
|
||||
} else if v == "leave" {
|
||||
@@ -240,16 +261,20 @@ func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) st
|
||||
}
|
||||
}
|
||||
countdown := "—"
|
||||
if g.ExpiresAt != nil {
|
||||
remaining := time.Until(*g.ExpiresAt).Truncate(time.Minute)
|
||||
if s.lead.ExpiresAt != nil {
|
||||
remaining := time.Until(*s.lead.ExpiresAt).Truncate(time.Minute)
|
||||
if remaining > 0 {
|
||||
countdown = remaining.String()
|
||||
} else {
|
||||
countdown = "closing"
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" #%d (day %d) — open %d / leave %d — closes in %s\n",
|
||||
g.ID, g.Day, opens, leaves, countdown))
|
||||
stackLabel := fmt.Sprintf("#%d", s.lead.ID)
|
||||
if s.size > 1 {
|
||||
stackLabel = fmt.Sprintf("#%d ×%d", s.lead.ID, s.size)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s (day %d) — open %d / leave %d — closes in %s\n",
|
||||
stackLabel, s.lead.Day, opens, leaves, countdown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user