Skip to content

new boom mechanic to try out where participant point gain is randomised - #3

Merged
zkrige merged 16 commits into
lekkertech:masterfrom
Jesse-Heath:feature-RandomisePointDistribution
Sep 4, 2026
Merged

new boom mechanic to try out where participant point gain is randomised#3
zkrige merged 16 commits into
lekkertech:masterfrom
Jesse-Heath:feature-RandomisePointDistribution

Conversation

@Jesse-Heath

Copy link
Copy Markdown
Contributor

No description provided.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 1f56a9fc-6d5e-4074-8e4c-71ad5072b21c


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@zkrige zkrige left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice mechanic, and the ledger/idempotency design is solid. Seven things below, first two are deploy blockers.

Tests 45/45, tsc --noEmit clean on 1e6dd87.

Comment thread src/features/boom/store.ts Outdated
// Stamp the random-scoring cutover on first use so every date already in the ledger keeps its
// legacy 3-2-1 scoring and is never retroactively re-assigned random points.
if (!this.data.random_scoring_from) {
this.data.random_scoring_from = DateTime.now().setZone(TZ).toISODate()!;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: cutover stamped as today, so deploying any time after noon on a day that already scored wipes and re-rolls it.

That date is >= from, so scoreFor returns [] and the points vanish from weeklyTotals. The 30s sweep then re-resolves it via duePending and assigns new random values that contradict the podium already in the channel.

Probe against a store holding today's legacy 3-2-1 podium + daily_announced:

totals BEFORE: []
duePending:    [{date:'2026-08-28',game:'boom'}, {...,game:'hadeda'}]
totals AFTER:  U1:4  U2:4  U3:4

Deploy on a Friday afternoon and the crown disagrees with the posted message. Stamp tomorrow's date instead.

Comment thread src/features/boom/store.ts Outdated
* Date+game pairs that have entries and a closed tally window but no awards yet — work the
* in-process timers missed (e.g. the bot restarted mid-window).
*/
duePending(nowMs = Date.now()): Array<{ date: string; game: Game; channel_id: string }> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same trigger as the cutover issue: duePending doesn't exclude dates with daily_announced.

alreadySettled is false (awards empty), so closeGame re-applies medal reactions to a day the old build already medalled. Skipping announced dates here fixes both.

Comment thread src/features/boom/index.ts Outdated
}
}

await announceDay(client, date, logger);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed chat.postMessage permanently loses the day's results and the Friday crown.

resolveGame flushes awards before the post, so once all games are resolved duePending returns nothing forever and nothing re-enters announceDay. A transient rate-limit at 12:05 means markDailyAnnounced never runs and the day is silently skipped.

On master the announce block sat in the message handler and got retried by the next message. Suggest a pending-announce check driven by the sweep.

