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>
Previous guard skipped resolution if lock_date == today_date, pushing
first-day windows to 24-32h regardless of when in the UTC day the lock
fired. With per-minute lock checks, the original same-tick race no longer
exists, so the date guard was just adding latency.
New guard: at least 12h since lock. Late-night locks still get >12h
funding/voting windows; early-morning locks get 24h+ as before. No effect
on the existing run #1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Adds Base.PinEvent / Base.UnpinEvent helpers (m.room.pinned_events state)
that read-modify-write the existing pin list — idempotent on both sides.
The bot needs power level for state events; failures are logged but not
fatal.
Schema: add coop_dungeon_runs.invite_post_id to remember which event to
unpin. Migration entry included.
Wired:
- !coop start: pin the invite post in the games room
- !coop cancel: unpin before posting cancellation
- coopProcessLocks (auto-lock or auto-cancel): unpin before lock/expiry post
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Combat actions are only deducted at lock, never at start or join, so
there's nothing to refund when an unjoined invite expires. Just say so.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit pass addressed concurrency races, crash-recovery double-pay risks, and
non-determinism in tie tallying.
CRITICAL — concurrency
- coopResolveFloor now acquires advUserLock for every party member (sorted by
UserID) before mutating state, so concurrent !coop fund / vote / giftvote
commands serialize against the scheduler.
CRITICAL — crash idempotency
- Add coop_dungeon_runs.last_resolved_day and coop_dungeon_members.member_payout
columns (with migration entries) for crash-resume tracking.
- Skip resolution only if last_resolved_day >= day AND status is terminal;
otherwise re-enter and finish via idempotent operations.
- On resume detection (event already has outcome), reuse the saved roll
instead of re-rolling — prevents different outcomes on retry.
- claimCoopMemberPayout / claimCoopBetPayout: atomic UPDATE...WHERE col IS NULL
pattern. Each member/bet can only be paid once; retries are silent no-ops.
- createCoopEvent now INSERT OR IGNORE — idempotent on retry.
- last_resolved_day is set as the FINAL write per floor.
HIGH — bugs
- Lazy-create floor event in resolver if missing — covers crash window
between lockCoopRun and the original createCoopEvent.
- coopTallyVote sorts tied winners alphabetically before fallback (was
non-deterministic via Go map iteration).
- Removed dead day-1 wipe combat-action refund block — refund check was
always false because midnight reset clears state before resolution.
LOW
- Log slog.Error on malformed JSON in parseCoopVotes / parseCoopFundingMap
instead of silently dropping data.
Tests
- Tally test extended to assert determinism across 50 runs (catches map-iteration
leaks).
- Added coverage for new tie-with-leader-vote-outside-winners case.
- Idempotency-guard sanity check on LastResolvedDay vs CurrentDay.
Race detector: clean. Full suite: green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-day party runs with funding decisions, TwinBee-narrated floor events,
spectator parimutuel betting, basket/mimic gift system, and weighted-roll
item distribution including masterwork drops at T4-T5.
Schema (5 tables, 2 fewer than spec by computing helpfulness on-the-fly and
skipping the loot-pending state):
- coop_dungeon_runs / _members (daily funding as JSON column)
- coop_dungeon_events (votes as JSON, used to derive TwinBee helpfulness)
- coop_dungeon_bets / _gifts
Mechanics:
- 2-4 player parties, 24h invite window, locks consume one combat action
- Per-floor success roll: base + sum(funding) + level/pet bonuses + event
vote modifier + active gift modifiers, clamped to 5..95
- Funding tiers (none/min/std/agg/all_in) with liability cap at +8% for
under-leveled players
- TwinBee narrates events from existing flavor pool; 20 authored events with
per-option modifiers and embedded recommendation
- Parimutuel betting with 10% rake; odds line shown on lock/daily/status posts
- Gift modifiers symmetric at 50/50 sender mix so no dominant strategy
- Item drops on success via weight-by-contribution roll; T4 25% / T5 100%
masterwork chance (random pick from existing T4/T5 defs)
Balance via Monte Carlo (coop_dungeon_balance_test.go):
- All tiers exceed 1.5x solo daily income at average party profile
- Optimal funding strategy walks up tiers correctly (Min/Std/Std/Mixed/Agg)
- All-In stays -EV at every tier (boost lever, not optimal play)
Tests: parsing, liability cap, JSON roundtrips, vote tally with leader
tiebreak, event meta consistency, parimutuel payouts, gift EV symmetry,
weighted-roll distribution (Monte Carlo), masterwork tier gates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>