Coop: split lock checks (per-minute) from resolution (daily)

Locks were gated behind the same once-per-day 08:00 UTC tick as floor
resolution. A run created after morningHour would wait until next day's
tick to be eligible — meaning actual lock latency could be 24-48h, not the
24h advertised. Worse, a run created late on Day N missed the Day N+1 tick
(too early) AND the Day N+2 tick happens 48h after creation.

Fix: lock checks fire on every ticker minute (cheap timestamp scan over
typically 0-5 open runs). Resolution stays daily, gated by JobCompleted.

Real symptom that prompted this: Run #1 created 17:17 UTC, locked never
fired because the morning tick at 08:00 UTC the next day saw the run as
not-yet-eligible (only 15h elapsed), then marked the daily job complete
before the actual 24h elapsed at 17:17 UTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-27 17:35:37 -07:00
parent 9d949dc649
commit ecca66da58

View File

@@ -14,20 +14,24 @@ import (
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
// coopTicker fires once per UTC day to: // coopTicker has two cadences:
// 1. Lock open invites whose 24h window has elapsed.
// 2. For each active run, resolve the day's floor and advance/complete.
// //
// Reuses the daily_prefetch job-completion guard so a bot restart on the same // 1. Lock checks fire every minute. They're cheap (one timestamp comparison
// UTC day does not double-process. // per open run) and a 24h invite window is poorly served by a 24h tick —
// a run started after 08:00 UTC would otherwise wait 30-48h to lock.
// 2. Floor resolution fires once per UTC day at morningHour, gated by the
// daily_prefetch JobCompleted guard. Resolution is the heavy step that
// advances days, distributes rewards, and posts to the room.
func (p *AdventurePlugin) coopTicker() { func (p *AdventurePlugin) coopTicker() {
ticker := time.NewTicker(1 * time.Minute) ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
// Locks: every minute.
p.coopProcessLocks()
// Resolutions: once per day at morningHour:00 UTC.
now := time.Now().UTC() now := time.Now().UTC()
// Run at the same minute as the morning DM (08:00 UTC) — same cadence as
// the rest of the adventure system.
if now.Hour() != p.morningHour || now.Minute() != 0 { if now.Hour() != p.morningHour || now.Minute() != 0 {
continue continue
} }
@@ -36,8 +40,7 @@ func (p *AdventurePlugin) coopTicker() {
if db.JobCompleted(jobName, dateKey) { if db.JobCompleted(jobName, dateKey) {
continue continue
} }
slog.Info("coop: daily tick") slog.Info("coop: daily tick — resolving active runs")
p.coopProcessLocks()
p.coopProcessActiveRuns() p.coopProcessActiveRuns()
db.MarkJobCompleted(jobName, dateKey) db.MarkJobCompleted(jobName, dateKey)
} }