Comment thread src/features/boom/index.ts Outdated
const sweepClient = (app as any).client;
if (sweepClient) {
const sweep = setInterval(() => {
closeDueWindows(sweepClient).catch(() => {});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweep passes no logger, so logger?.error?. (l.94) and logger?.warn?. (l.111) are no-ops and .catch(() => {}) eats the rest.

The restart-recovery path, the one most likely to hit a missing channel or a Slack failure, fails completely silently. Pass app.logger.

Comment thread src/features/boom/index.ts Outdated
// Compare on the message's own ts (full sub-second precision) so a slow delivery of a
// message that was sent inside the window still counts.
const tsMs = Math.round(Number(tsStr) * 1000);
const tooLate = closesAt != null && Number.isFinite(tsMs) && tsMs > closesAt;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slow-delivery grace this refines is unreachable.

The timer resolves the game at exactly closesAt, so a message sent 12:04:59 and delivered 12:05:02 hits db.isResolved(...) first and is clowned regardless of its own ts. Either resolve after a short grace period, or accept an entry whose ts <= closesAt even when resolved.

Comment thread src/features/boom/index.ts Outdated
const neededGames: Game[] = weekday === 3 ? ['boom', 'hadeda', 'wednesday'] : ['boom', 'hadeda'];
const neededGames = neededGamesForDate(date);

// Settle any window whose deadline passed while no timer was live (e.g. after a restart).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new await yields before the entryFor duplicate check below.

Two messages from the same user delivered in the same tick both see priorEntry === null, so both incrementCount, both addPlacement, and both get a check mark. counts[date][game] then exceeds the entrant count, contradicting docs/OPERATIONS.md ("counts[date][game] therefore equals the number of entrants").

* How long a game's tally window stays open, measured from its first valid entry.
* When it closes, every unique entrant is given a unique random point value in 1..n.
*/
export const ENTRY_WINDOW_MS = (() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tally window can outlive the noon window: a first :hadeda-boom: at 12:57 opens a window closing 13:02, but anyone posting at 13:00+ is clowned by the !inWindow guard first.

README and docs/OPERATIONS.md both describe the tally window as authoritative. Intended, or should the window be clamped to 12:59:59?

@zkrige

zkrige commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Good mechanic, but it needs a rebase and three fixes before I can merge.

  • Rebase first: base is 38d7d3b, master has moved 3 commits and all of index.ts/rules.ts/store.ts conflict. a247797 and d99db92 rewrite the same functions you did, so merging as-is reverts them.
  • index.ts:148: a game with zero entrants never resolves, so announceDay stalls. Friday with boom entrants and no hadeda = no results post, no crown.
  • store.ts:163: random_scoring_from is stamped tomorrow but the legacy announce path is deleted, so deploy day posts nothing at all. Verified with a repro.
  • store.ts:470: duePending is unbounded and closeGame bypasses RETRY_DAYS, so a 3-week-old leftover posts its podium into the channel.
  • Three smaller ones (3s grace vs the 15s master settled on, dead !inWindow branch, catchUp on every message) I can walk through if useful.

@zkrige zkrige left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The test coverage on this is genuinely thorough (tsc clean, 70/70 pass). Five issues, one behavioural.

src/features/boom/store.ts:462 — the first entrant of a day is exempt from the entry deadline. duePending skips any date where !hasAnyEntry(date), so a lone late entrant is still recorded, and scheduleSettle then computes a 0ms delay and settles on the next tick. A second player one second behind hits isResolved and gets :clown_face:. Identical circumstances, opposite outcomes, decided by macrotask ordering.

src/features/boom/store.ts:426resolveGame's deadline guard only covers the empty case (!entrants.length && nowMs < windowSettlesAtMs), so a game with entrants resolves at any nowMs, including mid-window. Every current caller is gated, so it is latent, but if nowMs lands just before windowSettlesAtMs (clock step-back, early libuv timer) the played games resolve and the unplayed ones do not, so announceDay can never fire and settleDay has already deleted the per-date timer.

src/features/boom/index.ts:282inWindow changed from inNoonWindow to inEntryWindow, but the !inWindow clown still runs before the !isWorkday branch at 290. A 💥 posted on a Saturday at 12:30 used to get "Boom isn't played today"; it now gets :clown_face: and no explanation. README.md and docs/TESTING.md in this diff still promise the notice.

.env.exampleBOOM_ANNOUNCE_GRACE_MS has no callers left in src/ but is still in the example; the new BOOM_ENTRY_WINDOW_MS is documented in README.md:72 and docs/CONFIG.md:16 but never appears there. An operator copying the example gets one dead knob and misses the live one.

docs/OPERATIONS.md:121 — "Legacy podium helpers (getPlacements, placementsCount, PODIUM_WEIGHTS) remain only to score dates before random_scoring_from". PODIUM_WEIGHTS is used, but getPlacements, placementsCount, addPlacement, incrementCount and getCounts have zero callers in src/ (tests only) — pre-cutover scoring goes through the private computePodium. Either delete the methods or correct the doc.

Records the decision to select the 3-2-1 podium or the randomised point
distribution once at registration, install exactly one orchestration, and stamp
each date with the mode that scored it so a flag change never re-scores a day
already played.
…uctor

addEntry and addPlacement each name the mode they record, so the Store needs no
mode argument and a historical date written through the legacy path can never be
stamped random. Records which existing store tests the deleted cutover logic
invalidates.
addEntry stamps random and addPlacement stamps legacy, so the write path names
the mechanism and the Store needs no mode argument. Removes the constructor
cutover the stamp supersedes, and restores getPodiumMessages, recordedDates and
hasAnyPlacement for the podium orchestration.
The app_mention handler is identical between the podium and randomised
orchestrations except for which catch-up it calls, so it takes that as an
argument and serves both.
index.ts becomes the router that owns the Store. The announce path moves to
random/announce.ts to keep every file under the size ceiling.
legacy/index.ts is master's orchestration with four adjustments: the export
rename, the Store passed in, import depths, and the app_mention handler replaced
by the shared one. rules.ts regains inNoonWindow, isWorkdayDate and
noonWindowEndMs, which the podium path needs.
Copied verbatim from origin/master with one line added to each cfg literal.
Editing anything else in these files would void the guarantee that rolling the
flag back reproduces current behaviour.
Adds the boom section to ARCHITECTURE.md, documents the flag and both
mode-specific knobs, and rewrites the OPERATIONS.md and TESTING.md claims about
the deleted one-way cutover.
The project had neither a linter nor a coverage reporter, so nothing enforced
method size, complexity or nesting.

eslint.config.js encodes complexity 10, max-depth 2, max-lines 300,
max-lines-per-function 25 and max-params 4. They are errors on src/env.ts and
src/features/boom, warnings elsewhere, so the untouched chat/ debt surfaces
without making the lint script permanently red.

legacy/index.ts is exempt by design: it is a verbatim copy of the pre-flag
orchestration whose equivalence is asserted by diff against master and by
master's own suite, so refactoring it would void the rollback guarantee.

@vitest/coverage-v8 is pinned to the installed vitest major.

Also deletes a dead Date.now() timing pair in src/ai/openai.ts whose value was
never logged.
Splits four oversized modules and flattens the functions that breached
complexity, length, nesting or parameter count. No observable behaviour changes:
all 101 tests pass unmodified, including master's own podium suites that assert
legacy mode still matches the pre-flag build.

store.ts 726 -> 239, split as Store extends PodiumStore extends StoreBase with
the types and pure helpers in store-data.ts. Every method keeps its name and
this-binding, so no caller changed. getPodiumMessages turned out to be a verbatim
duplicate of earliestMessagesByUser plus a slice, and computePodiumFromMessages
rebuilt the same earliest-per-user map; both collapse into earliestPerUser.

rules.ts 298 -> 188, with the JSONC scanner in jsonc.ts and the holiday loader in
holidays.ts. The rewritten stripJsonc was checked against the original over
200,000 random strings from its own alphabet, zero mismatches.

random/index.ts 257 -> 20, split into handler.ts, settle.ts, medals.ts, io.ts and
a rewritten announce.ts. The message handler was CC 28 in 52 lines, the worst
function in the codebase; it is now classify -> catchUp -> dispatch with one
guard per outcome.

env.ts loadConfig was CC 21 in 65 lines; it is now a spread of four slice
loaders, with the SLACK_BOT_TOKEN throw still ordered ahead of the BOOM_SCORING
throw. leaderboard.ts extracts the Block Kit builders; rendered output is
byte-identical.

addPlacement and addEntry took 5 parameters; the trailing ts and channel_id
collapse into one MessageRef object. That is the only signature change, and the
only edit to the frozen legacy suites beyond selecting legacy mode.

Operational reasoning removed from comments, recorded here:

- Medals are marked done only once every reaction lands, because awards are
  flushed first; a crash in between would lose them permanently. The next
  catch-up retries until they stick, and an already_reacted error is success.
- pendingMedals is snapshotted before settling, so it holds only medals orphaned
  by an earlier crash rather than ones this pass will apply.
- A day can be settled yet unannounced, because awards are flushed before the
  post; without the final catch-up loop a transient postMessage failure would
  drop the results and the crown for good.
- The crown is persisted only after Slack accepts the post, so a failure leaves
  no record of a crown nobody saw.
- A day older than the retry window stays settled but silent: posting a
  three-week-old podium is worse than never posting it.
- The announcing set stops a timer firing mid-await from double-posting.
- One settle timer per date, because the window is fixed at 12:00-12:05 local, so
  every game for a date settles at the same instant and the deadline never moves.
- Needed games nobody entered settle empty, so a day never stalls on an unplayed
  one.
- The weekend notice is once per date, not once per poster.
- Out-of-window messages are clowned before any store read, so non-workdays never
  mutate state. A message reaching the in-window clown branch arrived after its
  game settled, so the grace period has already passed.
- addEntry records the entry and bumps the tally in one call, so concurrent
  deliveries from one user cannot both count. redelivery is a Slack retry;
  duplicate is a repeat post and is ignored entirely.
- Daily results render display names, not <@id>, so listed users are not notified.
- The sweep recovers windows abandoned by a restart even when the channel is
  quiet. app.client is absent in test harnesses, where catch-up is driven by
  incoming messages instead.
A DRY review found that both earlier splits were cutting files to satisfy a
line-count ceiling rather than following a seam, which is the failure mode the
ceiling exists to prevent.

random/ goes from six files back to three. Nothing outside the directory
imported anything but registerRandomBoom, so every other export existed only
because the file had been cut up. io.ts was 9 lines used at three sites in
settle.ts; medals.ts had one importer. createSettler, createBoom and
createAnnouncer were one-line factories with one call site each, and the
Announcer record duplicated Settler's store pointer, so db was reachable three
ways.

The Store inheritance chain goes back to one class. PodiumStore.computePodium
was called from Store.scoreFor and Store touched this.data directly at fourteen
sites, so the protected field encapsulated nothing. The stateless reads move to
store-data.ts as free functions over StoreData.

Also removed: getLatestCrown and the latestCrownMs monotonic-clock helper that
existed only to serve it, both reachable only from tests; addPlacement's
arrival-order branch, whose only production caller always passes a message;
weekKeyForRange, a duplicate of weekKeyFor differing by a zone that cannot
change the ISO week of a bare date; and env.ts's four Pick<Config> aliases,
which annotated returns that loadConfig already enforces.

byEarliestTs drops its numeric tier. Slack timestamps are fixed-width
SSSSSSSSSS.uuuuuu, so lexicographic order is already chronological, and the
float lost the last microsecond digit anyway. parseSlackTs goes with it.

inAllowedChannel was defined identically three times. Two now share
util/channels.ts; legacy/index.ts keeps its own copy because that file is a
frozen equivalence proof.

jsonc.ts was proposed for deletion on the grounds that neither committed holiday
file contains a comment. That premise was wrong: both feature suites write
fixtures with a // comment and a trailing comma, and one of them is frozen. It
stays.

Net -118 lines across src/features/boom, src/env.ts and src/util. All files under
the ceiling, 100 tests green, zero lint errors.
@zkrige
zkrige merged commit 3c95448 into lekkertech:master Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants