Skip to content

Chart reuse rules and an event-global settings layer - #646

Open
noahm wants to merge 5 commits into
partykitfrom
chart-reuse-rules
Open

Chart reuse rules and an event-global settings layer#646
noahm wants to merge 5 commits into
partykitfrom
chart-reuse-rules

Conversation

@noahm

@noahm noahm commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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.id is a fresh nanoid per drawn copy and says nothing about which chart it is, so charts now carry a derived chartKey. Deriving one took more than the obvious fields — measured across all 41 files in src/songs:

  • (name, artist, style, diffClass, lvl) collides in eleven files (donkeykonga 117, pump 63, pump-phoenix 45, maimai 36)
  • saHash isn't unique either (bite7: 175 songs, 153 hashes)
  • maimai's collisions are genuinely distinct charts separated only by extras: ["dx"] vs ["std"]; SMX edits all share diffClass: "edit" and are separated by editId:…

So extras participates in the key, and a #2/#3 ordinal 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_26 shares all 538 of its keys with ddr_world, and a3 shares 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.id is 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 EventState gains a settings object, which is already synced and persisted. hideVetos, showMaxScore and the orphaned showPlayerAndRoundLabels atom (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 it
  • ingest resyncs rather than diverging silently if confirmed state ever refuses a server-ordered action
  • a middleware turns a locally refused action into a toast — reachable in ordinary use, since pocket-picking an already-drawn chart is exactly this

Showing 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 — grid gives each chart a mini jacket, list drops 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

  • Reuse makes a short draw routine rather than exceptional, so a partial draw now says so, and an exhausted pool gets a distinct message from a config that matches nothing.
  • The eligible-charts view dims what the event has spent and counts what's left.
  • Fixes a latent bug in the pre-seeded chart removal: an unchecked findIndex meant splice(-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.mjs now 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 validate and yarn build, both clean.

  • Classic mode: a 7-chart pool caps at exactly 7 across repeated draws, warns on the short draw, errors when exhausted; redraw never returns the card just rejected.
  • Server race, at protocol level: the colliding draw is rejected, never broadcast, consumes no seq, and the room keeps exactly one — with a rule-off control run landing both.
  • Two real browser clients, one-chart pool: one draw survives, the loser sees "Another device drew one of those charts first", both converge on the server's view.
  • Cross-file identity: dia_del_ritmo_26 and ddr_world each 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 via ddr_world) — not 11 per file.
  • Pocket pick: picking an already-drawn chart is refused with a toast and leaves the drawing untouched; an unused one still works and then counts as spent.
  • Transparency: the eligible-charts view reports "4 of 7 eligible charts still undrawn" with the spent charts dimmed.
  • OBS source: a room with two configs (13–14 and 16–17) and draws from both shows 5 of 15 charts by default with a "Lv 16–17" header, tracking the newest draw's config; ?all shows 15, ?min=13&max=14 shows 10, ?max=14 reads 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-numeric min all render without error.
  • Global settings pane: selecting it routes to config/global and renders on the right, toggling from there still reaches the server, and after last selecting it a bare /config lands back on the config rather than on global. Deep-linking to config/global works, in classic mode too.

One thing worth knowing

chartKey is 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

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
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ddr-tools Ready Ready Preview Sep 7, 2026 9:48pm UTC

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
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