mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Coop: per-gift voting timers (6h) — gifts resolve throughout the day
Replace the single daily-tick gift resolution with independent per-gift expiries. Each gift now has its own 6h voting window; once that elapses, the votes are tallied, the sender gets DM feedback, and the live game-room post is edited to reveal the type and resolved modifier. The modifier sits on the run waiting for the next floor resolution to merge it in. Effect: - Gifts fire continuously throughout the day rather than all at once at 08:00 UTC, surfacing sender activity in real time. - Senders get fast feedback (~6h instead of waiting for the next daily tick). - Floor resolution becomes purely "sum pending modifiers from already- resolved gifts" — cleaner separation of concerns. - Atomic per-gift apply via markCoopGiftApplied (UPDATE...WHERE applied_at IS NULL) prevents double-application on retry. Schema additions: - coop_dungeon_gifts.expires_at (when voting closes) - coop_dungeon_gifts.applied_at (when modifier merged into a floor) - Migration entries provided. Defensive backstop: floor resolution still force-resolves any gifts whose expiry was missed (e.g., bot down during the timer window). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
@@ -36,19 +37,26 @@ const (
|
||||
const (
|
||||
coopGiftBasket = "basket"
|
||||
coopGiftMimic = "mimic"
|
||||
|
||||
// Per-gift voting window. After this elapses, the gift's votes are
|
||||
// tallied independently of the daily floor tick. Senders get DM feedback
|
||||
// at expiry; the modifier sits on the run until the next floor resolves.
|
||||
coopGiftWindow = 6 * time.Hour
|
||||
)
|
||||
|
||||
type CoopGift struct {
|
||||
ID int
|
||||
RunID int
|
||||
SenderID id.UserID
|
||||
Day int
|
||||
GiftType string
|
||||
Votes map[id.UserID]string // "open" or "leave"
|
||||
VoteResult string // "opened" / "left" / ""
|
||||
Outcome string // "boost" / "reduction" / ""
|
||||
Modifier int
|
||||
PostEventID id.EventID
|
||||
ID int
|
||||
RunID int
|
||||
SenderID id.UserID
|
||||
Day int
|
||||
GiftType string
|
||||
Votes map[id.UserID]string // "open" or "leave"
|
||||
VoteResult string // "opened" / "left" / ""
|
||||
Outcome string // "boost" / "reduction" / ""
|
||||
Modifier int
|
||||
PostEventID id.EventID
|
||||
ExpiresAt *time.Time // voting closes
|
||||
AppliedAt *time.Time // modifier merged into a floor roll
|
||||
}
|
||||
|
||||
// ── Command: send a gift ────────────────────────────────────────────────────
|
||||
@@ -181,71 +189,128 @@ func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) er
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
// coopResolveGifts is called inside coopResolveFloor to tally and apply each
|
||||
// unresolved gift for the current day. Returns the total modifier and a brief
|
||||
// summary line.
|
||||
func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) {
|
||||
gifts, err := loadDayCoopGifts(run.ID, day)
|
||||
if err != nil || len(gifts) == 0 {
|
||||
return 0, ""
|
||||
// resolveSingleGift tallies a single gift's votes, persists the outcome, and
|
||||
// DMs the sender. Used by both the per-minute expiry ticker and the
|
||||
// floor-resolution catchall (for any gift that somehow missed its expiry).
|
||||
//
|
||||
// Idempotent: returns early if vote_result is already set.
|
||||
func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
|
||||
if g.VoteResult != "" {
|
||||
return
|
||||
}
|
||||
totalMod := 0
|
||||
var lines []string
|
||||
for _, g := range gifts {
|
||||
if g.VoteResult != "" {
|
||||
opens, leaves := 0, 0
|
||||
var leaderVote string
|
||||
for u, v := range g.Votes {
|
||||
if v == "open" {
|
||||
opens++
|
||||
} else if v == "leave" {
|
||||
leaves++
|
||||
}
|
||||
if u == leader {
|
||||
leaderVote = v
|
||||
}
|
||||
}
|
||||
var voteResult string
|
||||
switch {
|
||||
case opens > leaves:
|
||||
voteResult = "opened"
|
||||
case leaves > opens:
|
||||
voteResult = "left"
|
||||
case leaderVote == "open":
|
||||
voteResult = "opened"
|
||||
case leaderVote == "leave":
|
||||
voteResult = "left"
|
||||
default:
|
||||
voteResult = "left" // default for full abstention
|
||||
}
|
||||
|
||||
var mod int
|
||||
var outcome string
|
||||
switch g.GiftType {
|
||||
case coopGiftBasket:
|
||||
if voteResult == "opened" {
|
||||
mod = coopGiftBasketOpened
|
||||
outcome = "boost"
|
||||
} else {
|
||||
mod = coopGiftBasketUnopened
|
||||
outcome = "reduction"
|
||||
}
|
||||
case coopGiftMimic:
|
||||
if voteResult == "opened" {
|
||||
mod = coopGiftMimicOpened
|
||||
outcome = "reduction"
|
||||
} else {
|
||||
mod = coopGiftMimicUnopened
|
||||
outcome = "boost"
|
||||
}
|
||||
}
|
||||
g.VoteResult = voteResult
|
||||
g.Outcome = outcome
|
||||
g.Modifier = mod
|
||||
_ = resolveCoopGift(g.ID, voteResult, outcome, mod)
|
||||
_ = p.SendDM(g.SenderID, renderCoopGiftSenderDM(g, voteResult, mod))
|
||||
|
||||
// Edit the live game-room post to show the resolved state.
|
||||
gr := gamesRoom()
|
||||
if gr != "" && g.PostEventID != "" {
|
||||
_ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g))
|
||||
}
|
||||
}
|
||||
|
||||
// coopProcessGiftExpiries runs each ticker minute, resolving any gifts whose
|
||||
// voting window has elapsed. Modifiers are recorded but not yet applied to a
|
||||
// floor — they wait for the next floor resolution.
|
||||
func (p *AdventurePlugin) coopProcessGiftExpiries() {
|
||||
gifts, err := loadExpiredUnresolvedGifts()
|
||||
if err != nil {
|
||||
slog.Error("coop: load expired gifts", "err", err)
|
||||
return
|
||||
}
|
||||
for i := range gifts {
|
||||
g := &gifts[i]
|
||||
run, _ := loadCoopRun(g.RunID)
|
||||
if run == nil || run.Status != "active" {
|
||||
// Run gone or already terminal — still mark the gift resolved so
|
||||
// it stops cluttering the expiry query.
|
||||
_ = resolveCoopGift(g.ID, "left", "reduction", 0)
|
||||
continue
|
||||
}
|
||||
opens, leaves := 0, 0
|
||||
var leaderVote string
|
||||
for u, v := range g.Votes {
|
||||
if v == "open" {
|
||||
opens++
|
||||
} else if v == "leave" {
|
||||
leaves++
|
||||
}
|
||||
if u == leader {
|
||||
leaderVote = v
|
||||
}
|
||||
}
|
||||
var voteResult string
|
||||
switch {
|
||||
case opens > leaves:
|
||||
voteResult = "opened"
|
||||
case leaves > opens:
|
||||
voteResult = "left"
|
||||
case leaderVote == "open":
|
||||
voteResult = "opened"
|
||||
case leaderVote == "leave":
|
||||
voteResult = "left"
|
||||
default:
|
||||
voteResult = "left" // default for full abstention
|
||||
}
|
||||
p.resolveSingleGift(g, run.LeaderID)
|
||||
}
|
||||
}
|
||||
|
||||
var mod int
|
||||
var outcome string
|
||||
switch g.GiftType {
|
||||
case coopGiftBasket:
|
||||
if voteResult == "opened" {
|
||||
mod = coopGiftBasketOpened
|
||||
outcome = "boost"
|
||||
} else {
|
||||
mod = coopGiftBasketUnopened
|
||||
outcome = "reduction"
|
||||
}
|
||||
case coopGiftMimic:
|
||||
if voteResult == "opened" {
|
||||
mod = coopGiftMimicOpened
|
||||
outcome = "reduction"
|
||||
} else {
|
||||
mod = coopGiftMimicUnopened
|
||||
outcome = "boost"
|
||||
}
|
||||
// coopResolveGifts is called inside coopResolveFloor. Two responsibilities:
|
||||
//
|
||||
// 1. Catchall: force-resolve any gifts on this day that still have no
|
||||
// vote_result (the timer ticker should normally have caught them, but
|
||||
// this is the defensive backstop, e.g., bot was down).
|
||||
// 2. Apply: sum modifiers from any resolved-but-not-yet-applied gifts on
|
||||
// this run and atomically claim them via markCoopGiftApplied so the
|
||||
// same modifier is never double-applied across retries.
|
||||
//
|
||||
// Returns total modifier and a summary line for the daily-result post.
|
||||
func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) {
|
||||
// Catchall: any unresolved gifts for this day get force-resolved now.
|
||||
dayGifts, _ := loadDayCoopGifts(run.ID, day)
|
||||
for i := range dayGifts {
|
||||
if dayGifts[i].VoteResult == "" {
|
||||
p.resolveSingleGift(&dayGifts[i], leader)
|
||||
}
|
||||
totalMod += mod
|
||||
_ = resolveCoopGift(g.ID, voteResult, outcome, mod)
|
||||
}
|
||||
|
||||
// Apply: pending resolved gifts (any day, this run) get their modifier
|
||||
// merged into the floor roll.
|
||||
pending, _ := loadResolvedUnappliedGifts(run.ID)
|
||||
totalMod := 0
|
||||
var lines []string
|
||||
for _, g := range pending {
|
||||
claimed, err := markCoopGiftApplied(g.ID)
|
||||
if err != nil || !claimed {
|
||||
continue
|
||||
}
|
||||
totalMod += g.Modifier
|
||||
lines = append(lines, fmt.Sprintf(" %s gift from %s — %s → %+d%%",
|
||||
titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), voteResult, mod))
|
||||
_ = p.SendDM(g.SenderID, renderCoopGiftSenderDM(&g, voteResult, mod))
|
||||
titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), g.VoteResult, g.Modifier))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return 0, ""
|
||||
@@ -257,8 +322,9 @@ func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day i
|
||||
|
||||
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (int, error) {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type)
|
||||
VALUES (?, ?, ?, ?)`, runID, string(sender), day, giftType)
|
||||
expires := time.Now().UTC().Add(coopGiftWindow)
|
||||
res, err := d.Exec(`INSERT INTO coop_dungeon_gifts (run_id, sender_id, day, gift_type, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`, runID, string(sender), day, giftType, expires)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -271,11 +337,12 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
|
||||
g := &CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
|
||||
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID)
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -293,13 +360,21 @@ func loadCoopGift(giftID int) (*CoopGift, error) {
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -310,8 +385,9 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID); err != nil {
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
@@ -325,6 +401,14 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -333,7 +417,7 @@ func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -344,8 +428,9 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID); err != nil {
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
@@ -356,6 +441,17 @@ func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -401,6 +497,95 @@ func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error
|
||||
return err
|
||||
}
|
||||
|
||||
// loadExpiredUnresolvedGifts returns gifts whose voting window has elapsed
|
||||
// but whose votes haven't yet been tallied. Driven by the per-minute
|
||||
// expiry ticker.
|
||||
func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadResolvedUnappliedGifts returns gifts that have been tallied but whose
|
||||
// modifiers have not yet been merged into a floor success roll. Used by
|
||||
// floor resolution to sum and apply pending gift modifiers.
|
||||
func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL
|
||||
ORDER BY id`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// markCoopGiftApplied claims a gift's modifier as merged into a floor roll.
|
||||
// Idempotent — only the first call sets applied_at; subsequent calls return
|
||||
// false (claimed=false) so the same modifier is never double-applied.
|
||||
func markCoopGiftApplied(giftID int) (bool, error) {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`UPDATE coop_dungeon_gifts SET applied_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND applied_at IS NULL`, giftID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderCoopGiftPost(run *CoopRun, gift *CoopGift, members []CoopMember) string {
|
||||
@@ -414,7 +599,16 @@ func renderCoopGiftPost(run *CoopRun, gift *CoopGift, members []CoopMember) stri
|
||||
sb.WriteString(flavor)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`. Resolved at next daily tick.\n\n")
|
||||
closesIn := ""
|
||||
if gift.ExpiresAt != nil {
|
||||
remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute)
|
||||
if remaining > 0 {
|
||||
closesIn = fmt.Sprintf(" · voting closes in %s", remaining)
|
||||
} else {
|
||||
closesIn = " · voting closing"
|
||||
}
|
||||
}
|
||||
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".\n\n")
|
||||
opens, leaves := 0, 0
|
||||
for _, v := range gift.Votes {
|
||||
if v == "open" {
|
||||
@@ -436,6 +630,19 @@ func renderCoopGiftPost(run *CoopRun, gift *CoopGift, members []CoopMember) stri
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderCoopGiftResolvedPost replaces the live vote post when a gift's
|
||||
// voting window closes. Reveals the type and resolved modifier — the type
|
||||
// stays hidden during voting but is visible after.
|
||||
func renderCoopGiftResolvedPost(g *CoopGift) string {
|
||||
icon := "📦"
|
||||
verb := "left"
|
||||
if g.VoteResult == "opened" {
|
||||
verb = "opened"
|
||||
}
|
||||
return fmt.Sprintf("%s **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.",
|
||||
icon, g.ID, titleCaseWord(g.GiftType), verb, g.Modifier)
|
||||
}
|
||||
|
||||
func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string {
|
||||
pickFrom := func(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
|
||||
@@ -30,6 +30,11 @@ func (p *AdventurePlugin) coopTicker() {
|
||||
// Locks: every minute.
|
||||
p.coopProcessLocks()
|
||||
|
||||
// Gift expiries: every minute. Resolves any gift whose individual
|
||||
// 6h voting window has elapsed. Modifier sits on the run until the
|
||||
// next floor resolution merges it.
|
||||
p.coopProcessGiftExpiries()
|
||||
|
||||
// Resolutions: once per day at morningHour:00 UTC.
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
|
||||
Reference in New Issue
Block a user