@@ -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 {
_ = save CoopGiftPostID ( giftID , postID )
if isNewLead {
gift , _ : = loadCoopGift ( giftID )
postID , perr : = p . SendMessageID ( gr , render CoopGiftPost( 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" {
m od = coopGiftBasketOpened
perM od = coopGiftBasketOpened
outcome = "boost"
} else {
m od = coopGiftBasketUnopened
perM od = coopGiftBasketUnopened
outcome = "reduction"
}
case coopGiftMimic :
if voteResult == "opened" {
m od = coopGiftMimicOpened
perM od = coopGiftMimicOpened
outcome = "reduction"
} else {
m od = coopGiftMimicUnopened
perM od = 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 = m od
_ = resolveCoopGift ( g . ID , voteResult , outcome , mod )
_ = p . SendDM ( g . SenderID , renderCoopGiftSenderDM ( g , voteResult , mod ) )
g . Modifier = perM od
// 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 gift s for this day get force-resolved now.
// Catchall: any unresolved lead s 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 , giftT ype, 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_t ype = ?
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 loadDay CoopGifts ( 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 . D ay, coopDisplayName ( p , g . SenderID ) , titleCaseWord ( g . GiftType ) , result , g . Modifier ) )
k . d ay, strings . Join ( names , " + " ) , typeLabel , result , totalMod ) )
}
return sb . String ( )
}