mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32: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:
@@ -627,7 +627,9 @@ Multi-day party runs separate from solo. 2–4 players, 2–7 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.
|
**Spectator betting.** Parimutuel pool, 10% rake. Bets can be placed or increased any time during the run; positions are locked in (success or failure can't be switched). Party members can bet on their own run — sandbagging while heavily bet against is exploitative and visible. Odds line shows estimated success% based on party levels, pets, neutral funding assumption, and a hidden TwinBee Helpfulness Rating (rolling last 30 floor events: how often his recommendation was right). Helpfulness shifts the line ±20% but is never shown directly — players notice the line moves after events resolve.
|
||||||
|
|
||||||
**Gifts.** Anyone *not* in the run can spend one harvest action to send a Care Basket or a Mimic. The party never sees which type. Vote `open` or `leave`. **Each gift has its own 6-hour voting window** — voting closes when the window elapses, the gift is tallied, and the sender gets a DM with the result. The modifier sits on the run until the next floor resolution merges it in. Gifts therefore "fire" throughout the day rather than all at once at the daily tick. Magnitudes are equalized across all four outcomes — both "always open" and "always leave" yield EV=0 with σ=6, so neither is risk-averse-dominant. The choice is purely about reading sender intent.
|
**Gifts.** Anyone *not* in the run can spend one harvest action to send a Care Basket or a Mimic. The party never sees which type. Vote `open` or `leave`. **Each gift's voting window is 6 hours**; voting closes when it elapses, the stack is tallied, and every sender gets a DM with the result. The modifier sits on the run until the next floor resolution merges it in. Gifts "fire" throughout the day rather than piling up at the daily tick. Magnitudes are equalized across all four outcomes — both "always open" and "always leave" yield EV=0 with σ=6, so neither is risk-averse-dominant. The choice is purely about reading sender intent.
|
||||||
|
|
||||||
|
**Stacking.** Multiple gifts of the same type sent on the same day **stack into a single game-room post** — one vote covers the whole stack, modifier scales with size (5 baskets opened = +30%, 5 baskets exploded = −30%). The first-in sets the deadline; subsequent additions inherit it without extending the timer. When a stack hits size 2, TwinBee notes "this gift looks REALLY special" once and then quietly bumps the count on further additions. The end-of-run gift log groups by stack with all senders attributed.
|
||||||
|
|
||||||
| | Open | Leave |
|
| | Open | Leave |
|
||||||
|-------------|------|-------|
|
|-------------|------|-------|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ func runMigrations(d *sql.DB) error {
|
|||||||
`ALTER TABLE coop_dungeon_runs ADD COLUMN invite_post_id TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE coop_dungeon_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 expires_at DATETIME`,
|
||||||
`ALTER TABLE coop_dungeon_gifts ADD COLUMN applied_at DATETIME`,
|
`ALTER TABLE coop_dungeon_gifts ADD COLUMN applied_at DATETIME`,
|
||||||
|
`ALTER TABLE coop_dungeon_gifts ADD COLUMN stack_lead_id INTEGER`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
@@ -1449,7 +1450,8 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_gifts (
|
|||||||
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at DATETIME, -- voting closes; tally fires when reached
|
expires_at DATETIME, -- voting closes; tally fires when reached
|
||||||
resolved_at DATETIME, -- vote tallied (vote_result/modifier set)
|
resolved_at DATETIME, -- vote tallied (vote_result/modifier set)
|
||||||
applied_at DATETIME -- modifier merged into a floor success roll
|
applied_at DATETIME, -- modifier merged into a floor success roll
|
||||||
|
stack_lead_id INTEGER -- NULL for lead/standalone; follower points to lead's id
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result);
|
CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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))
|
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 {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't create gift.")
|
return p.SendDM(ctx.Sender, "Couldn't create gift.")
|
||||||
}
|
}
|
||||||
|
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
if gr != "" {
|
if gr != "" {
|
||||||
gift, _ := loadCoopGift(giftID)
|
|
||||||
members, _ := loadCoopMembers(runID)
|
members, _ := loadCoopMembers(runID)
|
||||||
if gift != nil {
|
if isNewLead {
|
||||||
|
gift, _ := loadCoopGift(giftID)
|
||||||
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
||||||
if perr == nil {
|
if perr == nil {
|
||||||
_ = saveCoopGiftPostID(giftID, postID)
|
_ = 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.",
|
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))
|
giftType, sender, runID, run.CurrentDay, giftID, leadID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error {
|
func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error {
|
||||||
|
|||||||
@@ -50,13 +50,14 @@ type CoopGift struct {
|
|||||||
SenderID id.UserID
|
SenderID id.UserID
|
||||||
Day int
|
Day int
|
||||||
GiftType string
|
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" / ""
|
VoteResult string // "opened" / "left" / ""
|
||||||
Outcome string // "boost" / "reduction" / ""
|
Outcome string // "boost" / "reduction" / ""
|
||||||
Modifier int
|
Modifier int
|
||||||
PostEventID id.EventID
|
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
|
AppliedAt *time.Time // modifier merged into a floor roll
|
||||||
|
StackLeadID *int // NULL = lead/standalone; otherwise points at lead's id
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command: send a gift ────────────────────────────────────────────────────
|
// ── 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.")
|
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 {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't send the gift.")
|
return p.SendDM(ctx.Sender, "Couldn't send the gift.")
|
||||||
}
|
}
|
||||||
@@ -114,15 +115,22 @@ func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error
|
|||||||
slog.Error("coop: save sender after gift", "err", err)
|
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()
|
gr := gamesRoom()
|
||||||
if gr != "" {
|
if gr != "" {
|
||||||
gift, _ := loadCoopGift(giftID)
|
|
||||||
members, _ := loadCoopMembers(runID)
|
members, _ := loadCoopMembers(runID)
|
||||||
postID, err := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
if isNewLead {
|
||||||
if err == nil {
|
gift, _ := loadCoopGift(giftID)
|
||||||
|
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
||||||
|
if perr == nil {
|
||||||
_ = saveCoopGiftPostID(giftID, postID)
|
_ = 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))
|
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Gift dispatched (run #%d, day %d). One harvest action consumed. The party doesn't know what you sent.", runID, run.CurrentDay))
|
||||||
@@ -161,6 +169,15 @@ func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) er
|
|||||||
if gift == nil {
|
if gift == nil {
|
||||||
return p.SendDM(ctx.Sender, "No unresolved gift found.")
|
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 {
|
if gift.RunID != run.ID || gift.Day != run.CurrentDay {
|
||||||
return p.SendDM(ctx.Sender, "That gift isn't for your current run/day.")
|
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 ──────────────────────────────────────────────────────────────
|
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// resolveSingleGift tallies a single gift's votes, persists the outcome, and
|
// resolveSingleGift tallies a stack lead's votes, persists the outcome, DMs
|
||||||
// DMs the sender. Used by both the per-minute expiry ticker and the
|
// every sender in the stack, and edits the game-room post to show the
|
||||||
// floor-resolution catchall (for any gift that somehow missed its expiry).
|
// 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) {
|
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 != "" {
|
if g.VoteResult != "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
opens, leaves := 0, 0
|
opens, leaves := 0, 0
|
||||||
var leaderVote string
|
var leaderVote string
|
||||||
for u, v := range g.Votes {
|
for u, v := range g.Votes {
|
||||||
@@ -221,39 +253,49 @@ func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
|
|||||||
case leaderVote == "leave":
|
case leaderVote == "leave":
|
||||||
voteResult = "left"
|
voteResult = "left"
|
||||||
default:
|
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
|
var outcome string
|
||||||
switch g.GiftType {
|
switch g.GiftType {
|
||||||
case coopGiftBasket:
|
case coopGiftBasket:
|
||||||
if voteResult == "opened" {
|
if voteResult == "opened" {
|
||||||
mod = coopGiftBasketOpened
|
perMod = coopGiftBasketOpened
|
||||||
outcome = "boost"
|
outcome = "boost"
|
||||||
} else {
|
} else {
|
||||||
mod = coopGiftBasketUnopened
|
perMod = coopGiftBasketUnopened
|
||||||
outcome = "reduction"
|
outcome = "reduction"
|
||||||
}
|
}
|
||||||
case coopGiftMimic:
|
case coopGiftMimic:
|
||||||
if voteResult == "opened" {
|
if voteResult == "opened" {
|
||||||
mod = coopGiftMimicOpened
|
perMod = coopGiftMimicOpened
|
||||||
outcome = "reduction"
|
outcome = "reduction"
|
||||||
} else {
|
} else {
|
||||||
mod = coopGiftMimicUnopened
|
perMod = coopGiftMimicUnopened
|
||||||
outcome = "boost"
|
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.VoteResult = voteResult
|
||||||
g.Outcome = outcome
|
g.Outcome = outcome
|
||||||
g.Modifier = mod
|
g.Modifier = perMod
|
||||||
_ = 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.
|
// Edit the live game-room post to show the resolved state — total swing
|
||||||
|
// reflects the full stack.
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
if gr != "" && g.PostEventID != "" {
|
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.
|
// 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) {
|
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)
|
dayGifts, _ := loadDayCoopGifts(run.ID, day)
|
||||||
for i := range dayGifts {
|
for i := range dayGifts {
|
||||||
|
if dayGifts[i].StackLeadID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if dayGifts[i].VoteResult == "" {
|
if dayGifts[i].VoteResult == "" {
|
||||||
p.resolveSingleGift(&dayGifts[i], leader)
|
p.resolveSingleGift(&dayGifts[i], leader)
|
||||||
}
|
}
|
||||||
@@ -320,16 +367,113 @@ func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day i
|
|||||||
|
|
||||||
// ── DB ──────────────────────────────────────────────────────────────────────
|
// ── 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()
|
d := db.Get()
|
||||||
expires := time.Now().UTC().Add(coopGiftWindow)
|
var leadID int
|
||||||
res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type, expires_at)
|
err := d.QueryRow(`SELECT id FROM coop_dungeon_gifts
|
||||||
VALUES (?, ?, ?, ?, ?)`, runID, string(sender), day, giftType, expires)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return nil, err
|
||||||
|
}
|
||||||
|
return loadCoopGift(leadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCoopGiftStack returns the lead and all followers (in id order) for a
|
||||||
|
// stack identified by lead_id. Used to propagate resolution + render.
|
||||||
|
func loadCoopGiftStack(leadID int) ([]CoopGift, error) {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||||
|
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||||
|
FROM coop_dungeon_gifts
|
||||||
|
WHERE id = ? OR stack_lead_id = ?
|
||||||
|
ORDER BY id ASC`, leadID, leadID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []CoopGift
|
||||||
|
for rows.Next() {
|
||||||
|
g := CoopGift{}
|
||||||
|
var sender, votesJSON string
|
||||||
|
var voteResult, outcome, postID sql.NullString
|
||||||
|
var expiresAt, appliedAt sql.NullTime
|
||||||
|
var stackLeadID sql.NullInt64
|
||||||
|
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||||
|
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g.SenderID = id.UserID(sender)
|
||||||
|
g.Votes = parseCoopVotes(votesJSON)
|
||||||
|
if voteResult.Valid {
|
||||||
|
g.VoteResult = voteResult.String
|
||||||
|
}
|
||||||
|
if outcome.Valid {
|
||||||
|
g.Outcome = outcome.String
|
||||||
|
}
|
||||||
|
if postID.Valid {
|
||||||
|
g.PostEventID = id.EventID(postID.String)
|
||||||
|
}
|
||||||
|
if expiresAt.Valid {
|
||||||
|
t := expiresAt.Time
|
||||||
|
g.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
if appliedAt.Valid {
|
||||||
|
t := appliedAt.Time
|
||||||
|
g.AppliedAt = &t
|
||||||
|
}
|
||||||
|
if stackLeadID.Valid {
|
||||||
|
v := int(stackLeadID.Int64)
|
||||||
|
g.StackLeadID = &v
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCoopGift inserts a new gift. If an unresolved same-type stack exists
|
||||||
|
// for the run+day, the new gift becomes a follower (no expires_at, no own
|
||||||
|
// post — lead's post is edited to bump the count). Otherwise a new lead is
|
||||||
|
// created with its own 6h expiry.
|
||||||
|
//
|
||||||
|
// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective
|
||||||
|
// stack lead — either itself (new lead) or the existing lead it joined.
|
||||||
|
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
existing, lerr := findCoopGiftStackLead(runID, day, giftType)
|
||||||
|
if lerr != nil {
|
||||||
|
return 0, 0, false, lerr
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
// Follower: no expires_at, no post. Inherits lead's deadline.
|
||||||
|
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
||||||
|
(run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
runID, string(sender), day, giftType, existing.ID)
|
||||||
|
if ierr != nil {
|
||||||
|
return 0, 0, false, ierr
|
||||||
}
|
}
|
||||||
id64, _ := res.LastInsertId()
|
id64, _ := res.LastInsertId()
|
||||||
return int(id64), nil
|
return int(id64), existing.ID, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New lead: own expiry, will get its own post.
|
||||||
|
expires := time.Now().UTC().Add(coopGiftWindow)
|
||||||
|
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
||||||
|
(run_id, sender_id, day, gift_type, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
runID, string(sender), day, giftType, expires)
|
||||||
|
if ierr != nil {
|
||||||
|
return 0, 0, false, ierr
|
||||||
|
}
|
||||||
|
id64, _ := res.LastInsertId()
|
||||||
|
return int(id64), int(id64), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCoopGift(giftID int) (*CoopGift, error) {
|
func loadCoopGift(giftID int) (*CoopGift, error) {
|
||||||
@@ -338,11 +482,12 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
|
|||||||
var sender, votesJSON string
|
var sender, votesJSON string
|
||||||
var voteResult, outcome, postID sql.NullString
|
var voteResult, outcome, postID sql.NullString
|
||||||
var expiresAt, appliedAt sql.NullTime
|
var expiresAt, appliedAt sql.NullTime
|
||||||
|
var stackLeadID sql.NullInt64
|
||||||
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
|
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||||
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
|
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
|
||||||
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt)
|
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -364,6 +509,7 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
|
|||||||
t := expiresAt.Time
|
t := expiresAt.Time
|
||||||
g.ExpiresAt = &t
|
g.ExpiresAt = &t
|
||||||
}
|
}
|
||||||
|
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||||
if appliedAt.Valid {
|
if appliedAt.Valid {
|
||||||
t := appliedAt.Time
|
t := appliedAt.Time
|
||||||
g.AppliedAt = &t
|
g.AppliedAt = &t
|
||||||
@@ -374,7 +520,7 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
|
|||||||
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||||
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
|
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -386,8 +532,9 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
|||||||
var sender, votesJSON string
|
var sender, votesJSON string
|
||||||
var voteResult, outcome, postID sql.NullString
|
var voteResult, outcome, postID sql.NullString
|
||||||
var expiresAt, appliedAt sql.NullTime
|
var expiresAt, appliedAt sql.NullTime
|
||||||
|
var stackLeadID sql.NullInt64
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
g.SenderID = id.UserID(sender)
|
g.SenderID = id.UserID(sender)
|
||||||
@@ -405,6 +552,7 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
|||||||
t := expiresAt.Time
|
t := expiresAt.Time
|
||||||
g.ExpiresAt = &t
|
g.ExpiresAt = &t
|
||||||
}
|
}
|
||||||
|
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||||
if appliedAt.Valid {
|
if appliedAt.Valid {
|
||||||
t := appliedAt.Time
|
t := appliedAt.Time
|
||||||
g.AppliedAt = &t
|
g.AppliedAt = &t
|
||||||
@@ -417,7 +565,7 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
|||||||
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||||
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
|
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -429,8 +577,9 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
|||||||
var sender, votesJSON string
|
var sender, votesJSON string
|
||||||
var voteResult, outcome, postID sql.NullString
|
var voteResult, outcome, postID sql.NullString
|
||||||
var expiresAt, appliedAt sql.NullTime
|
var expiresAt, appliedAt sql.NullTime
|
||||||
|
var stackLeadID sql.NullInt64
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
g.SenderID = id.UserID(sender)
|
g.SenderID = id.UserID(sender)
|
||||||
@@ -448,6 +597,7 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
|||||||
t := expiresAt.Time
|
t := expiresAt.Time
|
||||||
g.ExpiresAt = &t
|
g.ExpiresAt = &t
|
||||||
}
|
}
|
||||||
|
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||||
if appliedAt.Valid {
|
if appliedAt.Valid {
|
||||||
t := appliedAt.Time
|
t := appliedAt.Time
|
||||||
g.AppliedAt = &t
|
g.AppliedAt = &t
|
||||||
@@ -503,9 +653,10 @@ func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error
|
|||||||
func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
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
|
FROM coop_dungeon_gifts
|
||||||
WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP
|
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`)
|
ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -517,8 +668,9 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
|||||||
var sender, votesJSON string
|
var sender, votesJSON string
|
||||||
var voteResult, outcome, postID sql.NullString
|
var voteResult, outcome, postID sql.NullString
|
||||||
var expiresAt, appliedAt sql.NullTime
|
var expiresAt, appliedAt sql.NullTime
|
||||||
|
var stackLeadID sql.NullInt64
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
g.SenderID = id.UserID(sender)
|
g.SenderID = id.UserID(sender)
|
||||||
@@ -530,6 +682,10 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
|||||||
t := expiresAt.Time
|
t := expiresAt.Time
|
||||||
g.ExpiresAt = &t
|
g.ExpiresAt = &t
|
||||||
}
|
}
|
||||||
|
if stackLeadID.Valid {
|
||||||
|
v := int(stackLeadID.Int64)
|
||||||
|
g.StackLeadID = &v
|
||||||
|
}
|
||||||
out = append(out, g)
|
out = append(out, g)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
@@ -541,7 +697,7 @@ func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
|||||||
func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
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
|
FROM coop_dungeon_gifts
|
||||||
WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL
|
WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL
|
||||||
ORDER BY id`, runID)
|
ORDER BY id`, runID)
|
||||||
@@ -555,8 +711,9 @@ func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
|||||||
var sender, votesJSON string
|
var sender, votesJSON string
|
||||||
var voteResult, outcome, postID sql.NullString
|
var voteResult, outcome, postID sql.NullString
|
||||||
var expiresAt, appliedAt sql.NullTime
|
var expiresAt, appliedAt sql.NullTime
|
||||||
|
var stackLeadID sql.NullInt64
|
||||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
g.SenderID = id.UserID(sender)
|
g.SenderID = id.UserID(sender)
|
||||||
@@ -567,6 +724,10 @@ func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
|||||||
if outcome.Valid {
|
if outcome.Valid {
|
||||||
g.Outcome = outcome.String
|
g.Outcome = outcome.String
|
||||||
}
|
}
|
||||||
|
if stackLeadID.Valid {
|
||||||
|
v := int(stackLeadID.Int64)
|
||||||
|
g.StackLeadID = &v
|
||||||
|
}
|
||||||
out = append(out, g)
|
out = append(out, g)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
@@ -588,12 +749,20 @@ func markCoopGiftApplied(giftID int) (bool, error) {
|
|||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
// ── 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 {
|
func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, members []CoopMember) string {
|
||||||
var sb strings.Builder
|
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}"
|
// Stack size = lead + followers
|
||||||
// renders with real numbers.
|
stack, _ := loadCoopGiftStack(gift.ID)
|
||||||
|
stackSize := len(stack)
|
||||||
|
if stackSize == 0 {
|
||||||
|
stackSize = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tally on the lead's votes
|
||||||
opens, leaves := 0, 0
|
opens, leaves := 0, 0
|
||||||
for _, v := range gift.Votes {
|
for _, v := range gift.Votes {
|
||||||
if v == "open" {
|
if v == "open" {
|
||||||
@@ -604,10 +773,16 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member
|
|||||||
}
|
}
|
||||||
leaderName := coopDisplayName(p, run.LeaderID)
|
leaderName := coopDisplayName(p, run.LeaderID)
|
||||||
|
|
||||||
// Pick a flavor entry and substitute its template fields. Two {count}
|
header := fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**", gift.ID, run.ID, gift.Day)
|
||||||
// placeholders are positional: first = opens, second = leaves.
|
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 {
|
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(opens), 1)
|
||||||
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1)
|
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1)
|
||||||
flavor = strings.ReplaceAll(flavor, "{leader}", leaderName)
|
flavor = strings.ReplaceAll(flavor, "{leader}", leaderName)
|
||||||
@@ -615,7 +790,14 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member
|
|||||||
sb.WriteString("\n\n")
|
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 := ""
|
closesIn := ""
|
||||||
if gift.ExpiresAt != nil {
|
if gift.ExpiresAt != nil {
|
||||||
remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute)
|
remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute)
|
||||||
@@ -625,6 +807,9 @@ func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, member
|
|||||||
closesIn = " · voting closing"
|
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 + ".")
|
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".")
|
||||||
|
|
||||||
awaiting := 0
|
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
|
// renderCoopGiftResolvedPost replaces the live vote post when a gift's
|
||||||
// voting window closes. Reveals the type and resolved modifier — the type
|
// voting window closes. Total modifier = stackSize × per-gift mod.
|
||||||
// stays hidden during voting but is visible after.
|
func renderCoopGiftResolvedPost(g *CoopGift, stackSize, totalMod int) string {
|
||||||
func renderCoopGiftResolvedPost(g *CoopGift) string {
|
|
||||||
icon := "📦"
|
|
||||||
verb := "left"
|
verb := "left"
|
||||||
if g.VoteResult == "opened" {
|
if g.VoteResult == "opened" {
|
||||||
verb = "opened"
|
verb = "opened"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.",
|
if stackSize > 1 {
|
||||||
icon, g.ID, titleCaseWord(g.GiftType), verb, g.Modifier)
|
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 {
|
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."
|
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 {
|
func renderCoopGiftLog(p *AdventurePlugin, runID int) string {
|
||||||
gifts, err := loadAllCoopGifts(runID)
|
gifts, err := loadAllCoopGifts(runID)
|
||||||
if err != nil || len(gifts) == 0 {
|
if err != nil || len(gifts) == 0 {
|
||||||
return ""
|
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
|
var sb strings.Builder
|
||||||
sb.WriteString("📦 Gift Log:\n")
|
sb.WriteString("📦 Gift Log:\n")
|
||||||
for _, g := range gifts {
|
for _, k := range order {
|
||||||
result := g.VoteResult
|
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 == "" {
|
if result == "" {
|
||||||
result = "unresolved"
|
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",
|
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()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,22 +217,43 @@ func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) st
|
|||||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending gift list with per-gift countdowns. Voting windows expire
|
// Pending stacks with per-stack countdowns. Each game-room post is one
|
||||||
// independently throughout the day, so this is the canonical place to
|
// stack (lead + N followers); we show the lead's id and the stack size.
|
||||||
// see what's still open.
|
|
||||||
if run.Status == "active" {
|
if run.Status == "active" {
|
||||||
all, _ := loadAllCoopGifts(run.ID)
|
all, _ := loadAllCoopGifts(run.ID)
|
||||||
var pending []CoopGift
|
// Group unresolved gifts by lead. Followers contribute to the size
|
||||||
|
// but use the lead's expiry/votes.
|
||||||
|
type stackInfo struct {
|
||||||
|
lead CoopGift
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
stacks := map[int]*stackInfo{}
|
||||||
|
order := []int{}
|
||||||
for _, g := range all {
|
for _, g := range all {
|
||||||
if g.VoteResult == "" {
|
if g.VoteResult != "" {
|
||||||
pending = append(pending, g)
|
continue
|
||||||
}
|
}
|
||||||
|
leadID := g.ID
|
||||||
|
if g.StackLeadID != nil {
|
||||||
|
leadID = *g.StackLeadID
|
||||||
}
|
}
|
||||||
if len(pending) > 0 {
|
s, ok := stacks[leadID]
|
||||||
|
if !ok {
|
||||||
|
s = &stackInfo{}
|
||||||
|
stacks[leadID] = s
|
||||||
|
order = append(order, leadID)
|
||||||
|
}
|
||||||
|
if g.StackLeadID == nil {
|
||||||
|
s.lead = g
|
||||||
|
}
|
||||||
|
s.size++
|
||||||
|
}
|
||||||
|
if len(stacks) > 0 {
|
||||||
sb.WriteString("\n\nPending gifts:\n")
|
sb.WriteString("\n\nPending gifts:\n")
|
||||||
for _, g := range pending {
|
for _, leadID := range order {
|
||||||
|
s := stacks[leadID]
|
||||||
opens, leaves := 0, 0
|
opens, leaves := 0, 0
|
||||||
for _, v := range g.Votes {
|
for _, v := range s.lead.Votes {
|
||||||
if v == "open" {
|
if v == "open" {
|
||||||
opens++
|
opens++
|
||||||
} else if v == "leave" {
|
} else if v == "leave" {
|
||||||
@@ -240,16 +261,20 @@ func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
countdown := "—"
|
countdown := "—"
|
||||||
if g.ExpiresAt != nil {
|
if s.lead.ExpiresAt != nil {
|
||||||
remaining := time.Until(*g.ExpiresAt).Truncate(time.Minute)
|
remaining := time.Until(*s.lead.ExpiresAt).Truncate(time.Minute)
|
||||||
if remaining > 0 {
|
if remaining > 0 {
|
||||||
countdown = remaining.String()
|
countdown = remaining.String()
|
||||||
} else {
|
} else {
|
||||||
countdown = "closing"
|
countdown = "closing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf(" #%d (day %d) — open %d / leave %d — closes in %s\n",
|
stackLabel := fmt.Sprintf("#%d", s.lead.ID)
|
||||||
g.ID, g.Day, opens, leaves, countdown))
|
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}.",
|
"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 ────────────────────────────────────────────────────
|
// ── GIFT OUTCOME NARRATION ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Care basket opened (good outcome):
|
// Care basket opened (good outcome):
|
||||||
|
|||||||
Reference in New Issue
Block a user