Chart reuse rules and an event-global settings layer - #646
Open
noahm wants to merge 5 commits into
Open
Conversation
Tournament organizers regularly want "a chart drawn once in this event can't come up again". Every draw was independent: `draw()` rebuilt the whole deck from game data each time, with no memory of past draws. Two things stood in the way, and this addresses both. Stable chart identity. `DrawnChart.id` is a fresh nanoid per drawn copy and says nothing about which chart it is; the only content-derived comparison was an ad-hoc name/diffAbbr/level triple in card-draw. Charts now carry a `chartKey` derived from the loaded game data. Deriving it takes more than the obvious fields: across src/songs, (name, artist, style, diffClass, lvl) collides in eleven files and `saHash` isn't unique either, so `extras` participates in the key (maimai's dx/std pairs, SMX edit ids) and an ordinal in file order settles the rest. That yields unique keys for all 127k charts with only 157 needing the ordinal. `Chart.id` is added to both schemas as an optional override, but no data file needs backfilling. A home for the setting. It isn't a property of a draw config — it spans every config in a room — so `EventState` gains a `settings` object, which is already synced and persisted. `hideVetos`, `showMaxScore` and the orphaned `showPlayerAndRoundLabels` atom move there too, with a migration lifting saved per-config values rather than resetting them. The rule itself is enforced twice. Draws exclude used charts up front, reading them out of the draw history, which stays the single source of truth about deck state — there is no separate used-chart store to fall out of sync with it. Both halves of a pocket pick count as spent. That alone can't stop two cabs drawing the same chart before either has seen the other's draw, so the root reducer also throws on an action that would reuse a chart. The party server runs the same reducer bundle over the canonical ordering, so the loser of a race is rejected and rolled back by machinery that already exists. Making the reducer throw means the client has to cope with it: rebase now skips a pending action that has become invalid instead of crashing, `ingest` resyncs rather than diverging silently, and a middleware turns a locally refused action into a toast. That last case is reachable in ordinary use — pocket picking an already-drawn chart is exactly it. Reuse also makes a short draw routine rather than exceptional, so a partial draw now says so, and the eligible charts view dims what the event has spent and counts what's left. Also fixes a latent bug in the pre-seeded chart removal, where an unchecked findIndex meant splice(-1, 1) silently dropped an unrelated chart from the pool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvJ2WH81SV2Rvqv1z5YBGg
A chart's optional `id` overrides the identity the app otherwise derives from the song and chart fields, and that identity is what the event-wide reuse rule is built on. Two charts sharing an id would silently make one of them undrawable as soon as the other was drawn — a failure with no symptom at the point it's introduced. The data validator already runs in CI, so assert it there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvJ2WH81SV2Rvqv1z5YBGg
Chart keys were namespaced by the config's gameKey, so the same physical chart reached through two data files got two different keys. That was defensive, and measuring the corpus says it was the wrong instinct. With the namespace dropped, dia_del_ritmo_26 shares all 538 of its keys with ddr_world, and a3 shares 8444 of 8714 — these are the same charts, and a TO running an event from both a curated pack and the base game it was cut from means one chart when they say "don't repeat a chart". The risk traded against is two unrelated games conflating a chart that happens to share a name and rating; across the corpus that is zero, because the key also carries artist, style, diffClass, level and extras. DDR/ITG, DDR/Pump, ITG/SMX and maimai/ongeki share nothing. The namespace also left the two halves of the rule disagreeing. Draw-time exclusion matches on the coarse legacy key too, which was never namespaced, so the client already refused a cross-file repeat while the server invariant — matching on the chart key alone — would have allowed it. Two cabs racing across a pack config and a base-game config could both land the same chart. They now can't. Also fixes the fallback in chartKeyFor, which dropped the song entirely and returned a key that would match any chart of the same style, level and difficulty. It is close to unreachable, since the SMX edit import clones the whole GameData and so indexes its charts, but it was wrong. getDrawnChart already had the song in hand. gameKey is no longer needed to build a key, so it comes back off getDrawnChart's signature. Uniqueness within each file is unchanged: 127139 charts, 157 needing the ordinal tiebreak, no duplicates. An explicit `id` is now compared across files as well, which the schema and validator now say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvJ2WH81SV2Rvqv1z5YBGg
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Gives a stream a way to show viewers what has already come out of the draw pool, which the reuse rule makes worth watching over a long event. Two layouts, because which one fits is a property of the stream layout rather than something to decide here: `grid` gives each chart a mini jacket, and `list` drops the art for text rows grouped by level, fitting several times as many charts in the same space. A long event's pool runs to hundreds of charts, most at levels the bracket has already climbed past, so the view is filtered to a level range. The default range comes from the config behind the most recent draw, which follows the event on its own as rounds get harder. The config a person last *selected* can't drive it: that lives in one browser's localStorage, and an OBS browser source is its own browser that will never see it. Where the newest draw is the wrong answer, the URL says so instead -- ?config=<id>, ?min=15&max=17, or ?all. The draw history stays the only source of truth here too: the view reads the same walk over it that the reuse rule counts, so both halves of a pocket pick show as spent and nothing can drift from what the rule enforces. Every class name in the source's stylesheet is `:global`, unlike the rest of the app. An organizer restyles this overlay from the room's Global OBS Source Styles editor, so the names are a public surface and a hashed one would stop matching whenever the file changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvJ2WH81SV2Rvqv1z5YBGg
The event's global settings were a card pinned above the config list, so they took up the top of the left column permanently and read as a header for the configs underneath rather than as a thing you could be looking at. Make them an entry in the list instead: selecting one shows it on the right like any other settings pane. The selection rides in the route as `config/global`, so it's linkable and survives a remount the way a config selection does. It is deliberately the one entry that never reaches the last-config-selected atom -- it isn't a config, and remembering it would mean coming back to this page never returns you to the config you were actually working on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvJ2WH81SV2Rvqv1z5YBGg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the long-requested "a chart drawn once in this event can't come up again" rule, plus the global settings layer it needs to live in, plus an OBS source that shows a stream what the rule has spent.
Global-only enforcement, per the design sketch: any chart anywhere in the draw history is ineligible, with no reshuffle mechanic. The draw history stays the single source of truth about deck state — there is no parallel used-chart store that could desync from what's on screen. Both charts involved in a pocket pick (the replaced one and the hand-picked one) count as spent.
Chart identity
DrawnChart.idis a fresh nanoid per drawn copy and says nothing about which chart it is, so charts now carry a derivedchartKey. Deriving one took more than the obvious fields — measured across all 41 files insrc/songs:(name, artist, style, diffClass, lvl)collides in eleven files (donkeykonga 117, pump 63, pump-phoenix 45, maimai 36)saHashisn't unique either (bite7: 175 songs, 153 hashes)extras: ["dx"]vs["std"]; SMX edits all sharediffClass: "edit"and are separated byeditId:…So
extrasparticipates in the key, and a#2/#3ordinal in file order settles the rest. Result: unique keys for all 127,139 charts with only 157 (0.12%) needing the ordinal, and no data file touched.A key identifies a chart, not a chart-in-a-file — it carries no game key, so the same chart reached through two data files answers to the same key. That's what a TO means by "don't repeat a chart" when an event draws from both a curated pack and the base game it was cut from:
dia_del_ritmo_26shares all 538 of its keys withddr_world, anda3shares 8444 of 8714. The risk traded against is two unrelated games conflating a chart that happens to share a name and rating; across the corpus that's zero (DDR↔ITG, DDR↔Pump, ITG↔SMX and maimai↔ongeki share nothing), because the key also carries artist, style, diffClass, level and extras.Chart.idis added to both schemas as an optional override, but nothing needs backfilling.Event-global settings
Not a property of a draw config — it spans every config in a room — so
EventStategains asettingsobject, which is already synced and persisted.hideVetos,showMaxScoreand the orphanedshowPlayerAndRoundLabelsatom (never persisted or synced before) move there too, with a migration that lifts saved per-config values rather than resetting them, and a sanitize step on import so an old exported config can't reach across and change how an event is run.They get their own entry in the config list rather than a card pinned above it, so selecting them shows the settings on the right like any other pane. The selection rides in the route as
config/global, which is linkable and survives a remount the way a config selection does — but it is the one entry that never reaches the last-config-selected atom, since remembering a non-config there would mean returning to the page never lands you back on the config you were working on. The config page is mounted in both classic and event mode, so one component covers both.Enforced twice
Draws exclude used charts up front. That alone can't stop two cabs drawing at the same time, each against a history that doesn't yet contain the other's draw — so the root reducer also throws on an action that would reuse a chart. The party server runs the same reducer bundle over the canonical ordering, so the loser of a race is rejected and rolled back through machinery that already exists; no new protocol. This is the first of the "centrally-enforced invariants" the sync roadmap anticipates, landing ahead of its Step 4.
Deploy the partykit server before the web app, per the roadmap — the server needs to know the invariant before clients rely on it.
Sync changes this forced
Making the reducer throw meant the client had to cope:
rebase()now skips a pending action that a foreign action invalidated instead of crashing; the entry stays pending, so the server's verdict still settles itingestresyncs rather than diverging silently if confirmed state ever refuses a server-ordered actionShowing viewers what's been spent
A new OBS source at
e/<room>/source/drawn-charts[/<layout>]lists every chart the event has already drawn, so a stream can show what's come out of the pool. It reads the same walk over the draw history the rule counts, so both halves of a pocket pick appear and nothing can drift from what's enforced.Two layouts, since which one fits is a property of a stream's layout rather than something to decide here —
gridgives each chart a mini jacket,listdrops the art for text rows grouped by level and fits several times as many charts in the same space. Both are offered from the dashboard with a copy-URL button.A long event's pool runs to hundreds of charts, most at levels the bracket has already climbed past, so the view is filtered to a level range. The default comes from the config behind the most recent draw, which follows the event on its own as rounds get harder. The config a person last selected deliberately can't drive it: that lives in one browser's localStorage, and an OBS browser source is its own browser that will never see it. Where the newest draw is the wrong answer, the URL says so —
?config=<id>,?min=15&max=17(either end may be omitted), or?all.Every class name in that source's stylesheet is
:global, unlike the rest of the app's CSS modules. An organizer restyles the overlay from the room's Global OBS Source Styles editor, so those names are a public surface and a hashed one would stop matching whenever the file changed.Also
findIndexmeantsplice(-1, 1)silently dropped an unrelated chart from the pool whenever a pre-seeded chart wasn't in it (a pocket pick from outside the config's range, say).scripts/validate.mjsnow asserts chart-id uniqueness, since a duplicate would silently make one chart undrawable with no symptom where it was introduced.Not in scope
Reshuffle mechanics of any kind — exhaust-and-reshuffle, per-phase resets, mid-draw shuffle points, and the deck-membership state they'd need. The invariant here assumes a strictly append-only used-set.
Verification
No unit-test runner in the repo, so this was driven against the real app (both dev servers + Playwright) alongside
yarn validateandyarn build, both clean.dia_del_ritmo_26andddr_worldeach expose 11 single/challenge/19 charts and they're the same 11. Alternating draws between two configs in one room caps the event at 11 total (6 via the pack config, 5 viaddr_world) — not 11 per file.?allshows 15,?min=13&max=14shows 10,?max=14reads as "and below",?config=<early id>pins to 10. Drawing while the source stayed open took it from 5 to 10 with no reload. A pocket pick brought the total to 21 — 20 drawn plus the pick, with the replaced chart still listed. Empty rooms, an unknown layout segment and a non-numericminall render without error.config/globaland renders on the right, toggling from there still reaches the server, and after last selecting it a bare/configlands back on the config rather than on global. Deep-linking toconfig/globalworks, in classic mode too.One thing worth knowing
chartKeyis deliberately weaker per-file than a namespaced key would be: two genuinely unrelated games sharing a song name, artist, style, difficulty, level and extras would conflate. Measured at zero today, but that's a property of the corpus rather than of the algorithm. If a future data file breaks the assumption the symptom would be a chart mysteriously undrawable, and the per-file uniqueness check won't catch it — a cross-file check would, if that ever seems worth having.🤖 Generated with Claude Code
https://claude.ai/code/session_01FvJ2WH81SV2Rvqv1z5YBGg
Generated by Claude Code