Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/rules/draw-and-seeding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
paths:
- "internal/helper/{seed,seed_warnings,tree,pool_distribution_tree_aware,pool_partial,pool_bounds,draw,draw_perpool,draw_qualifier_paths,numbers,rounds}*.go"
- "internal/engine/{pools,numbering,knockout,knockout_skeleton,seed_warnings,league_schedule,chusen}*.go"
- "docs/assets/javascripts/pool-draw-animation.js"
---

# Draw, seeding and pool distribution

Moved from CLAUDE.md "Key Algorithms". Read before changing how a draw places, seeds or numbers competitors.

- **Binary tree brackets** (`helper/tree.go`): `Node` struct with `Left`/`Right` children, recursive subdivision for multi-page output. `helper.MaxPlayersPerTree = 16` (constants.go).
- **Seeding** (`helper/seed.go`): `StandardSeeding()` uses `generateBracketOrder()` for placement. `ApplySeeds()` handles collisions by swapping seed values. Seed names are title-cased with cases.Title(NoLower) and then looked up exactly by (name, dojo) in domain.RosterIndex; accents and non-initial capitals must match (NormalizeParticipantName is NOT on this path). `delayDojoMeetings` (same file) then delays dojo-mate meetings across the unseeded slots; its acceptance is SUM-DRIVEN by operator ruling (2026-08-31): a swap may pull an already-deep dojo's first meeting one round earlier to push the draw's earliest meetings later. Do NOT add a per-dojo never-earlier veto here: that rule belongs to the POOL exchange pass (`improveDojoMeetings`), and a finding that reports the deep-dojo trade as a regression is re-litigating the ruling.
- **Pool creation** (`helper/pool_distribution_tree_aware.go`): members are placed ONE AT A TIME in the roster's pre-shuffled order (no re-sorting -- shuffling is upstream's job), each descending the knockout skeleton tree by recorded per-node dojo counts (fewer of my dojo, then more room, then left); seeds are placed first on their EKC/D7 slots and recorded. A single pairwise exchange pass follows with a strictly tiered objective: per-pool spread-cap excess, winner-path round-1 count, winner-path meeting sum, then the all-qualifier crossing best effort -- each tier only resolves what the tier above left tied. No rotations. A crossed 2nd-place meeting a dojo-mate is ACCEPTED chance (operator ruling); never "fix" it beyond the tie-break tier. The old greedy fill's `assignPlayersToPools`/`discoverPool` survive only for `CreatePools` direct callers (estimator, goldens), where unique-dojo rosters make them identity-equal; `leastConflictedPool` is NOT one of those retirees -- it is load-bearing in the tree-aware distributor itself, called from three sites (`pickDojoTreeAwarePool`'s two fallbacks plus `poolUnderDojoCap`) as the descent's own least-conflicted fallback when the dojo tree has nothing to say. Pools distributed contiguously across shiaijo. **Competitor numbers belong to draw positions, never to people before a draw exists** (operator ruling 2026-09-06): once the entrant count fixes the pools and the tree, the positions are numbered pool by pool (`helper.NumberPools`, K1-K4 the first pool) and seeds and everyone else are placed into those positions; nothing is shown before the draw is generated (the earlier provisional registration-order numbers were removed as confusing). A knockout-only competition follows the SAME rule down the bracket instead of pool by pool (bc-pnum ruling 2, 2026-09-07): `helper.StandardSeeding`'s own placement order is recorded as `state.Bracket.DrawOrder` (participant ids, top of the tree to the bottom) when the draw is generated, and `engine.NumberKnockoutParticipants` composes each competitor's number from their position in it at read time; a competitor excluded from the draw (e.g. not checked in), or a bracket generated before this field existed, carries no number -- there is no participant-order or name-based fallback. **Changing any of this reddens a test that names a docs file**: the pool-draw page carries an interactive walk-through (`docs/assets/javascripts/pool-draw-animation.js`) that replays this descent in JS, and its example rosters' drawn pools are a fixture between markers in that same file, pinned by `TestPoolDrawDocWalkthroughMatchesTheDraw` (`internal/helper`) re-running them through the real distributor. That is deliberate: a recorded outcome rots silently otherwise. Update the fixture, then re-check the JS descent against it in a browser, since the test pins the recorded pools and not the JavaScript.
- **Tree-aware seeding** (`helper/seed.go`): `placeSeedIndices`, called from `buildPoolPhaseTreeAwareCore` (`helper/pool_distribution_tree_aware.go`; `PoolSeeding` itself was removed as dead code, bc-drwx item 11), places seeds so the top seeds land in different halves/quarters of the knockout TREE (D7, enforced via tree paths in seed_warnings.go); the EKF rank-to-court order is that tree contract's sheet encoding, since courts own contiguous chunks of the tree. Courts are NOT an overlay here: `numCourts` is the modulus `seedPoolRank`/`seedCourtOrder` compute the seed spread against, so a wrong court count places seeds in the wrong quarter. (Courts genuinely are an overlay for the SEPARATE dojo-tree descent that places the unseeded -- see the Pool creation bullet above -- which reads a court index only to derive `drawCourts` up front and to reorder for courts at the very end, never in between.) **Wrapped seeds** (rank beyond `numPools`, e.g. rank 5 over 4 pools -- reachable from a contiguous seeding with more ranks than pools, or from check-in dropping a mid-sequence seed and leaving a gap, `TestGenerateDraw_SeedGapFromCheckInKeepsD6Placement`): the extra seed takes the next pool (in the same rank-priority order the natural arithmetic defines) with room holding NO seed; failing that, the next with room holding no DOJO-MATE; only once both fail does it fall through to D6's unconditional placement. Once placed, a wrapped seed is never moved by anything downstream -- not the pool exchange pass (`improveDojoMeetings`, which excludes every seeded slot from both sides of a swap) and not `delayDojoMeetings` (which only ever moves unseeded slots, via its own `occupied` guard). `seed_warnings.go`'s halves/quarters/shiaijo checks are scoped to ranks `<= maxSeedRanks` for the same reason: a wrapped seed has no half/quarter home in D6's structure, so scoring it against those checks by its COUNT position in the placed-seeds list (rather than by its rank value) produced a spurious "could not be split into halves" warning for a wrapped seed that had landed cleanly in its own seed-free pool (bc-pnum operator ruling; restores what an earlier review round had narrowed to the dojo-mate case alone).
- **A pool bout's number is its place in the playing order, and the id carries it** (operator ruling 2026-10-04: "Should be in playing order and both ids should be in sync"). `numberPoolMatchesInPlayingOrder` (engine/pools.go) gives each pool's ids after every reorder the draw makes (the league scheduler's no-back-to-back order), just before the save, so `Pool A-0` is the first bout played. Every surface reads the number from the id: the server label below, the scores list, the workbook (`AttachPoolMatches` sorts by it) and the console's "Match N of M" (`poolPosition`, `poolMatchNumberOf`, viewer_utils.jsx), never from a row position. A queue reorder moves only `scheduledAt`, so a bout keeps its number. A league drawn by an earlier release keeps ids given before its reorder; every surface still shows one number for each of its bouts.
14 changes: 14 additions & 0 deletions .claude/rules/eligibility-and-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
paths:
- "internal/engine/{eligibility,scoring,scoring_tx,kachinuki}*.go"
- "internal/state/competitor_status*.go"
- "internal/mobileapp/{handlers_match,handlers_decision,handlers_eligibility,errors}*.go"
- "web-mobile/js/{ineligible_match,start_match,barred_chip,barred_match_notice,admin_shiaijo,admin_scoring_shared}.jsx"
---

# Competitor eligibility and the match queue

Moved from CLAUDE.md "Key Algorithms". Read before changing withdrawals, bars, reinstatement, starting a match or sending one back to the queue.

- **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the fusensho for Carol." (kiken-injury: "...Reinstate Alice if they can fight again, or record the fusensho for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. A withdrawal decision against a competitor ANOTHER match already barred is decided by ONE rule, `engine.alreadyBarredRefusal` (bc-kfup), asked by both T105 halves (`checkConcurrentIneligibility` before the write, the K2 check-and-set in `recordIneligibilityFromDecision`): a kiken is refused with `already_ineligible` (a competitor withdraws once), while a fusenpai is their default loss on a remaining match and records NO new `CompetitorStatus`, so the existing one keeps naming the match where they withdrew. Do not re-add a fusenpai refusal there: StartMatchTx refuses to start that match, so the fusenpai is the operator's exit. Clearing the match that DID record the bar moves it onto such a chained fusenpai rather than lifting it (`standingWithdrawalOf`, fusenpai only: a kiken still on record beside a later bar was lifted by a reinstatement before that bar existed, since a second kiken is refused), and a reopen that clears it lands `scheduled` because `reopenTargetStatusTx` judges the bars the restore will leave (`restoredStatus`, shared with the restore). Accepted as it is (operator decision 2026-09-26): a fusenpai recorded before a reinstatement still carries the bar on, so a competitor who withdrew injured, had a no-show recorded on another match, was reinstated, and then has a later withdrawal cleared as a mistake is barred again by that earlier no-show; the editor names the no-show match, where clearing the fusenpai lifts it. Telling the two apart needs the time of each reinstatement, which is not worth storing for this. Do not report it as a new defect. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateEligibility`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateEligibility` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record fusensho for <opponent>` (`POST /decision {decision: "fusensho", decisionBy: <barred side>}`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate <name>`. The same request-time pass stamps a COMPLETED match a kiken, fusenpai or fusensho decided with `withdrawnStatus` (`state.WithdrawnStatusAnnotation`: the withdrawn side's status now, eligible / the match that bars it / reinstateable), which `RecordedWithdrawal` words its clear from instead of fetching statuses. The viewer payloads load statuses whenever a match needs either stamp (`anyMatchToAnnotate`), and the `/decision`, `/score` and bulk-score writes stamp the result they push (`stampWithdrawnStatus`), so a pushed row and its refetch read the same. **Recording a withdrawal changes only that match** (operator ruling 2026-09-26, "do not cascade"): the post-kiken Remaining matches panel that offered to close the competitor's other matches was removed, and each of them is closed from its own notice when it comes up. Do not re-add a cascade from one match's editor. Recording one still starts the court's next match in the same competition (mp-nwds item 7, the operator's request), on the court console and the Scores tab alike, both through `start_match.jsx` (one start at a time; a refused start is shown on the refused match, on the console's Up next or queue row and on the Scores tab's row, with one toast: an error `editMatchScore` already toasted is marked (`markToasted`, write_result.jsx) and the host does not toast it again. The notice is bound to the refused match, not to Up next, by one rule both hosts ask (`startRefusalStands`, start_match.jsx): it stays while that match is `scheduled` on the court, and across an empty list, which says nothing about the match and goes when it leaves `scheduled`, when a non-empty court list no longer holds it, when the operator starts a match, or when an eligibility change in its competition is broadcast; Up next moving on does not drop it. A refusal because another start was still out is kept, in the past tense (`startWasBlockedByStartMessage`), on the refused match once that start has gone out: `landed` on the start's record, set by the console's `startMatch` for a start that landed or was queued offline, never for a pick whose deferral of the running bout failed, nor for a start the server refused: a `clock_skew` answer, or a superseded one that holds the `result` group (`heldGroups` non-empty: a different result is stored and nothing of the start was written). A superseded start with NO `heldGroups` is an echo-hold (a start names only `result`, and the server lists a held group only when it differs from the stored one, `reportHeld`/`HeldEcho`): another device's start of the same match landed first and the match is running, so `classifyStartOutcome` answers it `{ok: true}` like a landed start: no sentence, no toast, a pick pins the panel, and a refusal waiting on it is worded in the past tense. The two refusals answer `{ok: false, msg}`, so both hosts take them as refused starts: the sentence (`START_CLOCK_SKEW_MESSAGE`, or `START_SUPERSEDED_MESSAGE`, composed by `notSavedText` from `SUPERSEDED_REASON` and `SUPERSEDED_ADVICE` under the "Not started" lead, which never invites a retry, start_match.jsx) is stored on the refused match and toasted once, and a pick does not pin the panel on that match; after a pick the court is idle, the running bout already sent back to the queue. The refused match is never started on its own; if the blocking start fails instead, its own refusal stays. On the Scores tab `refuseStart` replaces the single waiting refusal and clears `waitingOn`, so its `finally` has nothing to rewrite, and a superseded start that holds its result group, after a decision, lands on that match with its notice. Accepted: the `result` group's projection also holds `CorrectionReason`/`ResultSource`, so a start over a match running under a different source or reason answers `heldGroups: ["result"]` and shows the refused sentence on a running match; the notice drops once live data shows it running (`startRefusalStands`), the toast stays. The console reads `startPatch`, `winnerSideLR` and `teamMatchMarks` by ES import (`start_match.jsx`, `side_marks.jsx`), and admin_shiaijo.jsx still must not import bracket.jsx); both skip the withdrawn competitor's matches through `sideBarredByDecision`/`involvesCompetitor` (`ineligible_match.jsx`), because the list shows them barred only after it refreshes.
- **Send back to queue keeps the score** (operator ruling 2026-09-26, bc-sbq). `RevertMatchToQueue`/`requeueBracketMatch` clear only the verdict and the audit notes; points, penalties, overtime, bouts, flags and rep-bout fighters stay, the operator removes a wrong mark themselves, and the button is always offered (picking another bout sends a scored one back the same way). Starting the match again keeps them: the SPA's `startPatch` sends the request-only `startOnly` flag and `engine.keepQueuedScore` copies the stored score onto that write, because the wire cannot say "no score sent" (the start's empty arrays are the bytes of an operator clearing every mark, which must still clear, so the editors' own Start stays unflagged; `startOnly` with any status but `running` is a 400, since the stored score is swapped in after validation). The start's representative-bout winner is derived AFTER that swap (`deriveDaihyosenWinner`, scoring_tx.go), so it comes from the bouts the start keeps, never from rows it discards. While queued, every display shows the match as NOT STARTED through `matchShowsScore` (match_shows_score.jsx, a leaf every display ES-imports), which `IndividualScore`/`TeamScoreboard` apply to themselves; the score editors are the one exception. A queued knockout match's kept points do not make it "fought" (`bracketMatchStartedOrDecided` reads status and winner only), so correcting or reopening the match that feeds it re-seats it with its points and asks nothing when it starts. A FINISHED later match that a confirmed correction reopens keeps its points, bouts and flags too, only its verdict goes (`reopenDisplacedBracketMatch` through `reopenBracketMatchKeepingTheFight`, both the knockout and the pool-requalification doors). Engi flags are saved as they are entered: a running engi write is not judged on the flag total (`engiRunningWrite`, scoring_tx.go) and the bracket write carries flags, so a send-back or switch keeps them.
Loading
Loading