diff --git a/CLAUDE.md b/CLAUDE.md index e9939f0fa..ebe5df516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ When asked to *plan*, *verify*, or *record a plan on a bead*: do NOT write code. ## Governance Before implementing features or making architectural decisions, read the project constitution: -**`.specify/memory/constitution.md`**: defines the core principles (YAGNI, DRY, TDD, DDD, evidence-based decisions, bracket integrity, and live-tournament constraints) that all changes must comply with. The constitution is tracked in git. Feature plans under `specs/-.../` are gitignored (only `specs/openapi.yaml` and `specs/*/screenshots/` are tracked), so a worktree or fresh clone has no plan file. +**`.specify/memory/constitution.md`**: defines the core principles (YAGNI, DRY, TDD, DDD, evidence-based decisions, bracket integrity, and live-tournament constraints) that all changes must comply with. The constitution is tracked in git. Feature plans under `specs/-.../` are gitignored (only `specs/openapi.yaml` and `specs/*/screenshots/` are tracked by the rules), so a worktree or fresh clone has no plan file, except the few that were force-added before the rule (`git ls-files specs` lists them, e.g. `specs/007-ekc-draw/spec.md`). Those are tracked and read, so a change that contradicts one corrects it in the same PR. ## Project Overview @@ -107,7 +107,7 @@ go test -cover ./internal/helper/... - **`internal/excel/`**: Excel file lifecycle (`Client`), the from-scratch workbook template (`NewFileFromScratch`), style definitions. - **`internal/service/`**: Service layer abstraction over helper logic. - **`internal/resources/`**: Embedded file management. Resources flow: `main.go` embeds → `resources.NewResources()` → `cmd.ExecuteWithResources()`. -- **`internal/mobileapp/`**: Gin HTTP handlers for the tournament app (`mobile-app` command). Routes: `handlers_competition.go` (including `POST /api/competitions/:id/generate-draw` [status `draw-ready`] and `DELETE /api/competitions/:id/draw` to discard the draw; a draw-ready competition is listed on the shiaijo operator views and its FIRST RECORDED result starts it -- a match left running or completed, NEVER a write that clears a result back to scheduled (the engine's result gate, which also discounts the byes a knockout draw resolves at generation time) -- via `engine.MaybeAutoCompletePools`'s `AutoCompleteStarted` outcome, which `tryAutoCompletePools` broadcasts as `competition_started`, so the desk's `POST .../start` is one of two doors, bc-prow ruling), `handlers_match.go`, `handlers_participants.go` (including single participant PUT updates, individual/bulk check-ins `POST /api/competitions/:id/participants/checkin-bulk`), `handlers_tournament.go`, `handlers_swiss.go` (`generate-round`, `standings`), `handlers_decision.go` (kiken/fusenpai/daihyosen: `POST /api/competitions/:id/matches/:mid/decision`), `handlers_eligibility.go` (`/api/competitions/:id/competitor-status`), `handlers_lineup.go` (team lineups), `handlers_schedule.go` (`GET /api/schedule/estimate`, public), `handlers_reset.go` (`POST /api/tournament/reset`, public, for forgotten admin passwords; 404s in locked mode), `handlers_auth_config.go` (`GET /api/auth-config`, public, reports auth mode to the SPA). Real-time push via SSE (`hub.go`) with events: `match_updated`, `competitor_status_updated`, `competition_completed`, `swiss_round_generated`, etc. Auth via `X-Tournament-Password` header (`middleware.go`), with two modes selected at startup by a `PasswordVerifier` (`auth_source.go`): **file mode** (default, plaintext compare against `tournament.md`) or **locked mode** (`--lock-password` flag + `TOURNAMENT_PASSWORD_HASH` env var, bcrypt compare, `POST /api/tournament/reset` returns 404; the SPA `/reset` page still renders an operator-disabled message). Consumer-boundary interfaces live in `deps.go` (NFR-002). +- **`internal/mobileapp/`**: Gin HTTP handlers for the tournament app (`mobile-app` command). Routes: `handlers_competition.go` (including `POST /api/competitions/:id/generate-draw` [status `draw-ready`] and `DELETE /api/competitions/:id/draw` to discard the draw; a draw-ready competition is listed on the shiaijo operator views and its FIRST RECORDED result starts it -- a match left running or completed, NEVER a write that clears a result back to scheduled (the engine's result gate, which also discounts the byes a knockout draw resolves at generation time) -- via `engine.MaybeAutoCompletePools`'s `AutoCompleteStarted` outcome, which `tryAutoCompletePools` broadcasts as `competition_started`, so the desk's `POST .../start` is one of two doors, bc-prow ruling), `handlers_match.go`, `handlers_participants.go` (including single participant PUT updates, individual/bulk check-ins `POST /api/competitions/:id/participants/checkin-bulk`), `handlers_tournament.go`, `handlers_swiss.go` (`generate-round`, `standings`), `handlers_decision.go` (kiken/fusenpai/fusensho/daihyosen: `POST /api/competitions/:id/matches/:mid/decision`; `handleBothSidesBarredHikiwake` runs before the endpoint's own `Validate()` -- which otherwise 400s `hikiwake` outright with "use /score for fought/hikiwake" -- as the one carve-out that accepts `decision: "hikiwake"`, no `decisionBy` needed, when both sides of a SCHEDULED pool or league match (`engine.IsPoolMatchID`, league reuses the same `"Pool "`-prefixed ids; Swiss ids never match and are not covered) are already barred; the write records `Status: completed, Decision: "hikiwake"` with no new `CompetitorStatus`. The same both-barred knockout match instead gets `ineligible_competitor` naming both sides and pointing at the earlier withdrawal or the draw), `handlers_eligibility.go` (`/api/competitions/:id/competitor-status`), `handlers_lineup.go` (team lineups), `handlers_schedule.go` (`GET /api/schedule/estimate`, public), `handlers_reset.go` (`POST /api/tournament/reset`, public, for forgotten admin passwords; 404s in locked mode), `handlers_auth_config.go` (`GET /api/auth-config`, public, reports auth mode to the SPA). Real-time push via SSE (`hub.go`) with events: `match_updated`, `competitor_status_updated`, `competition_completed`, `swiss_round_generated`, etc. Auth via `X-Tournament-Password` header (`middleware.go`), with two modes selected at startup by a `PasswordVerifier` (`auth_source.go`): **file mode** (default, plaintext compare against `tournament.md`) or **locked mode** (`--lock-password` flag + `TOURNAMENT_PASSWORD_HASH` env var, bcrypt compare, `POST /api/tournament/reset` returns 404; the SPA `/reset` page still renders an operator-disabled message). Consumer-boundary interfaces live in `deps.go` (NFR-002). - **`internal/state/`**: File-backed state store for the mobile app. Tournament and competition config lives in `tournament-data/tournament.md` and `tournament-data/competitions//config.md` (YAML front-matter). Participants are in `participants.csv` alongside each config. - **`internal/engine/`**: Thin adapter that drives `internal/helper` pool/bracket generation from a `state.Competition`. Called by the `POST /api/competitions/:id/start` handler. - **`web-mobile/`**: Preact/JSX frontend for the mobile app, served embedded in the binary. Entry point: `web-mobile/index.html`. JS modules in `web-mobile/js/` are grouped by prefix: `admin_*.jsx` (the operator console: setup, participants, pools, scoring, scheduling, lineups, etc.), `viewer_*.jsx` and `display_*.jsx` (public attendee/spectator surfaces), and shared infrastructure (`app.jsx`, `api_client.jsx`, `api_serializers.jsx`, `bracket.jsx`, `data.jsx`, `patch.jsx`, `router.jsx`, `ui.jsx`, `glossary.jsx`). Run `ls web-mobile/js/` for the current set rather than relying on an enumerated list here. Note `admin_participants.jsx` holds the `LinedTextarea` gutter participant paste box; check-in is NOT there, it is owned by `admin_registration_desk.jsx`. CSS in `web-mobile/css/styles.css`. Pre-compiled to `web-mobile/dist/` by esbuild (run automatically as part of `make go/build`). @@ -158,6 +158,8 @@ tournament-data/ 6. Higher number of individual draws (IT). 7. Higher number of points scored (PW). 8. Lower number of points lost (PL). +- **Team default-win credit**: when a completed, non-kachinuki team match is decided by a match-level default win (`domain.IsDefaultWinDecisionStr`: kiken/kiken-voluntary/kiken-injury/fusenpai/fusensho), every numbered bout with no result of its own is credited to the OTHER side, one individual victory and two points, the same figures a fusensho bout scores. `state.DefaultWinCreditSide(status, decision, decisionBy, att)` decides which side is credited (from `decisionBy` when set, else `domain.AttributeWinnerSide`); `state.SubBoutEffectiveResult` fills a credited bout's `Winner`/ippons (maru) without touching its `Decision`, so a credited bout carries no per-bout Kiken/Fus. mark of its own. These two functions are format-agnostic; kachinuki is excluded one level up, at WRITE time, not inside them: `state.NeedsDefaultWinBoutPadding`/`PadDefaultWinBoutPositions` (which synthesize the empty placeholder rows the credit functions act on) are gated on `!comp.IsKachinuki()` in `RecordMatchResultWithIneligibilityTx` (`engine/scoring_tx.go`), so a kachinuki encounter is never padded and so is never credited in practice. The same rule feeds the wire summary (`MatchResult.TeamResult()`/`BracketMatch.TeamResult()`), standings accrual (`accrueTeamSubResults`, `engine/scoring.go`), and the Excel export (`internal/export/builder.go`); the JS mirror is `web-mobile/js/team_default_credit.jsx`, consulted only when the server's own `teamResult` is absent. A score-sheet correction (`incoming.Decision` empty or `hikiwake`) over a stored default-win ruling keeps it: `engine.KeepsWithdrawalRuling(storedStatus, storedDecision, incoming)` was widened from the old withdrawal-only check (`domain.IsWithdrawalDecisionStr`) to `domain.IsDefaultWinDecisionStr`, so a stored fusensho survives a correction exactly as a kiken/fusenpai does. +- **Operator match labels** (`internal/engine/match_label.go`): `OperatorMatchLabel(comp, bracket, matchID)` is the ONE Go owner of how a match is named back to the operator -- in refusal messages, the `court_busy`/`downstream_knockout_running` `label` fields, and `reasonHuman` sentences. A pool/league row reads e.g. `"Pool A · Match 1"` (`"League table · Match N"` for a league; the middle dot is the literal separator, `supplementaryOrNumberedLabel`); a knockout row (`MatchLabel`) reads e.g. `"Match 2 (Final)"`, `"knockout Match N"` (no round recorded), or `"the 3rd-place match"` (bronze). Pinned against the JS side by the shared golden fixture `internal/engine/testdata/match_labels.json`: `TestOperatorMatchLabel_GoldenTable` (Go) and `web-mobile/js/__tests__/match_labels_golden.test.jsx` (JS, `scoreRowMatchLabel`/`matchLabel` from `pool_ids.jsx`/`write_result.jsx`) load the SAME file. - **Team Match Winning Criteria**: An encounter between two teams is decided by: 1. Highest number of individual winners (Victories). 2. Highest number of points scored. @@ -168,10 +170,10 @@ tournament-data/ - **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. A hantei mark does not raise it: `realIppons` drops it and it undoes through its own "click to undo" control. - **Team Match Labels**: Summaries use **"IV"** (Individual Victories) and **"PW"** (Points Won). - **Court limit**: `--courts` is hard-capped at 16 (`helper.MaxCourts`, derived from the court label alphabet A–P) and any value over that returns an error rather than silently truncating. 16 is the largest allocation any one competition can legally hold, so a venue beyond it could never give all its shiaijo to a single competition. -- **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. 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. +- **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 default win for Carol." (kiken-injury: "...Reinstate Alice if the doctor allows, or record the default win 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. `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`, `annotateIneligibleSides`). 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.annotateIneligibleSides` (`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 default win for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. - **A fixed-order team bout row records NO fighter name, never the team's** (operator ruling 2026-09-17, bc-dnst: "the storage is the main source of truth"). The bout settles at the MATCH level, so when no lineup names a fighter the row knows no per-fighter identity and `buildPatch` writes `sideA`/`sideB` EMPTY. It used to write the team name there, which is not a person: every display then had to filter it back out, and the ones that did not showed the team's name where the competitor goes. Nothing needed the copy, because attribution reads the MATCH-level name first (`state.SubBoutWinnerSide`, whose `sub.SideA` arm is guarded against `"" == ""`; the JS `teamIVScore` fallback mirrors it) and the server's own quick-score path already wrote this shape. **The DAIHYOSEN row is the exception and keeps the team names**: the hantei mark is placed on the winner's side by comparing the winner against THAT row's `sideA`/`sideB` (`placeHt`), so blanking them drops the mark from the wire. `resolveBoutSideName`'s `teamNameA`/`teamNameB` filter stays for rows written BEFORE this rule; it is legacy handling, not a live guard. - **A lineup vacancy is never flagged on the score sheet** (operator ruling 2026-09-16, bc-dnst). Team sizes are unregulated and a vacancy is legitimate play, so the sheet must not tell the operator to complete a lineup. The "SHIRO/AKA: Lineup incomplete, add the remaining players" notice was REMOVED with its predicate and its CSS; it never blocked anything, but it read as a requirement. Pinned by `team_editor_vacancy_not_flagged.render.test.jsx`, which exists because a removed warning otherwise leaves no failing test behind. Do not re-add it in any form. -- **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round or a specific match (mp-825). Team sizes are unregulated and vacancies are never enforced: `TeamLineup.Validate`/`validateFive` (the FIK 5-person back-fill/DQ rule) were REMOVED (mp-gmcg); only the key-only `ValidatePositions` check remains. Kachinuki ("winner-stays-on") is operator-led: `engine.MaybeAdvanceKachinuki` is append-only (adds the next pairing; NEVER auto-finalizes; the roster snapshot is advisory) and the encounter ends only on an explicit `status: completed` score write, which strips trailing unscored auto-appended bouts. Both win rules, exhaustion and taisho-defeated, record `decision: "kachinuki-exhaustion"`; a drawn pool/league encounter is `hikiwake`. A tied pairing may be fought on in encho on that same bout in ANY phase; whether it must produce a result (e.g. the taisho must be defeated) is OPERATOR DISCRETION, never derived from pool-vs-bracket (the phase-independence rule lives in `allowNumberedEnchoFromStore` (`handlers_match.go`) and `subBoutNeedsNumberedEnchoAllowance` (`validation.go`); do NOT re-scope numbered-bout encho by match-ID phase; that was tried and reverted). Only the knockout END stays blocked while tied (the bracket needs a winner, `validateBracketCompletion`). Daihyosen does not exist in kachinuki → `POST .../daihyosen` returns 400. Mistakes: `POST /api/competitions/:id/matches/:mid/reopen` (kachinuki-only) flips a completed match back to running, clearing winner/decision but keeping the bout log; `POST .../matches/:mid/requeue-blocker-and-reopen` atomically frees the target's court (requeuing the blocking match) and reopens under one court-lock hold; `DELETE .../matches/:mid/kachinuki-bout` removes the current trailing unscored auto-appended pairing. Every numbered bout row of a team match shows the fighter's squad number and, when the position is unnamed, a typeable name box (operator ruling 2026-09-15, bc-dnst). The number belongs to the squad member, not the row: team order can change between matches, so picking a different member into a position moves that member's number onto the row. A fresh team's seeded blank slots are members with a number and no name, so an unnamed fixed-order position shows the number of the blank member seeded for it, and a name typed there names that member through `resolveMemberIdsForPositions` (rename, never a new mint while a blank slot with that number exists), so the name stays attached to the number it was shown with. A team's squad is seeded with team size + 2 numbered slots (`SquadReserveSlots`, two reserves), and the row's list shows every slot with its number, blank ones by number alone, so any number the team can field can be picked and then named; a picked slot is written by id (`buildInlineLineupWrite` with a member) and a name typed into it renames that member (operator ruling 2026-09-15, bc-dnst). That makes a picked-but-unnamed slot a real placement everywhere: every lineup writer (the score sheet row, the Up Next "Enter lineup" panel, the Lineups page) sends the position with an empty name and the member id rather than dropping it; `TeamLineup.OrderedMembers` counts a position occupied when it carries a name OR an id, so such a fighter is on the kachinuki roster; and `AdvanceKachinuki` decides which side stayed on through `domain.SubBoutAttribution`/`AttributeWinnerSide` (ids first), because the client records a nameless winner as its TEAM name plus the member id and a name comparison alone can never see it stay on. A lineup may not place one member id at two positions: `memberPlacedElsewhere` (lineup_resolver.jsx) is the ONE client predicate, asked by every writer before it writes (`buildInlineLineupWrite`, the panel's save, the Lineups page's add path) and rendered as the list filter `rosterWithoutPlacedElsewhere` on every picker, so the offer and the refusal (" is already at ") never disagree; `ErrLineupDuplicateMember` from `ValidatePositions` (a 400 naming the two positions) is the server backstop. A lineup that already held a duplicate before this guard existed is repaired by clearing one of the two rows. Under kachinuki only the FIRST row is a lineup position; every later row's pick rides the bout itself (`pickManual` with the member id, never a lineup PUT), the list there is unfiltered because the fighter who stayed on legitimately repeats, and a name typed over a picked blank member renames that member and keeps its id. Which side stayed on is decided through `domain.AttributeWinnerSide`, whose id tier names a side when the winner id equals THAT side's id even if the other side carries none (JS mirror `attributeWinnerSide`, result_slot.jsx), with the same name-only residue `RetiredPlayersFromBoutLog` keeps for a row no id can settle; `RetiredMemberSet.Count` tallies ids plus id-only names so a nameless fighter is counted. Before the draw a team has no number, so the Lineups page, the one operator surface for a team's people, labels a slot through `squadSlotLabel` ("Slot 3"), a sibling of `squadMemberLabel` in the same owner; spectator surfaces keep `squadMemberLabel` and stay bare before the draw. The competition Settings page's Squad members section was REMOVED on 2026-09-15 by operator decision (bc-dnst): the Lineups page is the one home for a team's people, and its "Team members" list's "Clear name" control (beside Rename) is where clearing a member's name now lives. The Lineups page's "+ Add" confirm is derived from the resolver's own three outcomes (an existing member is selected with no confirm, a blank slot is named, else a member is minted). A rename or a clear reaches every stored lineup: `RenameTeamMember`/`ClearTeamMemberName` rewrite the name of every lineup position holding that member by id (`renameMemberInLineupsLocked`), because the score sheet and the export read the lineup's stored name; fought bouts keep their frozen names. The Excel export indexes lineup positions by member id as well as by name (`memberKey`, `resolveKachinukiBoutPosition`) so a nameless fighter still gets its position label. +- **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round or a specific match (mp-825). Team sizes are unregulated and vacancies are never enforced: `TeamLineup.Validate`/`validateFive` (the FIK 5-person back-fill/DQ rule) were REMOVED (mp-gmcg); only the key-only `ValidatePositions` check remains. Kachinuki ("winner-stays-on") is operator-led: `engine.MaybeAdvanceKachinuki` is append-only (adds the next pairing; NEVER auto-finalizes; the roster snapshot is advisory) and the encounter ends only on an explicit `status: completed` score write, which strips trailing unscored auto-appended bouts. Both win rules, exhaustion and taisho-defeated, record `decision: "kachinuki-exhaustion"`; a drawn pool/league encounter is `hikiwake`. A tied pairing may be fought on in encho on that same bout in ANY phase; whether it must produce a result (e.g. the taisho must be defeated) is OPERATOR DISCRETION, never derived from pool-vs-bracket (the phase-independence rule lives in `allowNumberedEnchoFromStore` (`handlers_match.go`) and `subBoutNeedsNumberedEnchoAllowance` (`validation.go`); do NOT re-scope numbered-bout encho by match-ID phase; that was tried and reverted). Only the knockout END stays blocked while tied (the bracket needs a winner, `validateBracketCompletion`). Daihyosen does not exist in kachinuki → `POST .../daihyosen` returns 400. Mistakes: `POST /api/competitions/:id/matches/:mid/reopen` (a kachinuki match, or any match a withdrawal OR default win decided -- kiken, kiken-injury, fusenpai, or fusensho; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a match-level fusensho whose `decisionBy` side is STILL barred by an earlier, unrelated withdrawal: `engine.reopenTargetStatus` (`kachinuki.go`) sends that one to `scheduled` instead of `running`, because going straight to `running` would strand the operator behind the eligibility gate with no way to start it; once that competitor is reinstated, the identical fusensho reopens to `running` like any other decision; a reopen that lands `scheduled` takes no court, so neither court gate applies to it (the target status is decided before either gate runs); in a knockout, when the winner of the match being reopened or corrected went straight through a bye (a hidden, unnumbered match the draw leaves with one empty side, so nobody can open or reopen it directly), the write takes that winner back out of it (or re-seats the new one) and answers for the first real match past it (`propagatedDownstreamOf`), rather than refusing; a POOL reopen in a mixed competition is never refused for a seated knockout, because the write that finishes it runs the pool requalification check (`engine/pool_requalify.go`); `POST .../matches/:mid/requeue-blocker-and-reopen` atomically frees the target's court (requeuing the blocking match) and reopens under one court-lock hold; `DELETE .../matches/:mid/kachinuki-bout` removes the current trailing unscored auto-appended pairing. Every numbered bout row of a team match shows the fighter's squad number and, when the position is unnamed, a typeable name box (operator ruling 2026-09-15, bc-dnst). The number belongs to the squad member, not the row: team order can change between matches, so picking a different member into a position moves that member's number onto the row. A fresh team's seeded blank slots are members with a number and no name, so an unnamed fixed-order position shows the number of the blank member seeded for it, and a name typed there names that member through `resolveMemberIdsForPositions` (rename, never a new mint while a blank slot with that number exists), so the name stays attached to the number it was shown with. A team's squad is seeded with team size + 2 numbered slots (`SquadReserveSlots`, two reserves), and the row's list shows every slot with its number, blank ones by number alone, so any number the team can field can be picked and then named; a picked slot is written by id (`buildInlineLineupWrite` with a member) and a name typed into it renames that member (operator ruling 2026-09-15, bc-dnst). That makes a picked-but-unnamed slot a real placement everywhere: every lineup writer (the score sheet row, the Up Next "Enter lineup" panel, the Lineups page) sends the position with an empty name and the member id rather than dropping it; `TeamLineup.OrderedMembers` counts a position occupied when it carries a name OR an id, so such a fighter is on the kachinuki roster; and `AdvanceKachinuki` decides which side stayed on through `domain.SubBoutAttribution`/`AttributeWinnerSide` (ids first), because the client records a nameless winner as its TEAM name plus the member id and a name comparison alone can never see it stay on. A lineup may not place one member id at two positions: `memberPlacedElsewhere` (lineup_resolver.jsx) is the ONE client predicate, asked by every writer before it writes (`buildInlineLineupWrite`, the panel's save, the Lineups page's add path) and rendered as the list filter `rosterWithoutPlacedElsewhere` on every picker, so the offer and the refusal (" is already at ") never disagree; `ErrLineupDuplicateMember` from `ValidatePositions` (a 400 naming the two positions) is the server backstop. A lineup that already held a duplicate before this guard existed is repaired by clearing one of the two rows. Under kachinuki only the FIRST row is a lineup position; every later row's pick rides the bout itself (`pickManual` with the member id, never a lineup PUT), the list there is unfiltered because the fighter who stayed on legitimately repeats, and a name typed over a picked blank member renames that member and keeps its id. Which side stayed on is decided through `domain.AttributeWinnerSide`, whose id tier names a side when the winner id equals THAT side's id even if the other side carries none (JS mirror `attributeWinnerSide`, result_slot.jsx), with the same name-only residue `RetiredPlayersFromBoutLog` keeps for a row no id can settle; `RetiredMemberSet.Count` tallies ids plus id-only names so a nameless fighter is counted. Before the draw a team has no number, so the Lineups page, the one operator surface for a team's people, labels a slot through `squadSlotLabel` ("Slot 3"), a sibling of `squadMemberLabel` in the same owner; spectator surfaces keep `squadMemberLabel` and stay bare before the draw. The competition Settings page's Squad members section was REMOVED on 2026-09-15 by operator decision (bc-dnst): the Lineups page is the one home for a team's people, and its "Team members" list's "Clear name" control (beside Rename) is where clearing a member's name now lives. The Lineups page's "+ Add" confirm is derived from the resolver's own three outcomes (an existing member is selected with no confirm, a blank slot is named, else a member is minted). A rename or a clear reaches every stored lineup: `RenameTeamMember`/`ClearTeamMemberName` rewrite the name of every lineup position holding that member by id (`renameMemberInLineupsLocked`), because the score sheet and the export read the lineup's stored name; fought bouts keep their frozen names. The Excel export indexes lineup positions by member id as well as by name (`memberKey`, `resolveKachinukiBoutPosition`) so a nameless fighter still gets its position label. - **Schedule Estimator** (`internal/engine/schedule.go`): `EstimateSchedule(EstimateInput) ScheduleEstimate` produces total/per-court minutes from match duration × multiplier × slowest-court buffer. Exposed via stateless `GET /api/schedule/estimate` on both the CLI web server and the mobile app. - **Store Transactions** (`internal/state/transactions.go`): `Store.WithTransaction(compID, fn)` holds the per-comp lock once across multiple load/save operations. Use the `StoreTx` handle inside `fn`. Do NOT call public Store methods (they would deadlock the non-reentrant mutex). - **Cache invalidation: never key a cache on mtime alone** (`internal/state/store.go`): filesystem mtimes come from the kernel coarse clock (~1ms granularity), so two writes inside one tick are indistinguishable and an mtime-keyed cache serves pre-write data (mp-n6ke: stale standings vs fresh matches injected a phantom tiebreaker bout and stalled bracket advancement; mp-p7n was the same class). Each `fileCache` therefore carries a monotonic `version` counter, exposed as `Store.FileVersion(compID, filename)`; `engine.CalculatePoolStandings` requires BOTH version and mtime to match (the counter catches same-millisecond in-process writes, mtime still catches out-of-process edits). **If you add a writer for a file some cache derives from, call `s.bumpFileVersion` AFTER the bytes land**: an extra bump only costs a recompute, a missed bump serves stale data, and bumping before the write lets a reader stamp OLD bytes with the NEW token. Existing bumps sit at the chokepoints every writer funnels through (`savePoolMatchesLocked`, `saveOverridesLocked`) plus draw deletion and transaction abort. Two further rules follow from the counter being the cache key: **every read path must validate it, not just the fast path** (the `CalculatePoolStandings` single-flight loser returned the winner's entry unvalidated, and the winner stamps *pre-compute* tokens, so it could predate the loser's own write), and **the counter must stay monotonic for the life of the process, including across a competition being deleted and recreated** (IDs are name slugs, so `DeleteCompetition` calls `discardCompCacheBodies` to clear the cached bodies while leaving the counters climbing, rather than `compCache.Delete`, which reset them to 0 and let a recreated competition match its predecessor's stale entry). Prefer a deterministic counter over a sleep, which only hides the race. @@ -183,11 +185,11 @@ The rules below were settled by operator rulings and are pinned by tests. Each h #### Decision types -`internal/domain/decision.go` defines the canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (per-bout default win), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"`, and `"ippon-shobu"`. The last one is accepted by `Decision.Valid` but has no writer in the tree: supplementary pool tie-break bouts are identified by their match id (`Pool X-TB-N`, see `engine/tiebreaker.go`), not by this decision. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (`Decision.UnmarshalYAML`). +`internal/domain/decision.go` defines the canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (a per-bout default win within a team encounter, AND the match-level default win an operator records via `POST /decision` to close a barred competitor's remaining scheduled match -- `domain.IsWithdrawalDecisionStr` deliberately excludes it, since it is a default win awarded on someone ELSE's withdrawal rather than a withdrawal of the match it is recorded on; `domain.IsDefaultWinDecisionStr` includes it. `decisionBy` on either usage names the side that withdrew, did not appear, or is barred -- the LOSING side -- never the side the decision favours; the write then makes the OTHER side the winner (`scoring_tx.go`)), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"`, and `"ippon-shobu"`. The last one is accepted by `Decision.Valid` but has no writer in the tree: supplementary pool tie-break bouts are identified by their match id (`Pool X-TB-N`, see `engine/tiebreaker.go`), not by this decision. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (`Decision.UnmarshalYAML`). #### The middle is a closed set -Visual marks follow the score sheet's layout, split into two kinds. MIDDLE marks occupy the centre "vs" cell (the score-string centre): exactly one of `X` (tie), `(E)` (overtime, always bare: `periodCount` is recorded but never displayed), `(DH)` (rep bout). They are mutually exclusive by rule: a match that went to encho cannot end tied, so X beats (E); a daihyosen is one-point sudden death, so DH bouts have no encho. RESULT marks ride beside the competitor they name: `Ht` (hantei winner), `Kiken` (withdrawer), `Fus.` (no-show; the Excel export also marks the fusensho winner, the JS uses a bout badge instead). +Visual marks follow the score sheet's layout, split into two kinds. MIDDLE marks occupy the centre "vs" cell (the score-string centre): exactly one of `X` (tie), `(E)` (overtime, always bare: `periodCount` is recorded but never displayed), `(DH)` (rep bout). They are mutually exclusive by rule: a match that went to encho cannot end tied, so X beats (E); a daihyosen is one-point sudden death, so DH bouts have no encho. RESULT marks ride beside the competitor they name: `Ht` (hantei winner), `Kiken` (withdrawer), `Fus.` (no-show, and also the winner of a fusensho match-level default win; `sideMarks` in bracket.jsx and `SideMarks` in internal/export/suffix.go agree on this, so a fusensho match reads identically on every surface and in the exported workbook). Web score strings read `[left cell] [middle] [right cell]` with `vs` as the plain middle and `–` (or empty) meaning no points. Never digits: ippon renders as waza letters only, e.g. `M vs –`, `M (E) K`, `M Ht (E) K`. The dash is a CELL value only. The middle never shows a dash, so unplayed and pending match rows read `vs` too. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index f24620993..517db98bb 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -239,6 +239,7 @@ classDiagram +BracketMatch[][] Rounds +BracketMatch ThirdPlaceMatch +string[] DrawOrder + +bool TimesSettled } class BracketMatch { diff --git a/docs/screenshots/decision-reason-after-reopen.png b/docs/screenshots/decision-reason-after-reopen.png deleted file mode 100644 index a8f9fe082..000000000 Binary files a/docs/screenshots/decision-reason-after-reopen.png and /dev/null differ diff --git a/docs/screenshots/kachinuki-knockout-tie-encho.png b/docs/screenshots/kachinuki-knockout-tie-encho.png index 8d903c812..3fe123ac8 100644 Binary files a/docs/screenshots/kachinuki-knockout-tie-encho.png and b/docs/screenshots/kachinuki-knockout-tie-encho.png differ diff --git a/docs/screenshots/kachinuki-reopen-reason.png b/docs/screenshots/kachinuki-reopen-reason.png deleted file mode 100644 index dd075ae80..000000000 Binary files a/docs/screenshots/kachinuki-reopen-reason.png and /dev/null differ diff --git a/docs/screenshots/knockout-bracket-five.png b/docs/screenshots/knockout-bracket-five.png new file mode 100644 index 000000000..b6d1b20f6 Binary files /dev/null and b/docs/screenshots/knockout-bracket-five.png differ diff --git a/docs/screenshots/knockout-bracket-in-play.png b/docs/screenshots/knockout-bracket-in-play.png new file mode 100644 index 000000000..9ccdd2bcb Binary files /dev/null and b/docs/screenshots/knockout-bracket-in-play.png differ diff --git a/docs/screenshots/viewer-bracket.png b/docs/screenshots/viewer-bracket.png new file mode 100644 index 000000000..9ad22c36a Binary files /dev/null and b/docs/screenshots/viewer-bracket.png differ diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 19a8a26a2..427443445 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -20,10 +20,12 @@ An injury kiken can be reinstated later by the operator if the competitor recove **Fusenpai** is a no-show default loss: the competitor who did not appear forfeits the match. -**Fusensho** is a per-bout default win, used in team matches when the opposing team fields a vacant position. +**Fusensho** is a per-bout default win, used in team matches when the opposing team fields a vacant position. It is also what the app records for the match itself when you give the opponent of an already-barred competitor a default win; refer to [Matches after a competitor is barred](#matches-after-a-competitor-is-barred). When neither team has a fighter for a position, there is no default win: record that bout as a **Tie**. A default win (kiken, fusenpai, or fusensho) awards the winner the match points without a technique, and each awarded point is recorded as a circle. This follows the FIK Regulations of Kendo Shiai and Shinpan (Article 32 and the score board appendix). The winner's score shows two circles, or a single circle during overtime, when only the one deciding point is awarded. Any point the withdrawing side had already scored stays valid and is kept on the sheet. In a team match, the sub-bouts already fought are preserved and still count toward the standings. +When the default win (kiken, fusenpai, or fusensho) decides a regular team match, every bout that has no result yet is also credited to the other team, 2-0 each: one individual victory and two points, the same figures a fusensho bout scores. This shows up wherever the match does: the individual victories and points totals, the standings, the bout rows, and the exported workbook. Bouts already fought keep their own results. Kachinuki encounters are unaffected, because they end on whichever bout the operator chooses, not on every position being filled. + ## Hikiwake A hikiwake is a draw. It applies in pool, league, and Swiss matches, and in the individual bouts of a team encounter in any phase. A knockout match itself cannot end in a draw. A hikiwake contributes to the standings separately from wins and losses. To record one, tap **Mark draw** between the two sides in the score editor; in a team encounter, tap **Tie (hikiwake)** on the bout's row. @@ -32,13 +34,15 @@ A hikiwake is a draw. It applies in pool, league, and Swiss matches, and in the Encho is the extra period played when a match is level at the end of regulation and must still produce a winner. That is usually a knockout match, but an individual pool match can also go to encho when the tournament's rules call for it. A team pool encounter is not extended: a tie there is a draw, and teams still level on the pool standings play a daihyosen instead. Encho follows ippon-shobu rules: the first competitor to score wins. +An individual knockout match cannot be finished level. While the score is tied, the **Finish** button reads **Needs a winner** instead: fight encho, then record a hantei if the competitors are still level after it. + In the score editor, open the **Overtime** control and tick **Encho started**. A counter appears so you can record how many overtime periods were fought, using the **+** and **-** buttons. The counter starts at 1 and has no upper limit. How many periods are fought, and how a match still level after them is finally settled, is the shimpan's call. That may be a judges' decision for an individual bout, or a daihyosen for a team encounter. Record what actually happened on court, however many periods that took. ![The score editor during a second overtime period: the Encho started box is ticked, the period counter reads two, and the header shows the overtime readout.](../../screenshots/mobile-encho-overtime.png) Completed results follow the paper score sheet's layout. The centre, between the two sides' points, only ever carries one mark: **vs** when no special mark applies, **X** for a tie, **(E)** for a match that went to overtime, or **(DH)** for a team encounter sent to a representative bout. The marks never combine: a match that went to encho cannot end in a tie, and a daihyosen has no overtime. A side with no points shows a plain dash. A match won by men in regulation reads **M vs –**, and the same win in overtime reads **M (E) –**, on the court console and the public viewer. The exported results workbook carries the same **(E)** marker in its centre column, but leaves a no-points cell blank rather than dashed. The marker is the same however many overtime periods were fought, because the counter records the number for the tournament log but results never show it. -Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so for those the marks appear on the bracket and in the exported workbook. +Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show, and also **Fus.** next to the winner when the match itself was decided by a fusensho default win. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so for those the marks appear on the bracket and in the exported workbook. ![Completed results on the public viewer: an overtime win reading M, the E marker, then a dash for the side with no points, and a hantei result with the Ht mark beside the winner.](../../screenshots/viewer-result-encho.png) @@ -61,8 +65,57 @@ Chusen is the last resort when two or more tied teams have played a round of dai When chusen is required, the **Pools** tab (labelled **League** for league competitions) shows a **"Chusen (drawing lots) required"** panel listing the tied teams. Draw lots offline, enter each team's finishing position in the panel, and record it to settle the order and let the competition advance. +In a competition with pools and then a knockout, the panel can also appear after the knockout has started, when a corrected pool result leaves a tie that only chusen can settle. The knockout match that the tied place feeds waits until you record the positions. + +The positions you record decide who goes through. If they change who holds a qualifying place after the old holder has already fought a knockout match, the app asks first, in the same way as for a corrected pool result: it names who moves and which match was fought, and **Apply and reopen** records the positions and reopens that match for the new qualifier. If that knockout match is being fought right now, the positions are not recorded until it is finished or sent back to the queue. Refer to [Correct a pool result after the knockout has started](scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). + Chusen is the only place ranks are set by hand, and it happens only when the bouts themselves cannot decide the order. +### Change a chusen recorded in the wrong order + +After you record a chusen, the same tab shows a **"Chusen (drawing lots) recorded"** panel with the order you recorded, for example "Drawing lots: 1st Kyoto, 2nd Osaka, 3rd Nara". If you entered the order wrongly, select **Change**. The entry opens again with the recorded positions filled in. Enter the order the lots actually gave and record it, or select **Cancel** to leave the recorded order as it is. + +A changed order can change who qualifies from the pool. The app treats it the same as a first chusen, and checks the new order as a whole, so it only asks about a team whose place actually changes: if a team moves out of a knockout match that has already been fought, it asks first, and **Apply and reopen** reopens that match for the new qualifier. If that match is being fought right now, the change is not recorded until the match is finished or sent back to the queue. + ## Competitor eligibility after a decision A kiken or fusenpai marks the competitor who withdrew or did not appear as ineligible for further matches. The app blocks starting an ineligible competitor, so a withdrawn competitor cannot silently re-enter the draw. An injury kiken (FIK Article 30) can be reversed: once the operator reinstates the competitor, the eligibility block is lifted and they can fight again. + +### Matches after a competitor is barred + +A barred competitor's other scheduled matches cannot be started, so the app steers everyone around them instead of leaving a dead entry in the queue. They are skipped by **Up next**, **Finish + Start Next**, the shiai-jo display's next line, the lobby view, and on-deck alerts, so nobody is nudged to warm up for a bout that cannot be fought. On the public schedule, the pools, and the bracket, the barred competitor's name carries a **Withdrawn** mark. + +On the court console queue, the Scores tab, and the score editor, the match instead carries a note explaining why (for example "Kyoto withdrew: record the default win.") and a button naming the opponent, for example **Record default win for Sato**. One tap records the match as a fusensho for the opponent, without touching the barred competitor's eligibility record. For an injury kiken, a **Reinstate** button naming the barred competitor sits alongside it, restoring eligibility so the match can be fought instead of defaulted. Once recorded, the match reads, for example, "Recorded: Default win (fusensho) for Sato. Kyoto had withdrawn.", with a **Clear default win** action if it was recorded by mistake: it reopens the match, back to the queue if the competitor is still barred (record the default win again, or reinstate them first; a match going back to the queue takes no court, so another match running on that court does not stop it), or back to in progress, ready to score, if they have since been reinstated. + +If both competitors due to meet are already barred, neither can fight. In a pool or league match, record it as drawn with **Record as drawn (neither can fight)**: neither side receives a win or points. A knockout match has no equivalent, since the bracket needs a winner; correct the earlier withdrawal, or the draw, instead. + +**Clear withdrawal and reopen** on the original withdrawal (refer to [Correcting a withdrawal recorded by mistake](#correcting-a-withdrawal-recorded-by-mistake)) lists the later matches that were given a default win because of it, each with its pairing, so you know what is affected. Those matches keep their default win; reopen any of them individually with **Clear default win** once the competitor is eligible again. + +## Correcting a withdrawal recorded by mistake + +A kiken or fusenpai entered in error can always be fixed. Open the match in the score editor. It shows what is recorded, for example "Recorded: Kiken – Voluntary, Kyoto withdrew", and marks the side that withdrew with **Kiken** or **Fus.** beside its name. In a team match the result below the bouts names the recorded winner and the decision, and the bouts after the withdrawal show as default wins for the other team, even though nobody actually fought them. Pick the fix that matches what happened: + +- **The wrong competitor or team was marked.** Record the withdrawal again for the other side. In an individual match, use the **Kiken – Voluntary**, **Kiken – Injury** or **Fusenpai** button in the editor's **Decision** row. In a team match, open **Withdrawal or no-show** and use the same buttons there. The side you first marked is eligible again, and the other side becomes the one that withdrew. +- **Nobody withdrew.** Choose **Clear withdrawal and reopen**. It takes one tap and needs no reason; the editor says what it will do beside the button. A withdrawal gives the opponent the win by default, so without it the match was never decided. The match goes back to in progress and the editor stays open on it. The withdrawn competitor or team can compete again. Score the rest of the match and finish it as usual. + +What the reopen keeps depends on the match: + +- **A team match** keeps every bout already fought. +- **An individual match** keeps the points the withdrawn competitor had struck. It does not keep the winner's points. Recording the withdrawal replaced them with the default-win circles, and clearing the withdrawal removes those circles, so enter the winner's points again before you finish. The same applies to a team's representative bout, which is scored in the individual editor, and it also keeps who fought it for each team. The editor says this beside the button, before you tap it. + +A kachinuki team match that ended with a withdrawal or no-show shows the same **Clear withdrawal and reopen** in place of its **Reopen match** button, because the reopen also makes the withdrawn team eligible again. A kachinuki match that ended any other way keeps **Reopen match**. + +Removing a withdrawal makes the withdrawn competitor or team eligible again, whichever kind of withdrawal it was, because a withdrawal entered by mistake never happened. This includes a voluntary kiken, which otherwise cannot be reversed. + +**Save correction** on its own never removes a withdrawal. It saves the points or bouts you enter and keeps the withdrawal, its winner and the competitor's eligibility as they were recorded. In an individual match the winner's default-win circles are shown but cannot be changed. Only the withdrawn competitor's points can be corrected, and they are limited to one point, because a competitor with two points has already won the bout. + +A fix can change who goes through to later matches, so the app warns you first and you can cancel or go ahead: + +- Recording the withdrawal for the other side warns you when either side has already started a later match. +- Clearing a withdrawal in a knockout match warns you when the next match has already been played. If you go ahead, that next match is reopened so it can be fought and scored again, and the editor names it once the reopen is done. A round after it that nobody has played yet stops showing its winner. If a withdrawal decided that next match, its withdrawn competitor is eligible again too. + +In a knockout, a next match that is still in progress must be finished or sent back to the queue before a withdrawal can be cleared. If another match is running on this match's court, the app names it and offers to send it back to the queue, which clears any score entered for it. + +In a pool of a competition that ends in a knockout, clearing a withdrawal is not refused because of the knockout. Clearing it moves nobody in the bracket: the pool simply has an unfinished match again. When you finish that match, the app checks whether the result changes who qualifies from the pool. The same result is saved as usual. A result that moves someone who has already fought in the knockout is handled like any pool correction: you are shown who moves and which knockout matches are affected before anything is saved. Refer to [Correct a pool result after the knockout has started](scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). + +Matches that were already given a default win because of the withdrawal keep it; refer to [Matches after a competitor is barred](#matches-after-a-competitor-is-barred) for how to list and correct them. diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index a0fa89d9f..ed8a9c69e 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -31,19 +31,23 @@ Once a match is scored it moves to the **Completed** list on the court console, What Correct offers depends on the format: - For most matches, the editor reopens with the recorded scores ready to edit. Adjust the ippons, fouls, or winner, then use **Save correction**. A short reason is required so the change stays traceable. -- For a kachinuki team encounter, the editor shows the recorded bouts with a **Reopen match** button. Reopen is immediate: one tap returns the encounter to running with its bout log intact, so you can carry on or fix a bout. A reason is still kept with the result, but the app asks for it when you end the match again, not on the way back in. Correcting a mistake never costs you more than a tap. +- For a kachinuki team encounter, the editor shows the recorded bouts with a **Reopen match** button. An encounter that ended with a withdrawal or a no-show shows **Clear withdrawal and reopen** instead; refer to [Correcting a withdrawal recorded by mistake](recording-decisions.md#correcting-a-withdrawal-recorded-by-mistake). Reopen is immediate: one tap returns the encounter to running with its bout log intact, so you can carry on or fix a bout. No reason is needed, and none is asked for when you end the match again, whether you finish on a scored bout or record a withdrawal or a no-show. Correcting a mistake never costs you more than a tap. - You are asked for that reason however you end it. If the encounter finishes with a withdrawal or a no-show instead of a scored bout, the kiken or fusenpai panel asks for the reason in the same way. The **Record** button stays unavailable until you give one. +Reopening puts the encounter back into play, and a court can only run one match at a time. If another match is already running on that court, the editor names it and offers two ways forward. Leave it running, or clear its score, send it back to the queue, and reopen in a single step. Sending a match back to the queue clears any score already entered for it, so if that match is part way through, finish it first instead. - ![The Fusenpai panel on a reopened encounter: a side picker, a required reason box prompting "Why is this match being ended again?", a note that the match was reopened so ending it again needs a reason, and a Record button held back until the reason is filled in.](../../screenshots/decision-reason-after-reopen.png) +If the correction changes who won a knockout match, the later rounds update to follow the new winner. If the next match, or the bronze (3rd-place) match, has already been fought and scored, the app refuses the correction and names the match that is blocking it by its number and round, for example Match 3 (Final): the round is the heading of the bracket column you find it under. You can confirm anyway. Confirming applies the correction and reopens the named match where it is: its recorded result, including its bouts, is cleared, and the new pairing must be fought and scored again. The match was already played, so it stays where it is and nothing in the queue changes. A semifinal is the one case that names two matches at once, because it feeds both the final and the bronze match; confirming then reopens both. A round further on that nobody has played yet stops showing the winner of the reopened match. A round further on that has already been played is not changed yet: when you enter the result of the match you have just re-fought, the app asks you the same question again for that round, so you work forward one round at a time. A later slot that was only filled by a bye, and never fought, does not block a correction; it simply updates to follow the new winner. A match that is under way is not blocked or cleared either: it keeps what has been scored so far, now shown under the new competitor's name, and whoever is at the shiaijo finishes it as normal. When you are done, use **Back to court** to return to the live match. - Ending it on a scored bout asks in the same dialog, with **Ended by mistake** offered as the first reason: +### Correct a pool result after the knockout has started - ![Ending a reopened encounter: the editor asks for a reason, with "Ended by mistake" already selected and a note box beside it](../../screenshots/kachinuki-reopen-reason.png) +In a competition that has pools and then a knockout, a pool result decides who goes through to the knockout. You can still correct it after its qualifiers have been placed in the bracket, even after they have fought there. -Reopening puts the encounter back into play, and a court can only run one match at a time. If another match is already running on that court, the editor names it and offers two ways forward. Leave it running, or clear its score, send it back to the queue, and reopen in a single step. Sending a match back to the queue clears any score already entered for it, so if that match is part way through, finish it first instead. +- If the correction does not change who holds any qualifying place in that pool, it is saved as usual. +- If it changes a qualifier whose knockout match has not been played yet, it is saved and the new qualifier takes the old one's place in the bracket. +- If it changes a qualifier who has already fought a knockout match, the app shows you who moves and which knockout match was fought before anything is saved, for example: "Changing this result moves Pool A's 1st place from Aoki to Sato, who takes Aoki's place in the knockout. Match 9 (Quarterfinals) was already fought with Aoki: it will be reopened, its result cleared, and it must be fought again." A knockout match is always named with its round, so it cannot be mistaken for the pool match you are correcting, which has its own Match 1, Match 2 and so on. Cancel to leave everything as it was, or choose **Apply and reopen**. The correction is then saved, the new qualifier is placed in that match, and the match is reopened where it is, to be fought and scored again. If a withdrawal decided that match, the competitor it barred can compete again. A later round that nobody has played yet stops showing the old winner. A later round that has already been played is not changed now: when you enter the result of the re-fought match, the app asks you about that round in the same way. +- If a knockout match that the change reaches is being fought right now, the correction is refused: "Match 9 (Quarterfinals) is being fought now. Finish it or send it back to the queue, then save again." +- If the correction leaves a qualifying place tied, that place goes back to waiting in the bracket until the tie-break is fought, and the tie-break winner is placed there. A knockout match on that place that was already fought is reopened, and it is fought again once the tie-break decides who holds the place. -If the correction changes who won a knockout match, the later rounds update to follow the new winner. If the next match, or the bronze (3rd-place) match, has already been fought and scored, the app refuses the correction and names the match that is blocking it. You can confirm anyway. Confirming applies the correction and reopens the named match where it is: its recorded result, including its bouts, is cleared, and the new pairing must be fought and scored again. The match was already played, so it stays where it is and nothing in the queue changes. A semifinal is the one case that names two matches at once, because it feeds both the final and the bronze match; confirming then reopens both. Rounds further on are not touched yet. When you enter the result of the match you have just re-fought, the app asks you the same question again for the round after it, so you work forward one round at a time. A later slot that was only filled by a bye, and never fought, does not block a correction; it simply updates to follow the new winner. A match that is under way is not blocked or cleared either: it keeps what has been scored so far, now shown under the new competitor's name, and whoever is at the shiaijo finishes it as normal. When you are done, use **Back to court** to return to the live match. +A correction made while offline never carries a confirmation with it. If it reaches the server after a knockout match has been played with the old qualifier, it is not applied and you get a "Not saved" notice; make the correction again once you are online, and the app asks you then. ## Matches waiting on earlier results @@ -72,6 +76,10 @@ This applies to a correction as well. A correction you save while offline is hel ## Team matches and kachinuki +In a regular team match every bout is fought, so **Finish** does not record the encounter while a bout has no result. It names the bouts that still need one. Give each of them a score, a **Tie**, or a **Fusensho**. Every bout needs a result or a decision, whoever the lineups name. If only one team has a fighter for a position, record a **Fusensho** for that team. If neither team has a fighter for a position, record that bout as a **Tie**: it counts as an individual draw for both teams. **Save correction** follows the same rule. + +A team match that ended with a withdrawal (**Kiken** or **Fusenpai**) is the exception. When you correct a bout fought before the withdrawal, **Save correction** saves the bouts you enter and keeps the withdrawal exactly as it was recorded: the same winner, and the same effect on the team's eligibility. The bouts nobody fought after the withdrawal are not left empty: they are credited to the other team as a default win, 2-0 each, exactly as a fusensho bout is scored; refer to [Fusenpai and fusensho](recording-decisions.md#fusenpai-and-fusensho). An individual match corrected after a withdrawal works the same way: the withdrawn competitor's points you enter are saved, and the winner's default-win circles and the withdrawal are kept. If the withdrawal itself was recorded against the wrong team, open **Withdrawal or no-show** in the same editor and record it again for the other team. If nobody withdrew, choose **Clear withdrawal and reopen**: the match goes back to in progress with the bouts already fought kept, the team is eligible again, and you score the remaining bouts and finish it. For both fixes, refer to [Correcting a withdrawal recorded by mistake](recording-decisions.md#correcting-a-withdrawal-recorded-by-mistake). + Kachinuki team encounters are scored one bout at a time. Score the current bout, then use **Record bout** to keep going (the winner stays on and the next pairing is added) or **End match** to finish on the last scored bout. You decide when the encounter is over, so end it when a team is out of fighters or when the other team's Taisho has been beaten. On a tied bout you also decide what the tie means, according to the [kachinuki mode](../organisers/team-tournaments.md#kachinuki-modes) in force: @@ -80,7 +88,7 @@ On a tied bout you also decide what the tie means, according to the [kachinuki m - **Encho** keeps the same pair fighting when the pairing must produce a result. It is available in any stage. - **End match** records a drawn encounter in pools or leagues. In a knockout a tied last bout cannot end the match, so continue with **Record bout** or **Encho**. -If you finish too early, open the completed match and use **Reopen match** to carry on. Reopening takes a single tap; you are asked for a reason when you end the match again. The bouts you have already fought stay on screen as read-only rows above the current bout. The encounter reads like a regular team sheet, and you can check the winner-stays-on order at a glance. **× Remove this bout** takes back an unscored pairing the app added by mistake without ending the encounter. +If you finish too early, open the completed match and use **Reopen match** to carry on. Reopening takes a single tap, and ending the match again asks for no reason. The bouts you have already fought stay on screen as read-only rows above the current bout. The encounter reads like a regular team sheet, and you can check the winner-stays-on order at a glance. **× Remove this bout** takes back an unscored pairing the app added by mistake without ending the encounter. To fix a bout you have already recorded, tap its row: it reopens in place with the scoring controls. If your change flips who won, the app flags the later bouts for you to check. Refer to [Scoring a kachinuki encounter](../organisers/team-tournaments.md#scoring-a-kachinuki-encounter) for the full flow. diff --git a/docs/user-guide/organisers/knockout-draw.md b/docs/user-guide/organisers/knockout-draw.md index a88d6d6e6..c870c693a 100644 --- a/docs/user-guide/organisers/knockout-draw.md +++ b/docs/user-guide/organisers/knockout-draw.md @@ -157,8 +157,9 @@ Everything else about the prefix works the same as the pooled-format rule: you c Byes are decided **inside each block**, never across the draw as a whole. -- A block with an **even** number of competitors gets **no bye at all**, whatever seeds you have set. There is nothing to award. -- A block with an **odd** number of competitors gets **exactly one** first-round bye. +- A block that fills its slots exactly (two, four, eight or sixteen competitors) gets **no bye at all**, whatever seeds you have set. There is nothing to award. +- A block with an **odd** number of competitors has **exactly one** bye to award: a place that skips the first round and meets the winner of a first-round bout. +- In any block that does not fill its slots, some competitors start a round later than the others. The Bracket page shows each of them first in the match they fight, as the European Championship sheets print them. Where a block has a bye, it goes to a pool winner, in this order: @@ -170,10 +171,12 @@ The second criterion is fatigue compensation. In a round-robin pool of four, a c A block does not have to hold a pool winner. From two qualifiers per pool upwards there are more qualifiers than there are pools, so a block can be filled entirely by qualifiers that crossed in from other shiai-jo. Its bye then goes to the best of those instead: runners-up first, ranked by their own pool's place in the bye order, then third qualifiers. The same thing happens where a bye lands in a part of a block that holds no pool winner, and it can also happen because keeping a pool's own qualifiers apart outranks this order. -In later rounds, a block with an odd number of survivors produces a bye there too. That one falls to whichever match winner reaches the empty slot, so there is nothing to allocate and nothing for you to choose. +Every match is placed in the round its distance from the final gives it, as the European Championship sheets print them. So in a block of five only two competitors fight in the first round. The bye goes to one of the other three, who then meets that winner, and the remaining two meet each other in the same round, both one step from the block's final. None of the three appears in the first column; only the one place is awarded by the order above. A block of six sits in eight slots the same way: four competitors fight in the first round and two start in the second. Nothing else is allocated and there is nothing for you to choose. + +![The Bracket page for a knockout of five competitors on one shiai-jo. The Quarterfinals column holds only the one first-round bout, M1. In the Semifinals, M2 is between two competitors who start there, and M3 puts the third against the winner of M1, whose line joins the row the winner will fill. The Final is M4.](../../screenshots/knockout-bracket-five.png) !!! note "Empty slots are not the same as byes" - A bracket is drawn on a power of two, so a block of five competitors sits in eight slots. Only one of those competitors gets a named bye. The remaining empty slots pair off with each other and are never printed or displayed. + A bracket is drawn on a power of two, so a block of five competitors sits in eight slots. One of those competitors is awarded the bye. The remaining empty slots pair off with each other and are never printed; the two competitors beside them simply start a round later. ## Seeding diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 4a7b8efe1..b48bf3b45 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -164,7 +164,7 @@ The **Participant list** panel (labelled **Team list** for team competitions) co - Without display name: `Name, Dojo[, Dan grade]` - With display name (zekken): `Name, Zekken display name, Dojo[, Dan grade]` -Click **Paste clipboard** to read a tab-separated selection from the clipboard and convert it automatically. Click **Apply changes** to save the list; the box clears once the list is saved. The box is for a new or replacement list, not a copy of the saved roster: applying while a roster already exists asks you to confirm, because the new list replaces the current one (anyone not in the new list is removed, and names already on the roster keep their id and seed). +Click **Paste clipboard** to read a tab-separated selection from the clipboard and convert it automatically. Click **Apply changes** to save the list; the box clears once the list is saved. The box is for a new or replacement list, not a copy of the saved roster: applying while a roster already exists asks you to confirm, because the new list replaces the current one. A competitor or team matched to one already on the roster, by name and dojo, keeps their id and seed; for a team, their team members and lineups are kept too. Anyone not in the new list is removed, and, while the competition is still in setup, a removed team's members and lineups are deleted with it. Applying the list gives every competitor a participant id. The id is not shown on the row; the **Overview** names any competitor still without one. Competitor numbers come later: every competition numbers its competitors when the draw is generated, pool by pool for a pooled competition or down the bracket for a knockout-only one, and nothing is shown before that. Refer to [Competitor numbers](pool-draw.md#competitor-numbers). A competition saved by an earlier version of the app whose list has no ids shows a notice on its **Overview** naming the competitors: apply the list once to assign them. The draw does not run until every competitor has an id. @@ -227,10 +227,12 @@ A draw-ready competition is already listed on the shiai-jo operator views, since The **Pools** tab shows standings for every pool. Ranks are computed automatically from match results. Operators do not edit them by hand, with one exception: chusen (drawing lots), the last-resort tie-break for a consequential team-pool tie that a daihyosen cannot settle. Refer to [Recording decisions](../court-operators/recording-decisions.md). When a daihyosen settles a tie that determines pool advancement, the winning side carries a **DH** badge in the standings. -Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. +Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. A pool result corrected later still moves its qualifiers in the bracket; refer to [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). ![The Pools tab: each pool as a card with its standings in draw order, every competitor carrying their number and a rank badge, the pool's matches with their results, and the head-to-head grid beneath.](../../screenshots/mobile-pool-standings.png) +![The Bracket tab of a knockout of ten competitors, part-way through. The first column holds only the two first-round bouts, M1 and M2; everyone else first appears in a quarterfinal. M1 to M4 are scored, each winner ticked and joined to the row they fill. Quarterfinal M5 is marked NOW, the winners of M3 and M4 already face each other in semifinal M7, and the Final waits for the winners of M7 and M8.](../../screenshots/knockout-bracket-in-play.png) + For the four competition formats and the Swiss round-by-round flow, refer to [Formats](formats.md). For team lineups and team scoring rules, refer to [Team tournaments](team-tournaments.md). diff --git a/docs/user-guide/organisers/team-tournaments.md b/docs/user-guide/organisers/team-tournaments.md index 210bcc59f..9b21eab51 100644 --- a/docs/user-guide/organisers/team-tournaments.md +++ b/docs/user-guide/organisers/team-tournaments.md @@ -69,7 +69,7 @@ appears once the draw is generated. ### Incomplete and uneven teams -Team sizes are not fixed. A lineup can leave any position empty. Teams in the same competition can field different numbers of fighters, and the app never blocks a save or disqualifies a team over a vacancy. Fill in as many positions as each team brings and save. You can edit a lineup at any time, including after the match has started, so you can complete or adjust the order as a round is running. An empty position also shows a box directly on its score sheet row, labelled with its own number, so you can name the fighter without leaving the bout you are scoring. +Team sizes are not fixed. A lineup can leave any position empty. Teams in the same competition can field different numbers of fighters, and the app never blocks a save or disqualifies a team over a vacancy. Fill in as many positions as each team brings and save. You can edit a lineup at any time, including after the match has started, so you can complete or adjust the order as a round is running. Every bout still needs a result or a decision. When only one team leaves a position empty, the court operator records a **Fusensho** for the team whose fighter is present. When both teams leave a position empty, the court operator records that bout as a **Tie**, which counts as an individual draw for both teams. An empty position also shows a box directly on its score sheet row, labelled with its own number, so you can name the fighter without leaving the bout you are scoring. If your rules require a full team or set conditions on which positions may be left open, apply those off the app. The app treats the lineup you save as authoritative and scores against it. @@ -85,11 +85,13 @@ Individual bouts are scored first. Once all bouts are done, the encounter result 2. If wins are equal, the team with the highest points scored wins. 3. If both wins and points are equal, the encounter is a draw in pools or league. In a knockout stage, the encounter goes to a representative bout (daihyosen). Refer to [Recording decisions](../court-operators/recording-decisions.md) for how daihyosen is handled. +When the encounter itself ends on a default win (kiken, fusenpai, or fusensho), every bout that has no result yet is also credited to the other team, 2-0 each: one individual victory and two points, on top of whatever was already fought before the withdrawal. This feeds straight into the criteria above and into [Team standings and tie-breaks](#team-standings-and-tie-breaks). Refer to [Fusenpai and fusensho](../court-operators/recording-decisions.md#fusenpai-and-fusensho) for the full rule. + Two fighters from opposing teams may share a name, so the app records who won each bout by identity rather than by the name on the sheet. You do not have to do anything for this, and bouts you score now are unaffected. The one exception is an encounter scored by a much older version. A bout there between two same-named fighters can show no individual win for either team, because the name it stored cannot say which of the two it meant. The points scored in that bout still count. ## Kachinuki (winner stays on) -In kachinuki format, the winner of each bout remains on the court to face the next opponent from the opposing team. If a bout ends in a hikiwake (draw), both fighters retire instead of one continuing, and the next pair takes the court. Kachinuki is run under one of two rule sets, described in [Kachinuki modes](#kachinuki-modes). Because only the shiai-jo operator knows which rule set governs a match, and because team sizes are flexible, the app never decides on its own when a kachinuki encounter is over. The court operator ends it, using the buttons in the score editor. +In kachinuki format, the winner of each bout remains on the court to face the next opponent from the opposing team. If a bout ends in a hikiwake (draw), both fighters retire instead of one continuing, and the next pair takes the court. Kachinuki is run under one of two rule sets, described in [Kachinuki modes](#kachinuki-modes). Because only the shiai-jo operator knows which rule set governs a match, and because team sizes are flexible, the app never decides on its own when a kachinuki encounter is over. The court operator ends it, using the buttons in the score editor. A withdrawal or a no-show that ends a kachinuki encounter early does not credit any further bouts: unlike a regular team match, kachinuki has no fixed positions left to fill, so the encounter simply stands on the bouts already fought. ### Kachinuki modes @@ -118,7 +120,7 @@ The following clip walks through the flows end to end, recorded from the score e 1. **Winner stays on** (0:02): each win keeps the winner on to face the losing team's next fighter, and every fought bout reads **vs** in the centre. 2. **A knockout tie and Encho** (0:08): a knockout cannot end in a draw, so a tied bout holds **End match** back and offers **Encho**: the same pair fights on, marked **(E)**, until a point lands. 3. **A drawn encounter in a league** (0:15): the same tie in a league is simply ended as a draw, marked **X**. -4. **Reopen** (0:21): a completed encounter is reopened with all its bouts intact, then ended again, which asks for a reason. +4. **Reopen** (0:21): a completed encounter is reopened with all its bouts intact, then ended again. ### Choosing the team match format @@ -151,13 +153,13 @@ When the last bout is tied, the editor offers every legitimate way forward and y There is no representative bout (daihyosen) in kachinuki: a tied pairing that must produce a result is settled by encho on that same bout, not by a separate rep bout. -If you finish an encounter too early or record the wrong result, reopen it. Open the completed match and use **Reopen match**. A single tap returns the encounter to in progress with its bouts kept, so you can add or rescore bouts and end it again. Reopening discards a result that has already been recorded, so a reason is still kept with the result. You give it when you end the match again, not before you can get back in. That applies however you end it, whether you finish on a scored bout or record a withdrawal or a no-show. In a knockout, reopening also rolls back the next-round slot that this result had filled, as long as that later match has not started. If that later match has already started or been scored, the app refuses the reopen outright; sort out that later match first. +If you finish an encounter too early or record the wrong result, reopen it. Open the completed match and use **Reopen match**. A single tap returns the encounter to in progress with its bouts kept, so you can add or rescore bouts and end it again. An encounter that ended with a withdrawal or a no-show shows **Clear withdrawal and reopen** instead, because reopening it also makes the withdrawn team eligible again. It states that beside the button, and one tap reopens the encounter; refer to [Correcting a withdrawal recorded by mistake](../court-operators/recording-decisions.md#correcting-a-withdrawal-recorded-by-mistake). Neither button needs a reason, and nor does ending the encounter again, however you end it. In a knockout, reopening also rolls back the next-round slot that this result had filled. If that later match has already been played, the app names it and asks before going ahead; confirming reopens it too, so it is fought and scored again, and the editor names it once the reopen is done. A round after it that nobody has played yet stops showing its winner. If that later match is under way, the app refuses the reopen; finish it or send it back to the queue first. In a pool of a competition that ends in a knockout, reopening is never refused because of the knockout: ending the encounter again with a result that changes who qualifies is checked then, as described in [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). Reopening keeps every bout that was fought. The bout log is preserved in full, including who fought each bout, the points scored, and any overtime, so nothing you have already recorded is lost. Reopening clears the finished-match verdict it is discarding: the winning team, the final score line, and any withdrawal or no-show recorded against the encounter. Reopening is refused while another match is already running on the same court, because the reopened encounter would go back into play alongside it. Finish the running match, or send it back to the queue, and then reopen. -![The score editor for a completed kachinuki match, showing the recorded bouts and the Reopen match button, which is the only correction control for kachinuki.](../../screenshots/kachinuki-reopen.png) +![The score editor for a completed kachinuki match, showing the recorded bouts and the Reopen match button, the correction control for a kachinuki match that did not end with a withdrawal.](../../screenshots/kachinuki-reopen.png) The results workbook (**Export & print**, then **Download results (.xlsx)**) includes a **Kachinuki Detail** sheet with the bout-by-bout record for every kachinuki encounter: who fought whom, scores, draws, and each fighter's lineup position. diff --git a/docs/user-guide/spectators/following.md b/docs/user-guide/spectators/following.md index 24d407bce..22ffa3151 100644 --- a/docs/user-guide/spectators/following.md +++ b/docs/user-guide/spectators/following.md @@ -8,8 +8,8 @@ If you are not sure which role fits you, refer to [Choosing your setup](../start The viewer brings together everything you need to follow the day in one place: -- A personal **Watchlist** so you can track yourself, specific competitors, or a whole dojo. As a watched match approaches, the viewer nudges you so competitors know when to warm up and coaches know when to be at the court. Once someone you watch has no match left to fight, their card shows their last result instead, so you can still see how they finished. -- The full match schedule across all shiai-jo, filterable by competitor or team. The free-text filter matches a competitor's name, their dojo, or their competitor number, so you can jump straight to their bouts. Refer to [Searching by competitor number](#searching-by-competitor-number) for how numbers are matched. +- A personal **Watchlist** so you can track yourself, specific competitors, or a whole dojo. As a watched match approaches, the viewer nudges you so competitors know when to warm up and coaches know when to be at the court. Once someone you watch has no match left to fight, their card shows their last result instead, so you can still see how they finished. If a competitor withdraws, their remaining matches are skipped, so the watchlist never nudges you toward a bout that cannot be fought. +- The full match schedule across all shiai-jo, filterable by competitor or team. The free-text filter matches a competitor's name, their dojo, or their competitor number, so you can jump straight to their bouts. Refer to [Searching by competitor number](#searching-by-competitor-number) for how numbers are matched. A competitor who has withdrawn is marked **Withdrawn** beside their name wherever their remaining matches still appear. - Pool standings that update as scores are entered, with no page refresh needed. - The elimination bracket filling in as matches are completed. @@ -25,6 +25,11 @@ Tap a competition to see its schedule, standings, and bracket. Aka (red) and Shi
A competition's page: upcoming matches and recent results with waza-level scores.
+
+ ![A knockout's Bracket tab on a phone. The two first-round bouts and two quarterfinals are scored, each winner ticked, the next quarterfinal is marked NOW, and two winners already face each other in a semifinal. The later rounds continue off the right-hand edge.](../../screenshots/viewer-bracket.png){ .bc-phone } +
The Bracket tab: results fill in the tree as matches are completed. Scroll sideways for the later rounds.
+
+ ### Searching by competitor number Once the draw for a competition has run, its competitors are numbered. The number starts with a short prefix that belongs to that competition, so "K12" and "M12" are two different people in two different draws. Swiss competitions are the exception and use no numbers at all. diff --git a/docs/videos/kachinuki-demo.webm b/docs/videos/kachinuki-demo.webm index 26c3726d0..c4bcee4cb 100644 Binary files a/docs/videos/kachinuki-demo.webm and b/docs/videos/kachinuki-demo.webm differ diff --git a/internal/domain/decision.go b/internal/domain/decision.go index 296350bc3..f1826aa17 100644 --- a/internal/domain/decision.go +++ b/internal/domain/decision.go @@ -119,9 +119,15 @@ func IsDefaultWinDecisionStr(s string) bool { // IsWithdrawalDecisionStr reports whether the decision is a WITHDRAWAL: any // kiken variant, or fusenpai (a no-show). Deliberately narrower than -// IsDefaultWinDecisionStr, which additionally includes fusensho -- a -// PER-BOUT default win awarded within a team encounter on someone ELSE's -// withdrawal, not a withdrawal of the bout it is recorded on. +// IsDefaultWinDecisionStr, which additionally includes fusensho -- a default +// win awarded on someone ELSE's withdrawal, not a withdrawal of the bout/ +// match it is recorded on. Fusensho is not only a PER-BOUT mark within a +// team encounter: an operator may also record it at MATCH level via +// POST /decision (handlers_decision.go's allow-list) to close out a barred +// competitor's remaining scheduled match once they're already ineligible +// from an earlier kiken/fusenpai elsewhere -- ScoreRequest.Validate (the +// /score endpoint) rejects a top-level fusensho, but /decision's is a +// separate, distinct allow-list that accepts it. // // The expression `!IsKikenDecisionStr(d) && d != string(DecisionFusenpai)` // (or its positive mirror, `IsKikenDecisionStr(d) || d == string(DecisionFusenpai)`) diff --git a/internal/domain/glossary.go b/internal/domain/glossary.go index d03ed512b..d2e11fc0f 100644 --- a/internal/domain/glossary.go +++ b/internal/domain/glossary.go @@ -246,49 +246,49 @@ var reasonHumanPatterns = []struct { }{ // "kiken at " → "withdrew from match " { - re: regexp.MustCompile(`^kiken at (\S+)$`), + re: regexp.MustCompile(`^kiken at (.+)$`), rewrite: func(m []string) string { return "withdrew from match " + m[1] }, }, // "kiken-voluntary at " → "withdrew voluntarily from match " { - re: regexp.MustCompile(`^kiken-voluntary at (\S+)$`), + re: regexp.MustCompile(`^kiken-voluntary at (.+)$`), rewrite: func(m []string) string { return "withdrew voluntarily from match " + m[1] }, }, // "kiken-injury at " → "withdrew due to injury from match " { - re: regexp.MustCompile(`^kiken-injury at (\S+)$`), + re: regexp.MustCompile(`^kiken-injury at (.+)$`), rewrite: func(m []string) string { return "withdrew due to injury from match " + m[1] }, }, // "fusenpai at " → "no-show forfeit at match " { - re: regexp.MustCompile(`^fusenpai at (\S+)$`), + re: regexp.MustCompile(`^fusenpai at (.+)$`), rewrite: func(m []string) string { return "no-show forfeit at match " + m[1] }, }, // "fusensho at " → "bye-win at match " { - re: regexp.MustCompile(`^fusensho at (\S+)$`), + re: regexp.MustCompile(`^fusensho at (.+)$`), rewrite: func(m []string) string { return "bye-win at match " + m[1] }, }, // "daihyosen at " → "representative bout at match " { - re: regexp.MustCompile(`^daihyosen at (\S+)$`), + re: regexp.MustCompile(`^daihyosen at (.+)$`), rewrite: func(m []string) string { return "representative bout at match " + m[1] }, }, // "kachinuki-exhaustion at " → "team exhausted at match " { - re: regexp.MustCompile(`^kachinuki-exhaustion at (\S+)$`), + re: regexp.MustCompile(`^kachinuki-exhaustion at (.+)$`), rewrite: func(m []string) string { return "team exhausted at match " + m[1] }, @@ -321,3 +321,27 @@ func ResolveReasonHuman(reason string) string { } return "" } + +// SplitStatusReason parses a CompetitorStatus.Reason string of the shape +// " at " -- the one format +// engine.recordIneligibilityFromDecision ever writes (fmt.Sprintf("%s at +// %s", result.Decision, matchID)) -- into its two parts. ok is false for +// anything else (a hand-edited or legacy reason with no " at " separator), +// and decision/matchID are then "". +// +// The match id itself may contain a space (a pool id is "-", and a pool is commonly named "Pool A"), so the split is on +// the first literal " at " rather than on whitespace; a decision value +// never contains a space (domain.Decision, decision.go), so the split +// point is unambiguous. Used by engine's barred-competitor error +// constructors (IneligibleCompetitorError/AlreadyIneligibleError) so a +// refusal carries a machine-usable Decision/MatchID alongside the prose +// Reason, without a second consumer re-deriving the same split. +func SplitStatusReason(reason string) (decision, matchID string, ok bool) { + const sep = " at " + i := strings.Index(reason, sep) + if i < 0 { + return "", "", false + } + return reason[:i], reason[i+len(sep):], true +} diff --git a/internal/domain/glossary_test.go b/internal/domain/glossary_test.go index d3890be6b..5010a8b88 100644 --- a/internal/domain/glossary_test.go +++ b/internal/domain/glossary_test.go @@ -173,6 +173,12 @@ func TestResolveReasonHuman(t *testing.T) { {"daihyosen at match", "daihyosen at r3-m1", "representative bout at match r3-m1"}, {"kachinuki-exhaustion at match", "kachinuki-exhaustion at k_5", "team exhausted at match k_5"}, + // bc-cse: a pool/league match id contains a space ("Pool A-0" is + // "Pool A" + "-0"), so the pattern must capture past the first + // space rather than stop at it. + {"kiken-voluntary at pool match id", "kiken-voluntary at Pool A-0", "withdrew voluntarily from match Pool A-0"}, + {"fusenpai at pool match id", "fusenpai at Pool A-0", "no-show forfeit at match Pool A-0"}, + // Bare-term fallback path, when the engine emits just the // canonical decision name without a match context. {"bare kiken", "kiken", "withdrawal"}, @@ -194,6 +200,31 @@ func TestResolveReasonHuman(t *testing.T) { } } +func TestSplitStatusReason(t *testing.T) { + cases := []struct { + name string + reason string + wantDec string + wantMatch string + wantOK bool + }{ + {"simple id", "kiken-voluntary at m_12", "kiken-voluntary", "m_12", true}, + // A pool match id contains a space; the split is on the literal + // " at " separator, not on whitespace. + {"pool id with space", "kiken-injury at Pool A-0", "kiken-injury", "Pool A-0", true}, + {"no separator", "kiken", "", "", false}, + {"empty", "", "", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + decision, matchID, ok := domain.SplitStatusReason(tc.reason) + assert.Equal(t, tc.wantOK, ok) + assert.Equal(t, tc.wantDec, decision) + assert.Equal(t, tc.wantMatch, matchID) + }) + } +} + // findGlossarySpec returns the absolute path to // specs/003-tournament-gap-closure/glossary.md, walking up from the // test file's package directory so the test runs from any CWD. diff --git a/internal/domain/team_lineup.go b/internal/domain/team_lineup.go index 426fdce9a..8a8ff8aff 100644 --- a/internal/domain/team_lineup.go +++ b/internal/domain/team_lineup.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strconv" + "strings" ) // Position names a slot in a team lineup. For 5-person teams the named @@ -25,6 +26,16 @@ const ( // team size, where positions are 1-indexed numeric strings. func PositionNumbered(n int) Position { return Position(strconv.Itoa(n)) } +// Label is the position as the operator reads it: a FIK name title-cased +// ("senpo" becomes "Senpo"), a numbered position unchanged ("3"). +func (p Position) Label() string { + s := string(p) + if s == "" || (s[0] >= '0' && s[0] <= '9') { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + // TeamLineup pins which player occupies each Position for a team in a // given round OR for a specific match. The lineup is always editable, // including while a match is running or completed. @@ -179,6 +190,17 @@ func (t TeamLineup) OrderedMembers(teamSize int) []LineupSlot { return out } +// PositionForBout returns the lineup Position that fights numbered bout +// `bout` (1-based) of a team match: the bout-th entry of the canonical order +// OrderedMembers walks. False when bout is outside 1..teamSize. +func PositionForBout(teamSize, bout int) (Position, bool) { + order := canonicalPositionOrder(teamSize) + if bout < 1 || bout > len(order) { + return "", false + } + return order[bout-1], true +} + // allowedPositionSet returns the valid position keys for a team size: the five // FIK names for 5-person teams, else numbered positions 1..teamSize. func allowedPositionSet(teamSize int) map[Position]struct{} { diff --git a/internal/domain/team_lineup_test.go b/internal/domain/team_lineup_test.go index 5aed07104..328872070 100644 --- a/internal/domain/team_lineup_test.go +++ b/internal/domain/team_lineup_test.go @@ -358,3 +358,38 @@ func TestTeamLineupOrderedMembers_IdOnlySlotIsOccupied(t *testing.T) { } assert.Equal(t, []string{"Aoki", ""}, gotNames) } + +// PositionForBout names the position that fights numbered bout n, which the +// team finish gate's refusal uses to label a five-person team's bouts. +func TestPositionForBout(t *testing.T) { + pos, ok := domain.PositionForBout(5, 4) + require.True(t, ok) + assert.Equal(t, domain.PosFukusho, pos) + pos, ok = domain.PositionForBout(3, 3) + require.True(t, ok) + assert.Equal(t, domain.PositionNumbered(3), pos) + _, ok = domain.PositionForBout(3, 4) + assert.False(t, ok) + _, ok = domain.PositionForBout(5, 0) + assert.False(t, ok) +} + +func TestPosition_Label(t *testing.T) { + tests := []struct { + pos domain.Position + want string + }{ + {domain.PosSenpo, "Senpo"}, + {domain.PosJiho, "Jiho"}, + {domain.PosChuken, "Chuken"}, + {domain.PosFukusho, "Fukusho"}, + {domain.PosTaisho, "Taisho"}, + {domain.PositionNumbered(3), "3"}, + {"1", "1"}, + {"", ""}, + {"unknown", "Unknown"}, + } + for _, tc := range tests { + assert.Equal(t, tc.want, tc.pos.Label(), "position %q", tc.pos) + } +} diff --git a/internal/engine/bc_idfx_test.go b/internal/engine/bc_idfx_test.go index b989e14ba..94e038e59 100644 --- a/internal/engine/bc_idfx_test.go +++ b/internal/engine/bc_idfx_test.go @@ -794,7 +794,7 @@ func TestApplyPoolWrite_RestorePolicyIgnoresWinnerIDMismatch(t *testing.T) { Winner: "Alice", WinnerID: "some-stale-id", Status: state.MatchStatusCompleted, } - mismatch, superseded, err := applyPoolWrite(stored, prior, matchWriteRestore) + mismatch, superseded, _, err := applyPoolWrite(stored, prior, matchWriteRestore) require.NoError(t, err, "a restore must never be rejected by the forward-only WinnerID check") assert.False(t, mismatch) assert.False(t, superseded) @@ -813,7 +813,7 @@ func TestApplyPoolWrite_ForwardPolicyStillRejectsWinnerIDMismatch(t *testing.T) ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: "id-alice", SideBID: "id-bob", Winner: "Alice", WinnerID: "not-a-side-id", Status: state.MatchStatusCompleted, } - _, _, err := applyPoolWrite(stored, forward, matchWriteForward) + _, _, _, err := applyPoolWrite(stored, forward, matchWriteForward) require.Error(t, err, "a client forward write naming an unattributable winnerId must still be rejected") } diff --git a/internal/engine/bracket.go b/internal/engine/bracket.go index 4beea3017..589fa48f9 100644 --- a/internal/engine/bracket.go +++ b/internal/engine/bracket.go @@ -2,7 +2,6 @@ package engine import ( "fmt" - "sort" "strings" "github.com/gitrgoliveira/bracket-creator/internal/domain" @@ -86,19 +85,6 @@ func (e *Engine) generateKnockout(comp *state.Competition, players []domain.Play return e.store.SaveBracket(comp.ID, bracket) } -// bracketMatchLeafSlot is the LEFTMOST first-round slot that match (roundIdx, -// matchIdx) is rooted above, in a pow2-padded bracket.Rounds: match (r, m) -// covers leaves [m*2^(r+1), (m+1)*2^(r+1)). -// -// Two things derive from it and they MUST agree: a match's court (the region -// owning that leaf) and the tie-break inside a DisplayRound when numbering -// matches. They are the same quantity, so they are one function -- numbering a -// bout by one rule and placing it by another is precisely how the printed -// sheet's "Match 12" and the app's "Match 12" became different bouts. -func bracketMatchLeafSlot(roundIdx, matchIdx int) int { - return matchIdx * (1 << (roundIdx + 1)) -} - // generatePoolPreviewBracket builds the in-place knockout bracket for a mixed // (Pools + Knockout) competition at draw time. Its leaves start as pool-origin // placeholders ("Pool A-1st", "Pool B-2nd", …) produced by the court-first draw @@ -209,13 +195,13 @@ func (e *Engine) buildBracketFromDraw(comp *state.Competition, draw *helper.Knoc if draw == nil || draw.Root == nil { return nil, fmt.Errorf("buildBracketFromDraw: no draw to build from") } - // SlotArray, not TreeToLeafArray: the pow2 bracket must carry the draw's - // SLOT geometry so its round indices equal the printed Excel columns. The - // flat array tail-pads a vacancy block's bye pair into adjacency - // ([H10,C6,"",""] for the true [H10,"",C6,""]), which would fight a bout - // in round 1 that the sheet prints in round 2. Region widths are identical - // under both readings, so the spans and every court derivation are - // unaffected. + // SlotArray, not TreeToLeafArray: the pow2 bracket must hold every bout at + // the slots the draw tree gives it. The flat array tail-pads a vacancy + // block's bye pair into adjacency ([H10,C6,"",""] for the true + // [H10,"",C6,""]), which would store H10 v C6 in the first pow2 row + // although the sheet prints it in round 2, so the stored rows would not be + // the draw tree's. Region widths are identical under both readings, so the + // spans and every court derivation are unaffected. leaves := helper.SlotArray(draw.Root) regionSpans := draw.RegionSpans() @@ -269,7 +255,7 @@ func (e *Engine) buildBracketFromDraw(comp *state.Competition, draw *helper.Knoc // hall runs its closing bouts. helper.CourtForSpan owns both rules // and the Excel side asks it the same question (NodeCourts), so the // operator's screen and the printed handout cannot disagree. - courtIdx := helper.CourtForSpan(regionSpans, bracketMatchLeafSlot(rIdx, i), 1<<(rIdx+1)) + courtIdx := helper.CourtForSpan(regionSpans, state.BracketMatchLeafSlot(rIdx, i), 1<<(rIdx+1)) court := "" if len(comp.Courts) > 0 { if courtIdx >= len(comp.Courts) { @@ -347,16 +333,13 @@ func (e *Engine) buildBracketFromDraw(comp *state.Competition, draw *helper.Knoc } } - // Per-court slot assignment (T150) + ceremony-block skipping - // (T151). See pools.go for the same wiring; tournament load - // failures abort the start so the operator notices the missing - // schedule data rather than silently shipping a uniform-start - // bracket. + // Tournament load failures abort the start so the operator notices the + // missing schedule data rather than silently shipping a uniform-start + // bracket (the slots are assigned below, once the matches are numbered). tournament, err := e.store.LoadTournament() if err != nil { return nil, err } - assignBracketMatchSlots(bracket.Rounds, comp, tournament) // Display metadata (mp-7f2w): label each match with its effective round and // real feeders so the viewer renders the same effective-round columns as the @@ -364,13 +347,30 @@ func (e *Engine) buildBracketFromDraw(comp *state.Competition, draw *helper.Knoc // the "Winner of rX-mY" placeholders are still intact, it must NOT be // recomputed after results resolve those placeholders into player names. computeBracketDisplayMetadata(bracket) - applySlotDisplayRounds(bracket, draw) - // Assign sequential match numbers matching the Excel Tree sheet (AC8). - // Must run AFTER computeBracketDisplayMetadata sets Hidden so the skipping - // logic is identical to helper.AssignMatchNumbers (nil-node skip in Excel - // = Hidden or both-sides-empty in the web bracket). - assignBracketMatchNumbers(bracket) + // Rounds and match numbers (AC8), from the Feeders just stamped: each real + // bout's round is its distance from the final, the column the workbook + // prints it in, and the numbers follow helper.AssignMatchNumbers' walk + // (Hidden or both-sides-empty here = the Excel nil-node skip). The ONE + // body lives on state.Bracket so the load-time restamp + // (RestampRoundsFromFeeders) runs it too, and so a stored bracket and a + // fresh one cannot be stamped by different rules. It reads the Feeders and + // Hidden stamped above, never the sides' contents beyond whether both are + // empty (state.BracketMatch.numbered says why that does not change after + // the draw in any supported flow), so it walks a bracket in play exactly + // as it walked this one. A + // refusal here means the draw built a bracket its own feeders cannot + // describe: an internal fault, never an operator's. + if err := bracket.StampRoundsFromFeeders(); err != nil { + return nil, fmt.Errorf("buildBracketFromDraw: stamping rounds and match numbers: %w", err) + } + + // Per-court slot assignment (T150) + ceremony-block skipping (T151), in + // match-number order, so it runs after the numbering above. See pools.go + // for the same wiring. Scheduled in number order, so the load-time repair + // of the old storage-order scheduling owes it nothing (TimesSettled). + assignBracketMatchSlots(bracket.Rounds, comp, tournament) + bracket.TimesSettled = true // Bronze (3rd-place) knockout: only when this competition's format // requires a single 3rd place (comp.RequiresSingleThirdPlace, the @@ -480,84 +480,12 @@ func bronzeDefaultCourt(finalCourt string, courts []string) string { return "" } -// assignBracketMatchNumbers sets MatchNumber on every real (non-Hidden, -// non-empty) bracket match. This is the web API's numbering implementation; the -// Excel renderer has a SEPARATE one, helper.AssignMatchNumbers, which operates on -// []*Node instead of *state.Bracket. The two are NOT a literally-shared function -// (the types differ), they are kept equal-by-contract so the on-screen "Match N" -// always equals the printed Excel "Match N". -// -// Ordering, CRITICAL for byes: the Excel sheet numbers via eliminationMatchRounds, -// which groups matches by DEPTH-FROM-ROOT (the unbalanced tree's deepest matches -// come first), NOT by raw bracket.Rounds index. With a non-power-of-two roster the -// pow2-padded bracket.Rounds order diverges from that depth grouping, so numbering -// in raw Rounds order drifts (e.g. 5 entrants: the lone deep first-round bout must -// be Match 1, not the shallow slot-0 bout). DisplayRound already encodes the Excel -// depth grouping (verified by TestBracketDisplayMetadata_MatchesExcelRounds), so we -// number by descending DisplayRound (deepest/earliest round first). -// -// The tie-break inside a DisplayRound is the match's LEFTMOST FIRST-ROUND SLOT, -// bracketMatchLeafSlot — the same function buildBracketFromDraw uses to find a -// match's court. It has to be, because Excel's TraverseRounds walks each depth level -// LEFT TO RIGHT across the whole tree, and one effective round can draw its -// matches from several pow2 rounds at once: a shallow region's first bout and a -// deep region's second bout share a DisplayRound while sitting in bracket.Rounds -// 0 and 1. Tie-breaking on the within-round position alone (the old rule) then -// interleaves them by an index that means different things in the two rounds, and -// the printed "Match 12" and the app's "Match 12" become different bouts. Measured -// on a pool-fed draw of 8 pools x 3 qualifiers: the sheet numbered Pool G-3rd v -// Pool H-3rd 12 while the app numbered the E-1st/A-2nd v F-1st/B-2nd bout 12 -// (bc-draw Phase 5). The leaf slot orders them the way the sheet prints them, -// because a node's slot range is contiguous and left-to-right IS increasing slot. -// -// Skip rule (matches the Excel nil-node skip): Hidden (structural-bye) matches and -// both-sides-empty dead matches are excluded and do not consume a number. -// -// The printed Excel sheet is authoritative. The contract is enforced by -// TestMatchNumberingParity_ExcelVsWeb (match_numbering_parity_test.go) for -// knockout brackets and by TestExcelWorkbookMatchesEngineBracket_Mixed -// (excel_draw_parity_test.go) for pool-fed ones, the latter by reading the numbers -// back out of a rendered workbook. If they ever diverge, fix THIS path to match -// the Excel one — and the JS buildDisplayModel matchNumById ordering with it -// (web-mobile/js/bracket.jsx), which is the third implementation of this walk. -// -// Must run AFTER computeBracketDisplayMetadata, which sets Hidden / DisplayRound. -func assignBracketMatchNumbers(b *state.Bracket) { - type ref struct { - m *state.BracketMatch - // leafSlot is the first-round slot this match's subtree starts at. - leafSlot int - } - var real []ref - for ri := range b.Rounds { - for mi := range b.Rounds[ri] { - m := &b.Rounds[ri][mi] - if m.Hidden { - continue - } - if m.SideA == "" && m.SideB == "" { - continue - } - real = append(real, ref{m: m, leafSlot: bracketMatchLeafSlot(ri, mi)}) - } - } - // Descending DisplayRound (deepest/earliest round first), then left to right - // across the whole tree, mirrors the Excel eliminationMatchRounds walk. - sort.SliceStable(real, func(i, j int) bool { - if real[i].m.DisplayRound != real[j].m.DisplayRound { - return real[i].m.DisplayRound > real[j].m.DisplayRound - } - return real[i].leafSlot < real[j].leafSlot - }) - for i, r := range real { - r.m.MatchNumber = i + 1 - } -} - -// computeBracketDisplayMetadata fills DisplayRound / Hidden / Feeders on every -// match so the viewer can render effective-round columns identical to the Excel -// Tree sheet (matches grouped by depth-from-root; structural byes skip a column -// rather than appearing as empty cards). It is purely additive, the positional +// computeBracketDisplayMetadata fills Hidden / Feeders on every match (and +// clears DisplayRound, which state.Bracket.StampRoundsFromFeeders then derives +// from those Feeders) so the viewer can render effective-round columns +// identical to the Excel Tree sheet (matches grouped by depth-from-root; +// structural byes skip a column rather than appearing as empty cards). It is +// purely additive, the positional // ID + "Winner of rX-mY" resolution scheme used by scoring/scheduling/the pool // resolver is untouched. // @@ -566,8 +494,8 @@ func assignBracketMatchNumbers(b *state.Bracket) { // byes) are marked Hidden. For each real match, Feeders holds the IDs of the two // REAL feeder matches whose winners meet here ([A, B] order); a side fed by a // seeded entrant / pool placeholder / bye carries "" (no connector). DisplayRound -// counts from the final (1 = Final), assigned by walking the real feeder graph -// outward from the lone real match in the last round. +// counts from the final (1 = Final): StampRoundsFromFeeders walks this real +// feeder graph outward from the lone real match in the last round. // // Must run after bye winners have been auto-resolved and propagated (so resolved // names already sit in their feeder slots), i.e. at the end of bracket build. @@ -614,70 +542,20 @@ func computeBracketDisplayMetadata(bracket *state.Bracket) { return "" // dead match (both empty) } - byID := make(map[string]*state.BracketMatch) for r := range rounds { for i := range rounds[r] { mm := &rounds[r][i] - byID[mm.ID] = mm + // DisplayRound is cleared on every match: the real ones take + // theirs from these Feeders (state.Bracket.StampRoundsFromFeeders), + // the Hidden ones carry none. + mm.DisplayRound = 0 if isReal(mm) { mm.Hidden = false - mm.DisplayRound = 0 // assigned by the walk below mm.Feeders = []string{realFeederID(mm.SideA), realFeederID(mm.SideB)} } else { mm.Hidden = true - mm.DisplayRound = 0 mm.Feeders = nil } } } - - // DisplayRound's provisional value is the match's POW2 round counted from - // the final (1 = Final); applySlotDisplayRounds then overrides every bout - // the draw tree knows with the round the risen-aware Excel walk fights it - // in, which is the printed column. The pow2 row alone is wrong for an - // assembly-level late bout that the pow2 padding parks in round-1 - // adjacency, and the old feeder-graph walk was wrong the other way for a - // phantom-risen pair the sheets fight in round 1 (34th EKC Junior Male - // F2) -- the bracket graph cannot tell those two shapes apart, so neither - // recomputation can be the source. The draw tree can (Node.risen), and - // its walk is what the workbook prints. - if !isReal(at(numRounds-1, 0)) { - return // degenerate bracket (e.g. < 2 competitors) - } - for r := range rounds { - for i := range rounds[r] { - if mm := &rounds[r][i]; !mm.Hidden { - mm.DisplayRound = numRounds - r - } - } - } -} - -// applySlotDisplayRounds stamps each real bracket match with the round the -// draw's risen-aware walk (helper.SlotRoundMatches) fights it in. A bout is -// located by its first-round window: entrant width 2^(r+1) starting at slot -// offset i*2^(r+1) identifies pow2 match (r, i) exactly, because the bracket -// was built from helper.SlotArray of the same tree. -func applySlotDisplayRounds(bracket *state.Bracket, draw *helper.KnockoutDraw) { - if bracket == nil || draw == nil || draw.Root == nil { - return - } - numRounds := len(bracket.Rounds) - for _, sm := range helper.SlotRoundMatches(draw.Root) { - w := sm.EntrantWidth - r := -1 - for ww := w; ww > 1; ww >>= 1 { - r++ - } - if r < 0 || r >= numRounds { - continue - } - i := sm.Offset / w - if i < 0 || i >= len(bracket.Rounds[r]) { - continue - } - if mm := &bracket.Rounds[r][i]; !mm.Hidden { - mm.DisplayRound = numRounds - sm.Round - } - } } diff --git a/internal/engine/bracket_identity_test.go b/internal/engine/bracket_identity_test.go index 243725465..bb59d1c13 100644 --- a/internal/engine/bracket_identity_test.go +++ b/internal/engine/bracket_identity_test.go @@ -215,9 +215,7 @@ func excelRoundSizes(players []domain.Player) []int { names[i] = p.Name } // Normalize through the slot codec exactly as NewKnockoutDraw does for the - // printed workbook: the raw CreateBalancedTree tree is riseless and - // classifies a phantom-risen pair a round late, which is the old geometry - // this test would otherwise re-assert. + // printed workbook, so this reads the tree the workbook prints. tree := helper.BuildSlotTree(helper.SlotArray(helper.CreateBalancedTree(names))) var sizes []int for _, round := range helper.BuildEliminationMatchRounds(tree) { @@ -290,12 +288,11 @@ func TestBracketDisplayMetadata_MatchesExcelRounds(t *testing.T) { // TestBracketDisplayMetadata_Feeders verifies the feeder graph is well formed // across roster sizes (including deep multi-level bye chains, e.g. 24 players): -// every feeder ID resolves to a real, non-hidden match STRICTLY deeper in -// DisplayRound, and every real match except the final is referenced exactly -// once as a feeder. Strictly deeper, not exactly one: a phantom-risen pair -// fights in round 1 and its winner byes across the skipped column straight -// into a much later bout, exactly as the reference sheets draw the long -// winner line (34th EKC Junior Male F2 into F4). +// every feeder ID resolves to a real, non-hidden match exactly one DisplayRound +// deeper, and every real match except the final is referenced exactly once as +// a feeder. Exactly one because a bout's round is its distance from the final, +// which is how the reference sheets number their columns (34th EKC Junior +// Individual Male: P4 v P5, beside an empty pair, prints in column 2). func TestBracketDisplayMetadata_Feeders(t *testing.T) { cases := []struct { name string @@ -305,8 +302,12 @@ func TestBracketDisplayMetadata_Feeders(t *testing.T) { {"5 players", 5, 0}, {"7 players", 7, 0}, {"8 players (pow2)", 8, 0}, + // 9 and 19, like 5, hold a pair beside an empty pair, so they fail if + // that pair is classified anywhere but its distance from the final. + {"9 players", 9, 0}, {"12 players", 12, 0}, {"16 players (pow2)", 16, 0}, + {"19 players", 19, 0}, {"24 players", 24, 0}, {"24 players 8 seeds", 24, 8}, } @@ -335,8 +336,8 @@ func TestBracketDisplayMetadata_Feeders(t *testing.T) { f, ok := byID[fid] require.Truef(t, ok, "feeder %s must exist", fid) assert.Falsef(t, f.Hidden, "feeder %s must be a real match", fid) - assert.Greaterf(t, f.DisplayRound, m.DisplayRound, - "feeder %s must be strictly deeper than its parent", fid) + assert.Equalf(t, m.DisplayRound+1, f.DisplayRound, + "feeder %s must be one DisplayRound deeper than its parent", fid) refCount[fid]++ } } diff --git a/internal/engine/bracket_match_numbers_golden_test.go b/internal/engine/bracket_match_numbers_golden_test.go index f762dd4ab..ce4dfff3c 100644 --- a/internal/engine/bracket_match_numbers_golden_test.go +++ b/internal/engine/bracket_match_numbers_golden_test.go @@ -18,7 +18,7 @@ import ( // Bracket match numbers are a CROSS-LANGUAGE contract, and until this file // nothing pinned it. // -// assignBracketMatchNumbers (bracket.go) stamps MatchNumber on every real match +// state.Bracket.NumberMatches stamps MatchNumber on every real match // and the SPA re-derives the same numbering independently, in buildDisplayModel // (web-mobile/js/bracket.jsx), to label its cards "M1", "M2". A referee holding // the printed Excel sheet and looking at the operator's screen has to read the @@ -99,12 +99,13 @@ type bracketNumberGolden struct { // The marked cases were found by sweeping formats, entrant counts and shiaijo // counts for a bracket where the two orderings genuinely disagree; each one has // an effective round holding a DEEP match at a small position alongside a -// SHALLOW match at a larger position. Slot-true display rounds (Node.risen, -// 2026-08-18) removed that mixture from every KNOCKOUT shape -- a sweep of -// 3-32 entrants found none -- because a knockout tree's effective rounds now -// ARE its slot rounds. The pool-fed mixed draw still mixes (one effective -// round can hold matches from several pow2 rounds), so the discriminating -// fixture lives there. +// SHALLOW match at a larger position. A bout's effective round is its +// distance from the final, so a pair beside an empty pair shares an effective +// round with bouts from a later pow2 round: at 9 entrants, DisplayRound 3 +// holds pow2 round-1 bouts at positions 0, 1 and 4 and a pow2 round-2 bout at +// position 3. On one shiaijo, knockouts of 9, 10 and 17-22 entrants +// discriminate (a sweep of 3-32); the table pins 9 and 19, and the pool-fed +// mixed draw on 2 shiaijo. // // The flag below is an EXPECTATION, not an assertion of faith: // orderingsDisagree measures it on the bracket the engine actually built and @@ -129,11 +130,11 @@ var bracketNumberCases = []struct { {state.CompFormatKnockout, 4, 1, false}, {state.CompFormatKnockout, 5, 1, false}, {state.CompFormatKnockout, 8, 1, false}, - {state.CompFormatKnockout, 9, 1, false}, + {state.CompFormatKnockout, 9, 1, true}, {state.CompFormatKnockout, 11, 1, false}, {state.CompFormatKnockout, 13, 1, false}, {state.CompFormatKnockout, 16, 1, false}, - {state.CompFormatKnockout, 19, 1, false}, + {state.CompFormatKnockout, 19, 1, true}, // Mixed preview brackets: pool-origin placeholders rather than players, and // the shape the court-region draw actually produces on several shiaijo. {state.CompFormatMixed, 40, 2, true}, diff --git a/internal/engine/bracket_not_ready_label_test.go b/internal/engine/bracket_not_ready_label_test.go new file mode 100644 index 000000000..9dd75069c --- /dev/null +++ b/internal/engine/bracket_not_ready_label_test.go @@ -0,0 +1,88 @@ +package engine + +// bc-cse item 14: "knockout match %s is not ready to score/override..." +// used to interpolate the raw match id, which IS sent to the operator (both +// via respondEngineError's 400 body and RecordMatchResultWithIneligibility's +// caller in mobileapp). It must name the match the way the operator sees it +// instead, exactly like DownstreamKnockoutPlayedError already does. + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScoring_BracketMatchNotReadyToScore_MessageNamesOperatorLabelNotRawID(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "not-ready-score-label" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Status: state.CompStatusKnockout, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{ + Rounds: [][]state.BracketMatch{ + // SideB is an unresolved feeder placeholder, so the match is not + // yet playable. + {{ID: "m-r1-0", SideA: "Alice", SideB: "Winner of r0-m0", + Status: state.MatchStatusScheduled, MatchNumber: 1, DisplayRound: 1}}, + }, + })) + + txErr := inTx(t, store, compID, func(tx state.StoreTx) error { + _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", &state.MatchResult{ + ID: "m-r1-0", Status: state.MatchStatusRunning, + }, ForceOptions{}) + return err + }) + require.Error(t, txErr) + assert.NotContains(t, txErr.Error(), "m-r1-0", "the raw match id must not reach the operator") + assert.Contains(t, txErr.Error(), "Match 1 (Final)", "named by its operator label instead") + assert.Contains(t, txErr.Error(), "is not ready to score") +} + +func TestOverrideBracketWinner_NotReadyToOverride_MessageNamesOperatorLabelNotRawID(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "not-ready-override-label" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Status: state.CompStatusKnockout, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{ + Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Alice", SideB: "Winner of r0-m0", + Status: state.MatchStatusScheduled, MatchNumber: 3, DisplayRound: 1}}, + }, + })) + + _, err := eng.OverrideBracketWinner(compID, "m-r1-0", "Alice", 0) + require.Error(t, err) + assert.NotContains(t, err.Error(), "m-r1-0", "the raw match id must not reach the operator") + assert.Contains(t, err.Error(), "Match 3 (Final)", "named by its operator label instead") + assert.Contains(t, err.Error(), "is not ready to override") +} + +func TestScoring_BracketCompletedWithNoWinner_MessageNamesOperatorLabelNotRawID(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "no-winner-label" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Status: state.CompStatusKnockout, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{ + Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Alice", SideB: "Bob", + Status: state.MatchStatusRunning, MatchNumber: 5, DisplayRound: 1}}, + }, + })) + + txErr := inTx(t, store, compID, func(tx state.StoreTx) error { + _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", &state.MatchResult{ + ID: "m-r1-0", SideA: "Alice", SideB: "Bob", + Status: state.MatchStatusCompleted, // no Winner + }, ForceOptions{}) + return err + }) + require.Error(t, txErr) + assert.NotContains(t, txErr.Error(), "m-r1-0", "the raw match id must not reach the operator") + assert.Contains(t, txErr.Error(), "Match 5 (Final)", "named by its operator label instead") + assert.Contains(t, txErr.Error(), "cannot mark completed with no winner") +} diff --git a/internal/engine/bracket_order_of_play_test.go b/internal/engine/bracket_order_of_play_test.go new file mode 100644 index 000000000..e77155537 --- /dev/null +++ b/internal/engine/bracket_order_of_play_test.go @@ -0,0 +1,110 @@ +package engine + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// TestBracketOrderOfPlayFollowsMatchNumbers: on every shiaijo a freshly drawn +// knockout is scheduled in match-number order, so Match 1 is played before +// Match 2 and a round before the next. The storage order is not the order of +// play: in a five-player draw the pair beside the empty pair sits in the +// first storage row but is a semifinal, numbered after the quarterfinal. +func TestBracketOrderOfPlayFollowsMatchNumbers(t *testing.T) { + for n := 3; n <= 24; n++ { + for _, courts := range []int{1, 2, 4} { + t.Run(fmt.Sprintf("%d_entrants_%d_shiaijo", n, courts), func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "order-of-play" + createTestCompetition(t, store, compID, state.CompFormatKnockout, 0, func(c *state.Competition) { + c.Courts = courtLabels(courts) + }) + require.NoError(t, store.SaveParticipants(compID, makePlayers(n))) + require.NoError(t, eng.GenerateDraw(compID)) + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + + byNumber := map[int]*state.BracketMatch{} + maxNumber := 0 + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + if m := &bracket.Rounds[ri][mi]; m.MatchNumber > 0 && m.Status != state.MatchStatusCompleted { + byNumber[m.MatchNumber] = m + maxNumber = max(maxNumber, m.MatchNumber) + } + } + } + require.NotEmpty(t, byNumber, "the draw holds matches to play") + last := map[string]*state.BracketMatch{} + for num := 1; num <= maxNumber; num++ { + m, ok := byNumber[num] + if !ok { + continue + } + if prev := last[m.Court]; prev != nil { + require.Less(t, prev.ScheduledAt, m.ScheduledAt, + "shiaijo %s: Match %d (%s) must be scheduled after Match %d (%s)", + m.Court, m.MatchNumber, m.ScheduledAt, prev.MatchNumber, prev.ScheduledAt) + } + last[m.Court] = m + } + }) + } + } +} + +// TestBracketTimesTheOperatorMovedSurviveAReload: moving a match up the court +// queue swaps two times (UpdateMatchTime) and marks nothing else on the +// match. In a five-player draw, moving Match 2 above Match 1 leaves the +// court's times rising in storage order, which is exactly what the old +// scheduler left behind. The draw records its bracket as TimesSettled, so +// the load-time repair of that old scheduling never takes the move back. +func TestBracketTimesTheOperatorMovedSurviveAReload(t *testing.T) { + eng, store, dir := setupTestEngine(t) + const compID = "moved-times" + createTestCompetition(t, store, compID, state.CompFormatKnockout, 0, func(c *state.Competition) { + c.Courts = courtLabels(1) + }) + require.NoError(t, store.SaveParticipants(compID, makePlayers(5))) + require.NoError(t, eng.GenerateDraw(compID)) + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + require.True(t, bracket.TimesSettled, "a draw scheduled in match-number order owes the repair nothing") + + var m1, m2 *state.BracketMatch + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + switch m := &bracket.Rounds[ri][mi]; m.MatchNumber { + case 1: + m1 = m + case 2: + m2 = m + } + } + } + require.NotNil(t, m1) + require.NotNil(t, m2) + first, second := m1.ScheduledAt, m2.ScheduledAt + require.Less(t, first, second, "fixture: Match 1 is played first") + + // The operator moves Match 2 up the queue: the console swaps the times. + require.NoError(t, eng.UpdateMatchTime(compID, m2.ID, first)) + require.NoError(t, eng.UpdateMatchTime(compID, m1.ID, second)) + + reloaded, err := state.NewStore(dir) + require.NoError(t, err) + got, err := reloaded.LoadBracket(compID) + require.NoError(t, err) + byID := map[string]string{} + for ri := range got.Rounds { + for _, m := range got.Rounds[ri] { + byID[m.ID] = m.ScheduledAt + } + } + require.Equal(t, first, byID[m2.ID], "Match 2 keeps the time it was moved to") + require.Equal(t, second, byID[m1.ID], "Match 1 keeps the time it was moved to") +} diff --git a/internal/engine/bracket_rounds_restamp_test.go b/internal/engine/bracket_rounds_restamp_test.go new file mode 100644 index 000000000..4bc7898d4 --- /dev/null +++ b/internal/engine/bracket_rounds_restamp_test.go @@ -0,0 +1,227 @@ +package engine + +import ( + "fmt" + "strings" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Guard for the load-time restamp (bc-tmfn). Generation and the restamp share +// ONE producer of rounds and match numbers, state.Bracket.StampRoundsFromFeeders +// (each real bout's round is its distance from the final along the Feeders +// computeBracketDisplayMetadata stamps), so on a FRESH bracket a restamp being +// a no-op is true by construction. What that half still catches is a second +// producer creeping back into generation after the shared stamp: anything that +// rewrites a round or a number there (the pow2 provisional rounds this +// replaced, say) makes every load renumber a freshly drawn bracket, and the +// sweep below goes red on it. The Excel parity suites +// (excel_draw_parity_test.go, match_numbering_parity_test.go) and +// bracket_match_numbers_golden_test.go are what prove the shared producer +// matches the printed sheet. +// +// The PLAYED halves are not tautological: they pin that fighting a knockout +// out, and resolving pool finishers into it (ResolveQualifiedPools), never +// rewrite the Feeders or Hidden flags nor empty a side, which is what lets the +// restamp walk a bracket in play exactly as generation walked it. + +// assertRestampIsNoOp restamps an independent copy of compID's stored bracket +// and requires it to come back identical, rounds, match numbers and scheduled +// times included. +func assertRestampIsNoOp(t *testing.T, store *state.Store, compID string) { + t.Helper() + stored, err := store.LoadBracket(compID) + require.NoError(t, err) + restamped, err := store.LoadBracket(compID) // LoadBracket returns a copy + require.NoError(t, err) + + real := 0 + for _, round := range stored.Rounds { + for _, m := range round { + if m.Hidden { + continue + } + real++ + require.Positivef(t, m.DisplayRound, "%s: generation stamps every real bout's round", m.ID) + require.Positivef(t, m.MatchNumber, "%s: generation numbers every real bout", m.ID) + } + } + require.NotZero(t, real, "the shape must draw at least one real bout") + + changes, err := restamped.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Empty(t, changes, "the restamp moved a round, number or time generation stamped") + assert.Equal(t, stored, restamped) +} + +func TestRestampRoundsFromFeeders_LeavesFreshBracketsUnchanged(t *testing.T) { + t.Run("knockout", func(t *testing.T) { + for n := 2; n <= 40; n++ { + for _, courts := range []int{1, 2, 4} { + for _, seeds := range []int{0, 4, 8} { + if seeds > 0 && n <= seeds { + continue // not a seeded draw: every entrant would be a seed + } + t.Run(fmt.Sprintf("%d_entrants_%d_shiaijo_%d_seeds", n, courts, seeds), func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "restamp-knockout" + createTestCompetition(t, store, compID, state.CompFormatKnockout, 0, func(c *state.Competition) { + c.Courts = courtLabels(courts) + }) + players := makeSeededPlayers(n, seeds) + require.NoError(t, store.SaveParticipants(compID, makePlayers(n))) + var assignments []domain.SeedAssignment + for _, p := range players { + if p.Seed > 0 { + assignments = append(assignments, domain.SeedAssignment{Name: p.Name, Dojo: p.Dojo, SeedRank: p.Seed}) + } + } + if len(assignments) > 0 { + require.NoError(t, store.SaveSeeds(compID, assignments)) + } + require.NoError(t, eng.GenerateDraw(compID)) + assertRestampIsNoOp(t, store, compID) + }) + } + } + } + }) + + // Pool-fed brackets: the court-region draw (buildPoolFedDraw) with + // pool-origin placeholders, on the pool counts, qualifier counts and + // shiaijo counts the Excel parity sweep covers (unbalanced pool counts + // included), plus larger rosters at the default pool settings. + t.Run("mixed", func(t *testing.T) { + for _, numPools := range []int{2, 3, 4, 6, 7, 8} { + for _, winners := range []int{1, 2, 3} { + for _, courts := range []int{1, 2, 4} { + t.Run(fmt.Sprintf("%d_pools_%d_qualifiers_%d_shiaijo", numPools, winners, courts), func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "restamp-mixed" + createTestCompetition(t, store, compID, state.CompFormatMixed, 4, func(c *state.Competition) { + c.PoolSizeMode = "max" + c.PoolWinners = winners + c.Courts = courtLabels(courts) + }) + require.NoError(t, store.SaveParticipants(compID, mixedParityRoster(numPools))) + require.NoError(t, eng.GenerateDraw(compID)) + pools, err := store.LoadPools(compID) + require.NoError(t, err) + require.Lenf(t, pools, numPools, "the case must actually draw %d pools", numPools) + assertRestampIsNoOp(t, store, compID) + }) + } + } + } + for _, n := range []int{6, 11, 20, 33, 50, 80} { + for _, courts := range []int{1, 2, 4} { + t.Run(fmt.Sprintf("%d_entrants_%d_shiaijo", n, courts), func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "restamp-mixed-default" + createTestCompetition(t, store, compID, state.CompFormatMixed, 3, func(c *state.Competition) { + c.Courts = courtLabels(courts) + }) + require.NoError(t, store.SaveParticipants(compID, makePlayers(n))) + require.NoError(t, eng.GenerateDraw(compID)) + assertRestampIsNoOp(t, store, compID) + }) + } + } + }) +} + +// A bracket in play walks exactly like a fresh one: results resolve the +// "Winner of" sides into names, but Feeders and Hidden are never rewritten, so +// the restamp still changes nothing once every bout has been fought. +func TestRestampRoundsFromFeeders_LeavesPlayedBracketsUnchanged(t *testing.T) { + for _, n := range []int{5, 9, 13, 19, 27, 40} { + for _, courts := range []int{1, 4} { + t.Run(fmt.Sprintf("%d_entrants_%d_shiaijo", n, courts), func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "restamp-played" + createTestCompetition(t, store, compID, state.CompFormatKnockout, 0, func(c *state.Competition) { + c.Courts = courtLabels(courts) + }) + require.NoError(t, store.SaveParticipants(compID, makePlayers(n))) + require.NoError(t, eng.StartCompetition(compID)) + playKnockoutInNumberOrder(t, eng, store, compID) + assertRestampIsNoOp(t, store, compID) + }) + } + } +} + +// The pool-fed case a v2.0.0/v2.1.0 upgrade most often meets mid-event: every +// pool finished, the finishers resolved into the knockout in place of the +// "Pool A-1st" placeholders (ResolveQualifiedPools), and then the knockout +// fought out. Neither step may disturb the stored Feeders or Hidden. +func TestRestampRoundsFromFeeders_LeavesPlayedPoolFedBracketsUnchanged(t *testing.T) { + for _, numPools := range []int{3, 6, 7} { + for _, winners := range []int{1, 2, 3} { + for _, courts := range []int{1, 4} { + t.Run(fmt.Sprintf("%d_pools_%d_qualifiers_%d_shiaijo", numPools, winners, courts), func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "restamp-played-mixed" + createTestCompetition(t, store, compID, state.CompFormatMixed, 4, func(c *state.Competition) { + c.PoolSizeMode = "max" + c.PoolWinners = winners + c.Courts = courtLabels(courts) + }) + require.NoError(t, store.SaveParticipants(compID, mixedParityRoster(numPools))) + require.NoError(t, eng.StartCompetition(compID)) + completeAllPoolMatches(t, eng, store, compID) + + resolved := loadBracket(t, store, compID) + for _, round := range resolved.Rounds { + for _, m := range round { + require.Falsef(t, helper.IsPoolFinalistPlaceholder(m.SideA) || helper.IsPoolFinalistPlaceholder(m.SideB), + "%s still waits on a pool (%s v %s): the fixture must resolve every finisher", m.ID, m.SideA, m.SideB) + } + } + assertRestampIsNoOp(t, store, compID) + + playKnockoutInNumberOrder(t, eng, store, compID) + assertRestampIsNoOp(t, store, compID) + }) + } + } + } +} + +// playKnockoutInNumberOrder fights every numbered bout, side A winning. Match +// numbers run deepest round first, so fighting them in number order always +// finds both sides already decided. +func playKnockoutInNumberOrder(t *testing.T, eng *Engine, store *state.Store, compID string) { + t.Helper() + numbered := 0 + for _, round := range loadBracket(t, store, compID).Rounds { + for _, m := range round { + if m.MatchNumber > 0 { + numbered++ + } + } + } + require.NotZero(t, numbered) + for num := 1; num <= numbered; num++ { + var m *state.BracketMatch + b := loadBracket(t, store, compID) + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + if b.Rounds[ri][mi].MatchNumber == num { + m = &b.Rounds[ri][mi] + } + } + } + require.NotNilf(t, m, "Match %d", num) + require.Falsef(t, strings.HasPrefix(m.SideA, "Winner of") || strings.HasPrefix(m.SideB, "Winner of"), + "Match %d (%s) is fought before its feeders: %s v %s", num, m.ID, m.SideA, m.SideB) + scoreBracketMatch(t, eng, store, compID, m.ID, m.SideA) + } + final := loadBracket(t, store, compID) + require.Equal(t, state.MatchStatusCompleted, final.Rounds[len(final.Rounds)-1][0].Status) +} diff --git a/internal/engine/bronze_label_test.go b/internal/engine/bronze_label_test.go index be5c4b293..ae593b21a 100644 --- a/internal/engine/bronze_label_test.go +++ b/internal/engine/bronze_label_test.go @@ -7,14 +7,14 @@ import ( "github.com/stretchr/testify/assert" ) -// The 3rd-place match is numbered neither by assignBracketMatchNumbers (which +// The 3rd-place match is numbered neither by state.Bracket.NumberMatches (which // walks Bracket.Rounds) nor by the printed tree, so without a name of its own // MatchLabel's id fallback would put "m-bronze" in front of an operator. func TestMatchLabel_NamesTheBronzeRatherThanItsID(t *testing.T) { assert.Equal(t, "the 3rd-place match", MatchLabel(ReopenedMatch{ID: state.BronzeMatchID})) - assert.Equal(t, "Match 4", MatchLabel(ReopenedMatch{ID: state.BronzeMatchID, Number: 4}), + assert.Equal(t, "knockout Match 4", MatchLabel(ReopenedMatch{ID: state.BronzeMatchID, Number: 4}), "a number, when one exists, still wins") - assert.Equal(t, "Match 7", MatchLabel(ReopenedMatch{ID: "m-r3-0", Number: 7})) + assert.Equal(t, "knockout Match 7", MatchLabel(ReopenedMatch{ID: "m-r3-0", Number: 7})) assert.Equal(t, "m-r3-0", MatchLabel(ReopenedMatch{ID: "m-r3-0"}), "an unnumbered, unnamed match still falls back to its id") } diff --git a/internal/engine/chusen.go b/internal/engine/chusen.go index ce97cbccb..96c1d8c0a 100644 --- a/internal/engine/chusen.go +++ b/internal/engine/chusen.go @@ -19,46 +19,83 @@ import ( // so these fields are never marshaled directly and carry no json tags. type ChusenGroup struct { PoolName string - // Teams are the still-tied members in current standings order. + // Teams are the tied members in current standings order (which, once a + // chusen is recorded, is the order the override ranks sort them into). Teams []state.PlayerStanding // MinPosition is the 1-based finishing position of the best-placed member. MinPosition int + // Ranks is each team's recorded chusen rank, parallel to Teams, read from + // the override itself rather than from the standings row. Set only on a + // group already settled by chusen (ChusenReport.Recorded); nil on one + // still waiting for it. + Ranks []int +} + +// ChusenReport is every consequential team-pool tie the daihyosen left +// undetermined, split by whether the operator has recorded its chusen yet. +type ChusenReport struct { + // Pending groups still need a chusen (drawing lots). + Pending []ChusenGroup + // Recorded groups were settled by a chusen already recorded + // (groupSettledByChusen). The operator may change the recorded order, in + // case it was entered wrongly. + Recorded []ChusenGroup } // groupNeedsChusen reports whether a tied group remains unresolved after its // daihyosen bouts and therefore needs a chusen (drawing lots). It is the single // per-group predicate shared by dhCycleExists (which blocks auto-completion) and -// ChusenCandidates (which surfaces the groups to the operator). groupOverrides -// is poolRanks[poolName] for the group's pool (nil when none). +// ChusenStatus (which surfaces the groups to the operator). groupOverrides is +// poolRanks[poolName] for the group's pool (nil when none). // -// Returns false when: the operator has already ranked every member (chusen -// recorded), no daihyosen bout among the group has been played yet, or the -// played bouts produced a strictly-ordered win count. Returns true whenever two -// or more members finish the completed round on the same daihyosen win count -// (a true win/loss cycle, an all-drawn round, or any other partial tie) so the -// order is undetermined. +// Returns false when the operator has already ranked every member (chusen +// recorded, chusenRecorded) or when daihyosenLeftTied says the bouts settled +// the order or have not all been fought yet. +func groupNeedsChusen(group []state.PlayerStanding, allMatches []state.MatchResult, groupOverrides map[string]int) bool { + return !chusenRecorded(group, groupOverrides) && daihyosenLeftTied(group, allMatches) +} + +// groupSettledByChusen reports whether a tied group WAS decided by chusen: its +// daihyosen left the order undetermined, exactly as groupNeedsChusen requires, +// and the operator has since recorded a rank for every member. It is the one +// owner of "which groups were decided by chusen", which are the only groups +// whose recorded order the operator is offered to change: a chusen entered in +// the wrong order must stay fixable, but no other rank is set by hand. +func groupSettledByChusen(group []state.PlayerStanding, allMatches []state.MatchResult, groupOverrides map[string]int) bool { + return chusenRecorded(group, groupOverrides) && daihyosenLeftTied(group, allMatches) +} + +// chusenRecorded reports whether every member of a tied group carries a +// pool-rank override, i.e. the operator has recorded the drawn order. // // groupOverrides is resolved per member via lookupPoolRankOverride, keyed by // participant id ONLY (bc-cse, bc-pnum). Two same-name, different-dojo // teammates in one tied group therefore never share a single "already // recorded" verdict -- each is checked against its own override entry. -func groupNeedsChusen(group []state.PlayerStanding, allMatches []state.MatchResult, groupOverrides map[string]int) bool { - if len(groupOverrides) > 0 { - allOverridden := true - for _, s := range group { - if _, ok := lookupPoolRankOverride(groupOverrides, s.Player.ID); !ok { - allOverridden = false - break - } - } - if allOverridden { +func chusenRecorded(group []state.PlayerStanding, groupOverrides map[string]int) bool { + if len(groupOverrides) == 0 { + return false + } + for _, s := range group { + if _, ok := lookupPoolRankOverride(groupOverrides, s.Player.ID); !ok { return false } } + return true +} + +// daihyosenLeftTied reports whether a tied group's daihyosen bouts left its +// order undetermined, whatever the operator has recorded since. Returns false +// when no daihyosen bout among the group has been played yet, the round is not +// complete, or the played bouts produced a strictly-ordered win count. Returns +// true whenever two or more members finish the completed round on the same +// daihyosen win count (a true win/loss cycle, an all-drawn round, or any other +// partial tie). +func daihyosenLeftTied(group []state.PlayerStanding, allMatches []state.MatchResult) bool { // Membership and win counts key on competitor identity by participant id // ONLY (operator ruling bc-pnum), matching applyTiebreakSort next door. - // Note what this is and is not: chusen is team-only (ChusenCandidates - // returns nil for an individual competition) and two TEAMS may not share + // Note what this is and is not: chusen is team-only (ChusenStatus + // reports nothing for an individual competition) and two TEAMS may not share // a name even across dojos (checkNewTeamNameCollisions, // state/participants.go), so unlike the individual tiebreak path this is // NOT a routinely reachable collision. It is kept because the team-name @@ -119,37 +156,43 @@ func groupNeedsChusen(group []state.PlayerStanding, allMatches []state.MatchResu return false } -// ChusenCandidates returns the consequential team-pool ties that the daihyosen -// left undetermined and that therefore need a chusen (drawing lots). It is the -// single source of truth for "which groups still need an operator lots-draw", -// used by the GET /chusen-candidates endpoint. Empty (not an error) when the -// competition is not a team comp in the pools stage, or no such group exists. +// ChusenStatus returns the consequential team-pool ties that the daihyosen +// left undetermined, split into those that still need a chusen (drawing lots) +// and those a recorded chusen already settled. It is the single source of +// truth for both, used by the GET /chusen-candidates endpoint: "which groups +// still need an operator lots-draw" is groupNeedsChusen, and "which groups were +// decided by chusen" is groupSettledByChusen. Empty (not an error) when the +// competition is not a team comp whose pool order can still be set by hand +// (state.Competition.AcceptsPoolRankOverride: the pools stage, or a pools + +// knockout competition's knockout, where a pool correction can reopen a tie), +// or no such group exists. // // Pools are returned in name order for stable output. -func (e *Engine) ChusenCandidates(compID string) ([]ChusenGroup, error) { +func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { + var report ChusenReport comp, err := e.store.LoadCompetition(compID) if err != nil { - return nil, err + return report, err } if comp == nil { - return nil, notFoundErrorf("competition %s not found", compID) + return report, notFoundErrorf("competition %s not found", compID) } isTeam := comp.Kind == "team" || comp.TeamSize > 0 - if !isTeam || comp.Status != state.CompStatusPools { - return nil, nil + if !isTeam || !comp.AcceptsPoolRankOverride() { + return report, nil } standings, err := e.CalculatePoolStandings(compID) if err != nil { - return nil, err + return report, err } matches, err := e.store.LoadPoolMatches(compID) if err != nil { - return nil, err + return report, err } overridesObj, err := e.store.LoadOverrides(compID) if err != nil { - return nil, err + return report, err } var poolRanks map[string]map[string]int if overridesObj != nil { @@ -163,23 +206,33 @@ func (e *Engine) ChusenCandidates(compID string) ([]ChusenGroup, error) { } sort.Strings(poolNames) - var out []ChusenGroup for _, poolName := range poolNames { poolStandings := standings[poolName] + groupOverrides := poolRanks[poolName] for _, positions := range detectPoolTies(poolStandings) { // Only a tie that affects advancement/seed warrants a decider at all. if !tieAffectsAdvancement(positions, poolWinners) { continue } group := standingsAt(poolStandings, positions) - if groupNeedsChusen(group, matches, poolRanks[poolName]) { - out = append(out, ChusenGroup{ - PoolName: poolName, - Teams: group, - MinPosition: positions[0] + 1, - }) + g := ChusenGroup{ + PoolName: poolName, + Teams: group, + MinPosition: positions[0] + 1, + } + switch { + case groupNeedsChusen(group, matches, groupOverrides): + report.Pending = append(report.Pending, g) + case groupSettledByChusen(group, matches, groupOverrides): + g.Ranks = make([]int, len(group)) + for i, s := range group { + if r, ok := lookupPoolRankOverride(groupOverrides, s.Player.ID); ok { + g.Ranks[i] = r + } + } + report.Recorded = append(report.Recorded, g) } } } - return out, nil + return report, nil } diff --git a/internal/engine/chusen_test.go b/internal/engine/chusen_test.go index 68a942f50..cea8bf994 100644 --- a/internal/engine/chusen_test.go +++ b/internal/engine/chusen_test.go @@ -41,8 +41,9 @@ func TestChusenCandidates_CycleNeedsChusen(t *testing.T) { return sideA }) - cands, err := eng.ChusenCandidates(compID) + report, err := eng.ChusenStatus(compID) require.NoError(t, err) + cands := report.Pending require.Len(t, cands, 1, "an unresolved 3-way daihyosen cycle needs a chusen") assert.Equal(t, "Pool A", cands[0].PoolName) assert.Len(t, cands[0].Teams, 3) @@ -65,8 +66,9 @@ func TestChusenCandidates_StrictOrderNeedsNoChusen(t *testing.T) { return "Beta" }) - cands, err := eng.ChusenCandidates(compID) + report, err := eng.ChusenStatus(compID) require.NoError(t, err) + cands := report.Pending assert.Empty(t, cands, "a strictly-ordered daihyosen needs no chusen") } @@ -107,11 +109,127 @@ func TestChusenCandidates_ResolvedByOverride(t *testing.T) { eng.standingsCache.Delete(compID) eng.standingsFlight.Delete(compID) - cands, err := eng.ChusenCandidates(compID) + report, err := eng.ChusenStatus(compID) require.NoError(t, err) + cands := report.Pending assert.Empty(t, cands, "a chusen recorded as a full rank override clears the candidate") } +// scoreDHCycle scores setupTeamPoolComp's injected daihyosen round into the +// perfect cycle Alpha > Beta > Gamma > Alpha (one win each), the tie only a +// chusen settles. Checked as UNORDERED pairs; see +// TestChusenCandidates_CycleNeedsChusen for why a fixed side cannot be assumed. +func scoreDHCycle(t *testing.T, eng *Engine, store *state.Store, compID string) { + t.Helper() + _, err := eng.InjectPoolDaihyosenMatches(compID) + require.NoError(t, err) + scoreInjectedDH(t, eng, store, compID, func(sideA, sideB string) string { + pair := map[string]bool{sideA: true, sideB: true} + switch { + case pair["Alpha"] && pair["Beta"]: + return "Alpha" + case pair["Alpha"] && pair["Gamma"]: + return "Gamma" + case pair["Beta"] && pair["Gamma"]: + return "Beta" + } + return sideA + }) +} + +// recordChusenOrder records a drawn order for Pool A through the store, one +// team per rank starting at 1, and drops the standings cache so the next read +// sees it. +func recordChusenOrder(t *testing.T, eng *Engine, store *state.Store, compID string, order ...string) { + t.Helper() + for i, name := range order { + require.NoError(t, store.SaveRankOverride(compID, "Pool A", bctest.StampPlayerID(name, "Dojo "+name), i+1)) + } + eng.standingsCache.Delete(compID) + eng.standingsFlight.Delete(compID) +} + +func chusenTeamNames(g ChusenGroup) []string { + names := make([]string, len(g.Teams)) + for i, s := range g.Teams { + names[i] = s.Player.Name + } + return names +} + +// A chusen recorded in the wrong order must stay fixable, so ChusenStatus +// reports a group a recorded chusen settled, in the recorded order and with +// the recorded ranks, and keeps reporting it after the order is changed. The +// orders used here are deliberately not the natural one, so the reported +// order can only have come from the override. +func TestChusenStatus_ReportsTheRecordedOrder(t *testing.T) { + compID := "chusen-recorded" + eng, store := setupTeamPoolComp(t, compID, true) + scoreDHCycle(t, eng, store, compID) + + report, err := eng.ChusenStatus(compID) + require.NoError(t, err) + require.Len(t, report.Pending, 1) + assert.Empty(t, report.Recorded, "a tie still waiting for its chusen is not a recorded one") + assert.Nil(t, report.Pending[0].Ranks, "a pending group carries no recorded ranks") + + recordChusenOrder(t, eng, store, compID, "Gamma", "Alpha", "Beta") + report, err = eng.ChusenStatus(compID) + require.NoError(t, err) + assert.Empty(t, report.Pending, "the recorded chusen clears the candidate") + require.Len(t, report.Recorded, 1, "the group the chusen settled is reported as recorded") + g := report.Recorded[0] + assert.Equal(t, "Pool A", g.PoolName) + assert.Equal(t, 1, g.MinPosition) + assert.Equal(t, []string{"Gamma", "Alpha", "Beta"}, chusenTeamNames(g)) + assert.Equal(t, []int{1, 2, 3}, g.Ranks) + + // The operator changes it: still the same recorded group, now in the new order. + recordChusenOrder(t, eng, store, compID, "Beta", "Gamma", "Alpha") + report, err = eng.ChusenStatus(compID) + require.NoError(t, err) + assert.Empty(t, report.Pending) + require.Len(t, report.Recorded, 1) + assert.Equal(t, []string{"Beta", "Gamma", "Alpha"}, chusenTeamNames(report.Recorded[0])) + assert.Equal(t, []int{1, 2, 3}, report.Recorded[0].Ranks) +} + +// Only a group the daihyosen left undetermined is a chusen, so only such a +// group is reported as recorded. An override on a group the daihyosen put in +// a strict order (reachable only through the API) is not a chusen, and a +// partly recorded one is still waiting for its chusen. +func TestChusenStatus_RecordedIsOnlyAChusen(t *testing.T) { + t.Run("an override on a daihyosen-decided group is not a recorded chusen", func(t *testing.T) { + compID := "chusen-strict-override" + eng, store := setupTeamPoolComp(t, compID, true) + _, err := eng.InjectPoolDaihyosenMatches(compID) + require.NoError(t, err) + scoreInjectedDH(t, eng, store, compID, func(sideA, sideB string) string { + if sideA == "Alpha" || sideB == "Alpha" { + return "Alpha" + } + return "Beta" + }) + recordChusenOrder(t, eng, store, compID, "Alpha", "Beta", "Gamma") + + report, err := eng.ChusenStatus(compID) + require.NoError(t, err) + assert.Empty(t, report.Pending) + assert.Empty(t, report.Recorded) + }) + t.Run("a partly recorded chusen is still pending", func(t *testing.T) { + compID := "chusen-partial-override" + eng, store := setupTeamPoolComp(t, compID, true) + scoreDHCycle(t, eng, store, compID) + recordChusenOrder(t, eng, store, compID, "Gamma") + + report, err := eng.ChusenStatus(compID) + require.NoError(t, err) + assert.Len(t, report.Pending, 1) + assert.Empty(t, report.Recorded) + }) +} + // TestChusenCandidates_NonTeamHasNone: individual (non-team) competitions never // surface chusen candidates (chusen here resolves team-pool daihyosen cycles). func TestChusenCandidates_NonTeamHasNone(t *testing.T) { @@ -120,8 +238,9 @@ func TestChusenCandidates_NonTeamHasNone(t *testing.T) { ID: "chusen-indiv", Name: "Individual", Format: state.CompFormatLeague, Status: state.CompStatusPools, Courts: []string{"A"}, TeamSize: 0, })) - cands, err := eng.ChusenCandidates("chusen-indiv") + report, err := eng.ChusenStatus("chusen-indiv") require.NoError(t, err) + cands := report.Pending assert.Empty(t, cands) } @@ -151,8 +270,9 @@ func TestChusenCandidates_PartialRoundNotPremature(t *testing.T) { eng.standingsCache.Delete(compID) eng.standingsFlight.Delete(compID) - cands, err := eng.ChusenCandidates(compID) + report, err := eng.ChusenStatus(compID) require.NoError(t, err) + cands := report.Pending assert.Empty(t, cands, "chusen must not surface mid-round (only 1 of 3 DH bouts scored)") } @@ -181,8 +301,9 @@ func TestChusenCandidates_AllDrawnNeedsChusen(t *testing.T) { eng.standingsCache.Delete(compID) eng.standingsFlight.Delete(compID) - cands, err := eng.ChusenCandidates(compID) + report, err := eng.ChusenStatus(compID) require.NoError(t, err) + cands := report.Pending require.Len(t, cands, 1, "an all-drawn daihyosen round leaves the order undetermined -> chusen") } @@ -230,8 +351,9 @@ func TestChusenCandidates_PartialTieWithoutCycleNeedsChusen(t *testing.T) { // Final daihyosen wins: Alpha=3, Beta=1, Gamma=1, Delta=0 - a duplicate at // 1 with no cyclic relationship anywhere in the results. - cands, err := eng.ChusenCandidates(compID) + report, err := eng.ChusenStatus(compID) require.NoError(t, err) + cands := report.Pending require.Len(t, cands, 1, "Beta and Gamma tie on daihyosen wins with no cycle present -> still needs chusen") assert.Len(t, cands[0].Teams, 4) } @@ -272,3 +394,216 @@ func TestGroupNeedsChusen_SameNameTeamsKeyOnIdentity(t *testing.T) { assert.False(t, groupNeedsChusen(group, matches, nil), "a decided daihyosen order must not need a chusen just because two teams share a name") } + +// A pool correction made after a mixed competition's knockout has started can +// leave a qualifying tie the daihyosen cannot settle (here a three-way cycle). +// The chusen that settles it must be offered and accepted in knockout status, +// and seat the slot the correction sent back to its label; otherwise the +// knockout match that slot feeds can never be played. A chusen recorded by +// mistake then stays fixable like any rank override: named and refused while +// the old qualifier has fought, and, confirmed, reopened with the new one. +func TestChusen_KnockoutStatus_SeatsTheSlotAndStaysFixable(t *testing.T) { + f := newRQFixture(t, "chusen-ko", 1, [][]string{{"Alpha", "Beta", "Gamma"}, {"Delta", "Epsilon"}}, + func(c *state.Competition) { c.Kind, c.TeamSize = "team", 2 }) + // Pool A-0: Alpha v Beta, A-1: Alpha v Gamma, A-2: Beta v Gamma. + f.scorePool("Pool A-0", "Alpha") + f.scorePool("Pool A-1", "Alpha") + f.scorePool("Pool A-2", "Beta") + f.scorePool("Pool B-0", "Delta") + outcome, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + require.Equal(t, AutoCompleteKnockoutStarted, outcome) + final, side := f.slot("Pool A-1st") + name, _ := sideOf(final, side) + require.Equal(t, "Alpha", name) + + // The correction: every Pool A encounter was drawn. The tied place goes + // back to its label, and the daihyosen round is injected in knockout status. + draw := func(matchID string) { + m := loadPoolMatchByID(t, f.store, f.compID, matchID) + require.NoError(t, f.write(matchID, &state.MatchResult{ + SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + } + for _, id := range []string{"Pool A-0", "Pool A-1", "Pool A-2"} { + draw(id) + } + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + final, _ = f.slot("Pool A-1st") + name, _ = sideOf(final, side) + require.Equal(t, "Pool A-1st", name, "a tied place is not a finisher") + + // The daihyosen cycles: Alpha > Beta, Beta > Gamma, Gamma > Alpha. + scoreInjectedDH(t, f.eng, f.store, f.compID, func(sideA, sideB string) string { + pair := map[string]bool{sideA: true, sideB: true} + switch { + case pair["Alpha"] && pair["Beta"]: + return "Alpha" + case pair["Beta"] && pair["Gamma"]: + return "Beta" + } + return "Gamma" + }) + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + final, _ = f.slot("Pool A-1st") + name, _ = sideOf(final, side) + require.Equal(t, "Pool A-1st", name, "a cycle leaves the place undecided") + comp, err := f.store.LoadCompetition(f.compID) + require.NoError(t, err) + require.Equal(t, state.CompStatusKnockout, comp.Status) + + report, err := f.eng.ChusenStatus(f.compID) + require.NoError(t, err) + require.Len(t, report.Pending, 1, "the chusen is offered in knockout status") + assert.Equal(t, "Pool A", report.Pending[0].PoolName) + assert.Len(t, report.Pending[0].Teams, 3) + + for rank, team := range []string{"Alpha", "Beta", "Gamma"} { + changed, oerr := f.eng.OverridePoolRanks(f.compID, "Pool A", []RankOverride{{PlayerID: rqID(team), Rank: rank + 1}}) + require.NoError(t, oerr) + assert.True(t, changed) + } + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + final, _ = f.slot("Pool A-1st") + name, id := sideOf(final, side) + assert.Equal(t, "Alpha", name, "the chusen seats the slot") + assert.Equal(t, rqID("Alpha"), id) + report, err = f.eng.ChusenStatus(f.compID) + require.NoError(t, err) + assert.Empty(t, report.Pending, "the recorded chusen clears the candidate") + require.Len(t, report.Recorded, 1, "the recorded chusen is still reported in knockout status, so it can be changed") + assert.Equal(t, []string{"Alpha", "Beta", "Gamma"}, chusenTeamNames(report.Recorded[0])) + assert.Equal(t, []int{1, 2, 3}, report.Recorded[0].Ranks) + + // The final is fought, then the chusen turns out to have been misrecorded: + // Alpha drew 3rd, so Beta (2nd) holds Pool A's 1st place. + require.NoError(t, f.scoreKO(final.ID, "Alpha")) + _, err = f.eng.OverridePoolRanks(f.compID, "Pool A", []RankOverride{{PlayerID: rqID("Alpha"), Rank: 3}}) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Len(t, played.Blocking, 1) + assert.Equal(t, final.ID, played.Blocking[0].ID) + o, err := f.store.LoadOverrides(f.compID) + require.NoError(t, err) + assert.Equal(t, 1, o.PoolRanks["Pool A"]["id:"+rqID("Alpha")], "the refused override is taken back") + + var reopened []ReopenedMatch + changed, err := f.eng.OverridePoolRanks(f.compID, "Pool A", []RankOverride{{PlayerID: rqID("Alpha"), Rank: 3}}, ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + assert.True(t, changed) + require.Len(t, reopened, 1) + assert.Equal(t, final.ID, reopened[0].ID) + got := findBracketMatchInBracket(f.bracket(), final.ID) + assert.Equal(t, state.MatchStatusScheduled, got.Status) + name, id = sideOf(*got, side) + assert.Equal(t, "Beta", name, "the corrected chusen seats the new 1st") + assert.Equal(t, rqID("Beta"), id) +} + +// Changing a recorded chusen is ONE write, answered for on the final order. +// Two qualifiers per pool, a three-way tie in Pool A recorded Alpha 1st, Beta +// 2nd, Gamma 3rd, and the knockout matches both of Pool A's places feed +// already fought. The lots were misread: the real order is Gamma, Beta, +// Alpha. Only 1st place changes hands, so only its match may be named. +// +// Set one rank at a time, the first write (Alpha 3rd) leaves Alpha and Gamma +// tied on 3 behind Beta: Beta read as 1st and 2nd place as moved too, so the +// operator was asked to reopen BOTH fought matches and, confirming, had the +// 2nd-place result cleared although Beta keeps 2nd. Declining left Alpha's +// new rank recorded on its own. +func TestOverridePoolRanks_ChangingAChusenNamesOnlyTheRealMove(t *testing.T) { + f := newRQFixture(t, "chusen-change", 2, [][]string{{"Alpha", "Beta", "Gamma"}, {"Delta", "Epsilon"}}, + func(c *state.Competition) { c.Kind, c.TeamSize = "team", 2 }) + for _, id := range []string{"Pool A-0", "Pool A-1", "Pool A-2"} { + m := loadPoolMatchByID(t, f.store, f.compID, id) + require.NoError(t, f.write(id, &state.MatchResult{ + SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + } + f.scorePool("Pool B-0", "Delta") + _, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + // The daihyosen cycles: Alpha > Beta, Beta > Gamma, Gamma > Alpha. + scoreInjectedDH(t, f.eng, f.store, f.compID, func(sideA, sideB string) string { + pair := map[string]bool{sideA: true, sideB: true} + switch { + case pair["Alpha"] && pair["Beta"]: + return "Alpha" + case pair["Beta"] && pair["Gamma"]: + return "Beta" + } + return "Gamma" + }) + report, err := f.eng.ChusenStatus(f.compID) + require.NoError(t, err) + require.Len(t, report.Pending, 1, "the cycle needs a chusen") + + ranks := func(alpha, beta, gamma int) []RankOverride { + return []RankOverride{{rqID("Alpha"), alpha}, {rqID("Beta"), beta}, {rqID("Gamma"), gamma}} + } + recorded := func() map[string]int { + o, lerr := f.store.LoadOverrides(f.compID) + require.NoError(t, lerr) + return o.PoolRanks["Pool A"] + } + want := func(alpha, beta, gamma int) map[string]int { + return map[string]int{"id:" + rqID("Alpha"): alpha, "id:" + rqID("Beta"): beta, "id:" + rqID("Gamma"): gamma} + } + + changed, err := f.eng.OverridePoolRanks(f.compID, "Pool A", ranks(1, 2, 3)) + require.NoError(t, err) + require.True(t, changed) + outcome, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + require.Equal(t, AutoCompleteKnockoutStarted, outcome) + first, firstSide := f.slot("Pool A-1st") + second, secondSide := f.slot("Pool A-2nd") + require.NotEqual(t, first.ID, second.ID) + name, _ := sideOf(first, firstSide) + require.Equal(t, "Alpha", name) + name, _ = sideOf(second, secondSide) + require.Equal(t, "Beta", name) + require.NoError(t, f.scoreKO(first.ID, "Alpha")) + require.NoError(t, f.scoreKO(second.ID, "Beta")) + + // Refused: only 1st place moves, so only its match is named, and every + // rank of the group stays as it was recorded. + changed, err = f.eng.OverridePoolRanks(f.compID, "Pool A", ranks(3, 2, 1)) + assert.False(t, changed) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Len(t, played.Blocking, 1, "the 2nd-place match keeps Beta and is not named") + assert.Equal(t, first.ID, played.Blocking[0].ID) + require.Len(t, played.QualifierChange, 1) + assert.Equal(t, 1, played.QualifierChange[0].Rank) + assert.Equal(t, QualifierIdentity{Name: "Alpha", ID: rqID("Alpha")}, played.QualifierChange[0].From) + assert.Equal(t, QualifierIdentity{Name: "Gamma", ID: rqID("Gamma")}, played.QualifierChange[0].To) + assert.Equal(t, want(1, 2, 3), recorded(), "the refusal takes back every rank of the group") + for _, id := range []string{first.ID, second.ID} { + assert.Equal(t, state.MatchStatusCompleted, findBracketMatchInBracket(f.bracket(), id).Status, "nothing reopened before the operator confirms") + } + + // Confirmed: the 1st-place match reopens with Gamma seated; the + // 2nd-place match keeps its result. + var reopened []ReopenedMatch + changed, err = f.eng.OverridePoolRanks(f.compID, "Pool A", ranks(3, 2, 1), ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, want(3, 2, 1), recorded()) + require.Len(t, reopened, 1) + assert.Equal(t, first.ID, reopened[0].ID) + b := f.bracket() + got := findBracketMatchInBracket(b, first.ID) + assert.Equal(t, state.MatchStatusScheduled, got.Status) + name, id := sideOf(*got, firstSide) + assert.Equal(t, "Gamma", name) + assert.Equal(t, rqID("Gamma"), id) + kept := findBracketMatchInBracket(b, second.ID) + assert.Equal(t, state.MatchStatusCompleted, kept.Status, "Beta keeps 2nd, so its fought match keeps its result") + assert.Equal(t, "Beta", kept.Winner) +} diff --git a/internal/engine/competition.go b/internal/engine/competition.go index 20237f47b..a0288697e 100644 --- a/internal/engine/competition.go +++ b/internal/engine/competition.go @@ -124,11 +124,39 @@ const ( // locks; that is safe because MaybeAutoCompletePools is NOT inside an open // transform at that point. func (e *Engine) MaybeAutoCompletePools(compID string) (AutoCompleteOutcome, error) { + return e.maybeAutoCompletePools(compID, true) +} + +// MaybeAutoCompletePoolsAfterWrite is MaybeAutoCompletePools for a door that +// has just written the given matches (a score, a decision, a bulk batch), and +// the one such doors call. The difference is the knockout-status pool pass +// (advanceMixedPools once a mixed competition is in its knockout): it re-reads +// every pool's matches and standings, runs both tie-break injectors and parses +// the bracket, under the per-competition lock, and only a write that can move +// who holds a qualifying place needs it (poolWriteCanMoveQualifiers: a pool +// match, tie-break and pool daihyosen rows included, left completed). Every +// knockout write and every autosave in knockout status therefore skips it. A +// door that moves standings WITHOUT a match write (a pool-rank override) calls +// MaybeAutoCompletePools, which always runs it. +func (e *Engine) MaybeAutoCompletePoolsAfterWrite(compID string, written ...state.MatchResult) (AutoCompleteOutcome, error) { + poolsMayHaveMoved := false + for i := range written { + if poolWriteCanMoveQualifiers(written[i].ID, written[i].Status) { + poolsMayHaveMoved = true + break + } + } + return e.maybeAutoCompletePools(compID, poolsMayHaveMoved) +} + +// maybeAutoCompletePools is the shared body of both doors above. +// poolsMayHaveMoved gates only the knockout-status pool pass. +func (e *Engine) maybeAutoCompletePools(compID string, poolsMayHaveMoved bool) (AutoCompleteOutcome, error) { started, comp, err := e.autoStartOnFirstResult(compID) if err != nil { return AutoCompleteNoChange, err } - outcome, err := e.maybeAutoCompletePoolsRunning(compID, comp) + outcome, err := e.maybeAutoCompletePoolsRunning(compID, comp, poolsMayHaveMoved) if err != nil { return outcome, err } @@ -227,14 +255,37 @@ func (e *Engine) hasRecordedResult(compID string) (bool, error) { // maybeAutoCompletePoolsRunning is MaybeAutoCompletePools' pool-phase half. // comp is the caller's already-loaded competition (possibly nil when no such -// competition exists), so the two halves share one read. -func (e *Engine) maybeAutoCompletePoolsRunning(compID string, comp *state.Competition) (AutoCompleteOutcome, error) { +// competition exists), so the two halves share one read. poolsMayHaveMoved +// is false only when the caller knows its write cannot have moved pool +// standings (MaybeAutoCompletePoolsAfterWrite). +func (e *Engine) maybeAutoCompletePoolsRunning(compID string, comp *state.Competition, poolsMayHaveMoved bool) (AutoCompleteOutcome, error) { // MIXED (Pools + Knockout): resolve incrementally, pool finishers drop into // their knockout slots the moment each pool completes, with NO wait for the // rest of the pool phase. Short-circuit BEFORE the comp-wide "all pools done" // gate below (which is only meaningful for league completion). - if comp != nil && comp.Format == state.CompFormatMixed && comp.Status == state.CompStatusPools { - return e.advanceMixedPools(compID, comp) + // + // KNOCKOUT status too, not just pools: a pool correction made after the + // last pool was seeded can leave a place tied (its slot back on its draw + // label, pool_requalify.go), and the tie-break that settles it is injected + // and then seated only here. Stopping at the pools->knockout flip left that + // slot a placeholder for the rest of the competition. Everything this does + // is idempotent, and resolveSlots never repaints a running or played match. + // + // In knockout status it runs only when pool standings may have moved: it + // costs a full re-read of the pools, standings, both injectors and the + // bracket under the per-competition lock, and once the pools are seeded + // almost every write is a knockout match (or its autosave), which can move + // nothing here. Such a write returns NoChange rather than falling through + // to the league path below, which a mixed competition has no use for. + if comp != nil && comp.Format == state.CompFormatMixed { + switch { + case comp.Status == state.CompStatusPools: + return e.advanceMixedPools(compID, comp) + case comp.Status == state.CompStatusKnockout && poolsMayHaveMoved: + return e.advanceMixedPools(compID, comp) + case comp.Status == state.CompStatusKnockout: + return AutoCompleteNoChange, nil + } } // Optional fast-path outside the lock, avoids taking the @@ -537,7 +588,7 @@ func leagueGroupHasDH(group []state.PlayerStanding, allMatches []state.MatchResu // dhCycleExists reports whether any tied group is still unresolved after its // daihyosen bouts (a cycle / all-drawn), i.e. it needs a chusen. Delegates the -// per-group check to groupNeedsChusen (the same predicate ChusenCandidates uses +// per-group check to groupNeedsChusen (the same predicate ChusenStatus uses // to surface those groups to the operator). Below-cut ties never block: DH // matches are injected only for advancement-affecting groups, so a below-cut // group has no DH bouts and groupNeedsChusen returns false. When it does return diff --git a/internal/engine/daihyosen.go b/internal/engine/daihyosen.go index b2a2161b6..1ddcec1f2 100644 --- a/internal/engine/daihyosen.go +++ b/internal/engine/daihyosen.go @@ -107,42 +107,18 @@ func poolNameFromMatchID(id string) (string, bool) { return "", false } -// hasNumericSuffixAfter reports whether id ends with marker followed by one -// or more digits and nothing else, e.g. hasNumericSuffixAfter("Pool A-DH-3", -// "-DH-") is true. A plain strings.Contains(id, marker) would also match a -// REGULAR pool match whose pool name happens to contain the marker, e.g. a -// pool literally named "Pool A-DH-East" produces regular match ids like -// "Pool A-DH-East-0"; that id contains "-DH-" but is not a daihyosen bout -// (its numeric suffix follows a later, unmarked "-"). Anchoring the digits to -// the LAST occurrence of marker rejects that case: the suffix after it is -// "East-0", not all-digits, so it correctly reports false. Mirrors the JS -// twin's anchored regex (pool_ids.jsx DAIHYOSEN_BOUT_RE / SUPPLEMENTARY_BOUT_RE, -// both /-DH-\d+$/ style), which was already correctly suffix-anchored. -func hasNumericSuffixAfter(id, marker string) bool { - i := strings.LastIndex(id, marker) - if i < 0 { - return false - } - suffix := id[i+len(marker):] - if suffix == "" { - return false - } - for _, c := range suffix { - if c < '0' || c > '9' { - return false - } - } - return true -} - // IsPoolDaihyosenMatchID reports whether a match ID is a pool-stage // daihyosen bout (IDs of the form "Pool X-DH-N"). These are generated // by InjectPoolDaihyosenMatches when all team-pool matches complete with // a tie on all 8 ranking criteria. They are structurally pool matches // but scored as individual (one representative per side) rather than as // full team bouts. +// +// Delegates to state.IsPoolDaihyosenMatchID, the id grammar's one owner +// (state cannot import engine, so the definition lives there and this is +// the engine-facing name every other file in this package already uses). func IsPoolDaihyosenMatchID(matchID string) bool { - return hasNumericSuffixAfter(matchID, "-DH-") + return state.IsPoolDaihyosenMatchID(matchID) } // generatePoolDaihyosenMatches creates round-robin MatchResult entries for @@ -235,10 +211,8 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, return nil, notFoundErrorf("competition %s not found", compID) } - // A daihyosen is played only where the tie affects advancement/seeding - // (see tieAffectsAdvancement): the top poolWinners of each pool advance. - poolWinners := comp.EffectivePoolWinners() - + // A daihyosen is played only where the tie affects advancement/seeding; + // pendingTieBreaks owns that band. standings, err := e.CalculatePoolStandings(compID) if err != nil { return nil, err @@ -258,11 +232,7 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, // group being processed via groupMemberIDs -- see that function's // doc comment for why a bare-name reduction at this scan stage would // collapse distinct namesake-involving pairs. - type poolDHInfo struct { - existingRows []state.MatchResult - count int - } - poolDH := map[string]*poolDHInfo{} + poolDH := map[string][]state.MatchResult{} poolCourt := map[string]string{} // regularIncomplete[pool] is true if any regular (non-DH) match in the pool // is not yet completed. Daihyosen tie-breaks must only be injected after a @@ -284,11 +254,7 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, poolCourt[pn] = m.Court } if IsPoolDaihyosenMatchID(m.ID) { - if poolDH[pn] == nil { - poolDH[pn] = &poolDHInfo{} - } - poolDH[pn].count++ - poolDH[pn].existingRows = append(poolDH[pn].existingRows, m) + poolDH[pn] = append(poolDH[pn], m) } else if m.Status != state.MatchStatusCompleted { regularIncomplete[pn] = true } @@ -300,24 +266,8 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, if regularIncomplete[poolName] { continue } - info := poolDH[poolName] - existingCount := 0 - var existingRows []state.MatchResult - if info != nil { - existingCount = info.count - existingRows = info.existingRows - } - - for _, positions := range detectPoolTies(poolStandings) { - // Only break ties that affect who advances / their seed: a tie sitting - // entirely below the top-poolWinners cut shares its rank with no bout. - if !tieAffectsAdvancement(positions, poolWinners) { - continue - } - group := standingsAt(poolStandings, positions) - newMatches := generatePoolDaihyosenMatches(poolName, group, existingCount, poolCourt[poolName], existingRows) - existingCount += len(newMatches) - injected = append(injected, newMatches...) + for _, p := range pendingTieBreaks(comp, poolName, poolStandings, poolDH[poolName], poolCourt[poolName], true) { + injected = append(injected, p.bouts...) } } @@ -366,11 +316,24 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, // // Pass the names from the parent MatchResult.SideA / SideB so the // caller's view of "left team" / "right team" is canonical. -func ComputeTeamSummary(subResults []state.SubMatchResult, sideAName, sideBName string) (TeamSummary, TeamSummary) { +// +// credit is REQUIRED (bc-cse): a variadic credit let a caller silently skip +// state.DefaultWinCreditSide's default-win rule, which +// mobileapp/handlers_daihyosen.go's AddDaihyosen tie check did until this +// change. That call site computes credit the same way MatchResult.TeamResult +// does (state.DefaultWinCreditSide against the match's own +// Status/Decision/DecisionBy/Attribution()) rather than passing +// domain.MatchSideNone, even though the match it reads is never itself +// completed-by-default-win at that point (AddDaihyosen only applies to a +// still-tied, still-running encounter, so DefaultWinCreditSide already +// answers MatchSideNone there) -- computing it properly costs nothing and +// keeps every caller going through the one canonical derivation rather than +// a caller hand-asserting "no ruling can be in force here". +func ComputeTeamSummary(subResults []state.SubMatchResult, sideAName, sideBName string, credit domain.MatchSide) (TeamSummary, TeamSummary) { // Delegate to the single source of truth in state (the same computation // feeds the wire teamResult the frontend renders), so tie-break math and // the displayed IV/PW never drift. SideA is Aka, SideB is Shiro. - line := state.TeamResultFrom(subResults, sideAName, sideBName) + line := state.TeamResultFrom(subResults, sideAName, sideBName, credit) if line == nil { return TeamSummary{}, TeamSummary{} } diff --git a/internal/engine/daihyosen_test.go b/internal/engine/daihyosen_test.go index 12e164103..21f2f1da7 100644 --- a/internal/engine/daihyosen_test.go +++ b/internal/engine/daihyosen_test.go @@ -172,7 +172,9 @@ func TestComputeTeamSummaryFromSubResults(t *testing.T) { {Position: -1, Decision: string(domain.DecisionDaihyosen)}, } - a, b := ComputeTeamSummary(subs, sideA, sideB) + // domain.MatchSideNone: every bout above already carries its own result, + // so no default-win ruling could ever apply here regardless of credit. + a, b := ComputeTeamSummary(subs, sideA, sideB, domain.MatchSideNone) assert.Equal(t, TeamSummary{IndividualWins: 1, PointsWon: 4}, a) assert.Equal(t, TeamSummary{IndividualWins: 1, PointsWon: 3}, b) assert.True(t, IsTied(TeamSummary{IndividualWins: a.IndividualWins, PointsWon: 0}, diff --git a/internal/engine/default_win_bout_credit_test.go b/internal/engine/default_win_bout_credit_test.go new file mode 100644 index 000000000..2f14f6922 --- /dev/null +++ b/internal/engine/default_win_bout_credit_test.go @@ -0,0 +1,331 @@ +package engine + +// bc-tmfn follow-up: a team match a default-win ruling (any kiken, fusenpai, +// or fusensho) closes before every numbered bout has a result credits each +// unfought bout to the ruling's winning side (state.DefaultWinCreditSide / +// SubBoutEffectiveResult), and the write path pads the stored SubResults with +// an empty row per missing position so a reader has something to credit +// (state.PadDefaultWinBoutPositions, engine.RecordMatchResultWithIneligibilityTx). +// These pin the padding at write time (pool and bracket), its exclusion for +// kachinuki, the legacy-load repair, and the standings/wire consequence end +// to end. + +import ( + "fmt" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + bctest "github.com/gitrgoliveira/bracket-creator/internal/test/idstamp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDefaultWinBoutCredit_PoolStandings is the scenario from the operator +// ruling: team size 3, a pool of three teams. Yama C v Tora A is decided by +// Kiken-Voluntary against Yama C before any bout is fought; Umi E v Tora A is +// fought to two draws and one Tora A win (IV 0-1, PW 0-1). Tora A's kiken +// match alone must credit it IV 3 / PW 6; Yama C's only completed match must +// leave it IL 3 / PL 6; and Yama C, tied with Umi E on team W/L and IV, must +// rank BELOW Umi E on the lower-IL tiebreak (CLAUDE.md Team Tournaments +// tie-break #5). +func TestDefaultWinBoutCredit_PoolStandings(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "dwb-pool" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "Default Win Bout Credit Pool", + Format: state.CompFormatLeague, Status: state.CompStatusPools, + Courts: []string{"A"}, Kind: "team", TeamSize: 3, PoolWinners: 3, + })) + players := []helper.Player{{Name: "Yama C"}, {Name: "Tora A"}, {Name: "Umi E"}} + matches := []state.MatchResult{ + // Yama C v Tora A: running, no bout fought yet. Decided below via the + // real engine decision path, which pads bouts 1-3 (see + // engine.RecordMatchResultWithIneligibilityTx). + {ID: "Pool A-0", SideA: "Yama C", SideB: "Tora A", Court: "A", Status: state.MatchStatusRunning}, + // Umi E v Tora A: fought. Bouts 1-2 draw (hikiwake, no points), bout 3 + // Tora A's fighter wins 1-0. Team-level Winner/WinnerID mirror the + // FIK team-match rule (highest IV, CLAUDE.md "Team Match Winning + // Criteria"): Tora A's 1 individual win beats Umi E's 0. + {ID: "Pool A-1", SideA: "Umi E", SideB: "Tora A", Court: "A", Status: state.MatchStatusCompleted, + Winner: "Tora A", + SubResults: []state.SubMatchResult{ + {Position: 1, Decision: "hikiwake"}, + {Position: 2, Decision: "hikiwake"}, + {Position: 3, Winner: "Tora A", IpponsB: []string{"M"}}, + }, + }, + // Yama C v Umi E: not yet played. Left out of every completed-match + // tally on purpose, so Yama C's and Umi E's stats below come from + // exactly the one match each the scenario describes. + {ID: "Pool A-2", SideA: "Yama C", SideB: "Umi E", Court: "A", Status: state.MatchStatusScheduled}, + } + bctest.StampIDs(players, matches) + require.NoError(t, store.SavePools(compID, []helper.Pool{{PoolName: "Pool A", Players: players}})) + require.NoError(t, store.SavePoolMatches(compID, matches)) + + // Yama C (aka = SideA) withdraws; Tora A (shiro = SideB) is credited. + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + require.NoError(t, err) + + // The kiken match's OWN contribution, isolated: bouts 1-3 padded and + // credited to Tora A. + kikenMatch, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + var pool0 *state.MatchResult + for i := range kikenMatch { + if kikenMatch[i].ID == "Pool A-0" { + pool0 = &kikenMatch[i] + } + } + require.NotNil(t, pool0) + require.Len(t, pool0.SubResults, 3, "bouts 1-3 padded") + line := pool0.TeamResult() + require.NotNil(t, line) + assert.Equal(t, 3, line.ShiroIV, "Tora A (shiro) credited all 3 bouts") + assert.Equal(t, 6, line.ShiroPW, "3 bouts x 2 maru points each") + assert.Equal(t, 0, line.AkaIV) + assert.Equal(t, 0, line.AkaPW) + + standings, err := eng.CalculatePoolStandings(compID) + require.NoError(t, err) + byName := make(map[string]state.PlayerStanding, len(standings["Pool A"])) + for _, s := range standings["Pool A"] { + byName[s.Player.Name] = s + } + require.Contains(t, byName, "Yama C") + require.Contains(t, byName, "Tora A") + require.Contains(t, byName, "Umi E") + + yamaC := byName["Yama C"] + assert.Equal(t, 0, yamaC.Wins) + assert.Equal(t, 1, yamaC.Losses) + assert.Equal(t, 0, yamaC.IndividualWins, "IV") + assert.Equal(t, 3, yamaC.IndividualLosses, "IL: all 3 bouts credited to Tora A") + assert.Equal(t, 0, yamaC.PointsWon, "PW") + assert.Equal(t, 6, yamaC.PointsLost, "PL: 3 bouts x 2 maru points") + + toraA := byName["Tora A"] + assert.Equal(t, 2, toraA.Wins, "both matches") + assert.Equal(t, 0, toraA.Losses) + // 3 credited (kiken) + 1 real (bout 3 v Umi E) = 4; 6 credited + 1 real = 7. + assert.Equal(t, 4, toraA.IndividualWins, "IV across both matches") + assert.Equal(t, 7, toraA.PointsWon, "PW across both matches") + + umiE := byName["Umi E"] + assert.Equal(t, 0, umiE.Wins) + assert.Equal(t, 1, umiE.Losses) + assert.Equal(t, 0, umiE.IndividualWins) + assert.Equal(t, 1, umiE.IndividualLosses, "only bout 3 was lost; bouts 1-2 are draws (IT), not losses") + assert.Equal(t, 2, umiE.IndividualDraws, "IT: bouts 1-2") + + // Yama C (IL 3) ranks below Umi E (IL 1): tied on W/L/IV, the lower-IL + // tiebreak decides (CLAUDE.md Team Tournaments tie-break #5). + order := poolOrder(standings["Pool A"]) + assert.Equal(t, []string{"Tora A", "Umi E", "Yama C"}, order) +} + +// TestDefaultWinBoutCredit_BracketTeamResult is the knockout variant: a +// bracket team match decided by kiken before any bout is fought must credit +// the wire teamResult the same way a pool match does. +func TestDefaultWinBoutCredit_BracketTeamResult(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "dwb-bracket" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "dwb", Kind: "team", TeamSize: 3, Status: state.CompStatusKnockout, + })) + players := []helper.Player{{Name: "Ryu"}, {Name: "Tora"}} + b := &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning}, + }}} + matches := []state.MatchResult{{ID: "m-r1-0", SideA: "Ryu", SideB: "Tora"}} + bctest.StampIDs(players, matches) + b.Rounds[0][0].SideAID = matches[0].SideAID + b.Rounds[0][0].SideBID = matches[0].SideBID + require.NoError(t, store.SaveBracket(compID, b)) + + // Ryu (shiro = SideB) withdraws; Tora (aka = SideA) is credited. + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "no-show", nil, false) + require.NoError(t, err) + + loaded, err := store.LoadBracket(compID) + require.NoError(t, err) + m := loaded.Rounds[0][0] + require.Len(t, m.SubResults, 3, "bouts 1-3 padded") + line := m.TeamResult() + require.NotNil(t, line) + assert.Equal(t, 3, line.AkaIV, "Tora (aka) credited all 3 bouts") + assert.Equal(t, 6, line.AkaPW) + assert.Equal(t, 0, line.ShiroIV) + assert.Equal(t, 0, line.ShiroPW) +} + +// TestDefaultWinBoutCredit_PoolWritePads pins the write-time padding +// (engine.RecordMatchResultWithIneligibilityTx) on the pool branch in +// isolation from any standings computation: a kiken before any bout is +// scored leaves one empty row per missing position. +func TestDefaultWinBoutCredit_PoolWritePads(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "dwb-pool-pad" + createTestCompetition(t, store, compID, "league", 3, func(c *state.Competition) { + c.Kind = "team" + c.TeamSize = 3 + }) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Red", SideB: "White", Status: state.MatchStatusRunning, + }})) + result, _, err := eng.RecordDecision(compID, "Pool A-0", "fusenpai", "aka", "no-show", nil, false) + require.NoError(t, err) + + // Assert on the WRITE's own returned result first, in memory, before any + // further store.Load* call: store.LoadPoolMatches below would ALSO pad a + // stale file via the legacy-load repair (state.EnsureLegacyUpgraded), so + // checking only the reloaded copy could not tell write-time padding + // (engine.RecordMatchResultWithIneligibilityTx) apart from that safety + // net catching up on read. + require.Len(t, result.SubResults, 3, "the write's own returned result is already padded") + for i, wantPos := range []int{1, 2, 3} { + assert.Equal(t, wantPos, result.SubResults[i].Position) + assert.False(t, result.SubResults[i].HasResult(), "padded row has no result") + } + + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, ms, 1) + require.Len(t, ms[0].SubResults, 3, "and it persisted to disk") + for i, wantPos := range []int{1, 2, 3} { + assert.Equal(t, wantPos, ms[0].SubResults[i].Position) + assert.False(t, ms[0].SubResults[i].HasResult()) + } +} + +// TestDefaultWinBoutCredit_BracketWritePads is the bracket branch twin of +// TestDefaultWinBoutCredit_PoolWritePads. +func TestDefaultWinBoutCredit_BracketWritePads(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "dwb-bracket-pad" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "dwb", Kind: "team", TeamSize: 3, Status: state.CompStatusKnockout, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", SideA: "Red", SideB: "White", Status: state.MatchStatusRunning}, + }}})) + result, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-injury", "shiro", "injured", nil, false) + require.NoError(t, err) + + // Assert on the WRITE's own returned result first, in memory -- see + // TestDefaultWinBoutCredit_PoolWritePads for why this must not be + // skipped in favour of only the reloaded copy. + require.Len(t, result.SubResults, 3, "the write's own returned result is already padded") + for i, wantPos := range []int{1, 2, 3} { + assert.Equal(t, wantPos, result.SubResults[i].Position) + assert.False(t, result.SubResults[i].HasResult()) + } + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + m := b.Rounds[0][0] + require.Len(t, m.SubResults, 3, "and it persisted to disk") + for i, wantPos := range []int{1, 2, 3} { + assert.Equal(t, wantPos, m.SubResults[i].Position) + assert.False(t, m.SubResults[i].HasResult()) + } +} + +// TestDefaultWinBoutCredit_KachinukiNotPadded pins that a kachinuki match is +// never padded: kachinuki's completed write strips trailing unfought +// pairings on its own (applyKachinukiMerge), and the default-win padding +// must not add any back. +func TestDefaultWinBoutCredit_KachinukiNotPadded(t *testing.T) { + eng, store, _ := setupKachinukiComp(t, "dwb-kachinuki", 3) + require.NoError(t, store.SavePoolMatches("dwb-kachinuki", []state.MatchResult{{ + ID: "Pool A-0", SideA: "Red", SideB: "White", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideAMemberID: "r1", SideBMemberID: "w1", WinnerMemberID: "r1", Winner: "r1", IpponsA: []string{"M"}}, + }, + }})) + _, _, err := eng.RecordDecision("dwb-kachinuki", "Pool A-0", "kiken-voluntary", "shiro", "withdrew", nil, false) + require.NoError(t, err) + + ms, err := store.LoadPoolMatches("dwb-kachinuki") + require.NoError(t, err) + require.Len(t, ms, 1) + // Never padded to teamSize (3): kachinuki is excluded from + // state.PadDefaultWinBoutPositions entirely (comp.IsKachinuki() guard in + // RecordMatchResultWithIneligibilityTx). + assert.Less(t, len(ms[0].SubResults), 3, "kachinuki bouts are never padded up to TeamSize") +} + +// TestDefaultWinBoutCredit_LegacyLoadPads pins the load-time repair +// (state.EnsureLegacyUpgraded / upgradeTeamDefaultWinBoutPaddingLocked): a +// completed default-win team match saved directly (bypassing the engine +// write path entirely, as a pre-padding file on disk would have been) is +// padded the first time it is loaded. +func TestDefaultWinBoutCredit_LegacyLoadPads(t *testing.T) { + _, store, _ := setupTestEngine(t) + const compID = "dwb-legacy" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "dwb legacy", Format: state.CompFormatLeague, Status: state.CompStatusPools, + Courts: []string{"A"}, Kind: "team", TeamSize: 3, PoolWinners: 2, + })) + // Saved directly, bypassing RecordDecision entirely: a completed kiken + // with only ONE bout row (won by Red, the side that goes on to + // withdraw), the on-disk shape a file predating write-time padding + // would have. DecisionBy "aka" credits White (shiro) for bouts 2-3. + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Red", SideB: "White", Status: state.MatchStatusCompleted, + Decision: "kiken-voluntary", DecisionBy: "aka", Winner: "White", + SubResults: []state.SubMatchResult{{Position: 1, Winner: "Red", IpponsA: []string{"M"}}}, + }})) + + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, ms, 1) + require.Len(t, ms[0].SubResults, 3, "bouts 2 and 3 padded on load") + assert.Equal(t, 1, ms[0].SubResults[0].Position) + assert.Equal(t, []string{"M"}, ms[0].SubResults[0].IpponsA, "the real bout is untouched") + assert.Equal(t, "Red", ms[0].SubResults[0].Winner, "bout 1's own winner survives, even though Red goes on to withdraw") + assert.Equal(t, 2, ms[0].SubResults[1].Position) + assert.False(t, ms[0].SubResults[1].HasResult()) + assert.Equal(t, 3, ms[0].SubResults[2].Position) + assert.False(t, ms[0].SubResults[2].HasResult()) + + // The credit follows through to the wire teamResult too: bout 1 keeps + // its own result (Red/aka won it), bouts 2-3 are credited to White + // (shiro) by the match-level kiken. + line := ms[0].TeamResult() + require.NotNil(t, line) + assert.Equal(t, 1, line.AkaIV, "bout 1's own result: Red won it") + assert.Equal(t, 1, line.AkaPW) + assert.Equal(t, 2, line.ShiroIV, "White (shiro) credited bouts 2-3") + assert.Equal(t, 4, line.ShiroPW) +} + +// TestDefaultWinBoutCredit_PoolDaihyosenRowNeverPadded pins that a pool +// daihyosen/tiebreaker row (a single individual representative bout, never a +// team's own numbered positions -- IsPoolDaihyosenMatchID / IsTiebreakerMatchID) +// is never padded, on the write path or the legacy-load repair, even when it +// carries a completed default-win decision at TeamSize >= 2. +func TestDefaultWinBoutCredit_PoolDaihyosenRowNeverPadded(t *testing.T) { + for i, matchID := range []string{"Pool A-DH-1", "Pool A-TB-1"} { + t.Run(matchID, func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := fmt.Sprintf("dwb-dh-%d", i) + createTestCompetition(t, store, compID, "league", 3, func(c *state.Competition) { + c.Kind = "team" + c.TeamSize = 3 + }) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: matchID, SideA: "Red", SideB: "White", Status: state.MatchStatusRunning, + }})) + result, _, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "aka", "no-show", nil, false) + require.NoError(t, err) + assert.Empty(t, result.SubResults, "the write path never pads a pool DH/TB row") + + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, ms, 1) + assert.Empty(t, ms[0].SubResults, "the legacy-load repair never pads a pool DH/TB row either") + }) + } +} diff --git a/internal/engine/default_win_bout_padding_unreadable_test.go b/internal/engine/default_win_bout_padding_unreadable_test.go new file mode 100644 index 000000000..61907c6f9 --- /dev/null +++ b/internal/engine/default_win_bout_padding_unreadable_test.go @@ -0,0 +1,199 @@ +package engine + +// bc-tmfn/bc-cse follow-up: the write-time default-win padding gate in +// RecordMatchResultWithIneligibilityTx (scoring_tx.go) must never destroy an +// unreadable stored SubResults cell (state.MatchResult.SubResultsRaw / the +// DATA LOSS this file's first test guards), and must still fire for the +// ordinary case where the client's payload omits SideA/SideB and relies on +// applyPoolWrite's reconcileSides to backfill them from the stored row (the +// gate is read BEFORE that backfill runs, so it must consult the STORED +// row's sides, not the payload's). + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mangleCellForTest corrupts a byte sequence in a file on disk, mirroring +// state's own (unexported, package-scoped) mangleCell test helper. +func mangleCellForTest(t *testing.T, path, from, to string) { + t.Helper() + raw, err := os.ReadFile(path) // #nosec G304 + require.NoError(t, err) + mangled := strings.Replace(string(raw), from, to, 1) + require.NotEqual(t, string(raw), mangled, "the mangle pattern must actually match") + require.NoError(t, os.WriteFile(path, []byte(mangled), 0600)) // #nosec G306 +} + +// TestWriteTimeDefaultWinPaddingNeverOverwritesAnUnreadableCell guards the +// write-time half of the bc-tmfn data-loss finding: a kiken recorded (via +// the real engine.RecordDecision entry point) against a team match whose +// stored SubResults cell already failed to parse must not pad over it. The +// legacy-load half of the same bug is pinned separately in +// internal/state/default_win_bout_padding_unreadable_test.go. +func TestWriteTimeDefaultWinPaddingNeverOverwritesAnUnreadableCell(t *testing.T) { + eng, store, dir := setupTestEngine(t) + const compID = "dwb-unreadable-write" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "Unreadable Cell Write Path", + Format: state.CompFormatLeague, Status: state.CompStatusPools, + Courts: []string{"A"}, Kind: "team", TeamSize: 3, + })) + match := state.MatchResult{ + ID: "Pool A-0", SideA: "Kenshikan", SideB: "Sanshukan", Court: "A", + Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "Tanaka", SideB: "Suzuki", IpponsA: []string{"M"}, Winner: "Tanaka"}, + }, + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{match})) + + path := filepath.Join(dir, "competitions", compID, "pool-matches.csv") + mangleCellForTest(t, path, `""position"":1`, `""position"":1x`) + + // Confirm the cell reads back unreadable BEFORE the write under test, + // so the assertions below are about what the write does, not the load. + preLoad, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.True(t, preLoad[0].SubResultsUnreadable) + + // Kenshikan (aka = SideA) withdraws. RecordDecision fills SideA/SideB + // from the stored row itself, so this exercises the SubResultsUnreadable + // guard in isolation from the sides-backfill-ordering fix below. + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + require.NoError(t, err) + + loaded, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, loaded, 1) + assert.Empty(t, loaded[0].SubResults, + "an unreadable cell must not be padded by the write-time gate") + assert.True(t, loaded[0].SubResultsUnreadable, "and must still say so") + + raw, err := os.ReadFile(path) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(raw), "Tanaka", + "the malformed cell's bytes must survive the kiken write") + assert.Contains(t, string(raw), `""position"":1x`, + "and must survive VERBATIM, not partially rewritten by padding") +} + +// TestWriteTimeDefaultWinPaddingReadsPriorSidesBeforeBackfill guards the +// bc-cse ordering finding: the padding gate runs BEFORE +// applyPoolWrite/applyBracketResultIn's reconcileSides backfills an omitted +// SideA/SideB from the stored row, so a client payload that (as is common) +// states only the decision and leaves SideA/SideB blank must still get its +// default-win bout padding -- the gate has to read the STORED row's sides, +// not the payload's. +func TestWriteTimeDefaultWinPaddingReadsPriorSidesBeforeBackfill(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "dwb-sides-backfill" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "Sides Backfill Ordering", + Format: state.CompFormatLeague, Status: state.CompStatusPools, + Courts: []string{"A"}, Kind: "team", TeamSize: 3, + })) + match := state.MatchResult{ + ID: "Pool A-0", SideA: "Kenshikan", SideB: "Sanshukan", Court: "A", + Status: state.MatchStatusRunning, + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{match})) + + // Prime EnsureLegacyUpgraded for this competition BEFORE the write under + // test, so the assertions below observe the WRITE-TIME gate in + // isolation: EnsureLegacyUpgraded runs at most once per competition per + // Store instance, and its last step (upgradeTeamDefaultWinBoutPaddingLocked) + // would otherwise silently repair an unpadded row on the post-write + // LoadPoolMatches call, masking a write-time regression behind the + // legacy pass's own (separately tested) padding. + _, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + + // SideA/SideB deliberately omitted: this is the shape a score-endpoint + // payload that only states the decision takes. + result := &state.MatchResult{ + ID: "Pool A-0", + Decision: "kiken-voluntary", + Status: state.MatchStatusCompleted, + Winner: "Sanshukan", + WinnerSide: "B", + } + _, err = eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", result) + require.NoError(t, err) + + loaded, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, loaded, 1) + assert.Equal(t, "Kenshikan", loaded[0].SideA, "sides backfilled from the stored row") + assert.Equal(t, "Sanshukan", loaded[0].SideB) + assert.Len(t, loaded[0].SubResults, 3, + "padding must still apply even though the payload omitted SideA/SideB") +} + +// TestWriteTimeDefaultWinPaddingPadsAnExplicitHikiwakeCorrectionOverAWithdrawal +// guards the advisor-caught regression in the padDecision computation: +// KeepsWithdrawalRuling accepts an incoming Decision of "" OR "hikiwake" (its +// own definition), not only "". A correction that explicitly re-states the +// encounter as "hikiwake" over a stored withdrawal must still be recognised +// as one that keeps the ruling and get its default-win padding -- gating the +// KeepsWithdrawalRuling check on padDecision == "" would miss exactly this +// shape, since padDecision is "hikiwake" (not a default-win decision) before +// the check ever runs. +func TestWriteTimeDefaultWinPaddingPadsAnExplicitHikiwakeCorrectionOverAWithdrawal(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "dwb-hikiwake-correction" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "Hikiwake Correction Over Withdrawal", + Format: state.CompFormatLeague, Status: state.CompStatusPools, + Courts: []string{"A"}, Kind: "team", TeamSize: 3, + })) + match := state.MatchResult{ + ID: "Pool A-0", SideA: "Kenshikan", SideB: "Sanshukan", Court: "A", + Status: state.MatchStatusRunning, + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{match})) + _, err := store.LoadPoolMatches(compID) // prime EnsureLegacyUpgraded, as above + require.NoError(t, err) + + // Kenshikan (aka = SideA) withdraws before any bout is fought; padded to + // 3 placeholder positions credited to Sanshukan. + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + require.NoError(t, err) + + loaded, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, loaded, 1) + require.Len(t, loaded[0].SubResults, 3, "padded by the withdrawal itself") + + // A bout-level CORRECTION explicitly re-states the encounter as a draw + // (KeepsWithdrawalRuling's other accepted shape besides "") and sends + // only ONE of the three positions, as a genuine bout-level edit would. + // preserveWithdrawalRuling reinstates the stored kiken-voluntary AFTER + // the padding gate runs, so the gate must recognise this write as one + // that keeps the ruling and still pad the two rows it omits. + correction := &state.MatchResult{ + ID: "Pool A-0", + Decision: "hikiwake", + Status: state.MatchStatusCompleted, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "Tanaka", SideB: "Suzuki"}, + }, + CorrectionReason: "typo in bout 1 names", + } + _, err = eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", correction) + require.NoError(t, err) + + after, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, after, 1) + assert.Equal(t, "kiken-voluntary", after[0].Decision, + "the withdrawal ruling is reinstated over the correction's explicit hikiwake") + assert.Len(t, after[0].SubResults, 3, + "the correction sent only 1 of 3 positions; padding must still fill the rest") +} diff --git a/internal/engine/downstream_correction_guard_test.go b/internal/engine/downstream_correction_guard_test.go index 13b4bd9a0..6d230475e 100644 --- a/internal/engine/downstream_correction_guard_test.go +++ b/internal/engine/downstream_correction_guard_test.go @@ -122,6 +122,49 @@ func TestDownstreamKnockoutCorrection_RefusedByDefault(t *testing.T) { "a refused correction must not bump the file version") } +// TestDownstreamKnockoutCorrection_RefusalMessageNamesTheOperatorLabelNotRawID +// covers bc-cse item 14: DownstreamKnockoutPlayedError.Error() used to +// interpolate the raw match id ("m-r1-0") into its "correcting match %q..." +// sentence, which IS sent to the operator verbatim +// (respondIfDownstreamKnockoutPlayed's "message" field). It must instead +// name the match the way the operator sees it (MatchLabel), exactly like +// every other operator-facing refusal in this family. +func TestDownstreamKnockoutCorrection_RefusalMessageNamesTheOperatorLabelNotRawID(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "kcdg-label-not-id" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Status: state.CompStatusKnockout, + })) + b := &state.Bracket{ + Rounds: [][]state.BracketMatch{ + { + {ID: "m-r1-0", SideA: "Alice", SideB: "Bob", SideAID: "alice", SideBID: "bob", + Winner: "Alice", WinnerID: "alice", Status: state.MatchStatusCompleted, + IpponsA: []string{"M"}, MatchNumber: 1, DisplayRound: 2}, + }, + { + // The final carries its OWN result (ippons), so correcting + // m-r1-0 is blocked on it. + {ID: "m-r2-0", SideA: "Alice", SideB: "Dave", SideAID: "alice", SideBID: "dave", + Winner: "Alice", WinnerID: "alice", Status: state.MatchStatusCompleted, + IpponsA: []string{"M", "M"}, MatchNumber: 2, DisplayRound: 1}, + }, + }, + } + require.NoError(t, store.SaveBracket(compID, b)) + + txErr := inTx(t, store, compID, func(tx state.StoreTx) error { + _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", correctR1ToBob("fix"), ForceOptions{}) + return err + }) + var dkErr *DownstreamKnockoutPlayedError + require.ErrorAs(t, txErr, &dkErr) + + msg := dkErr.Error() + assert.NotContains(t, msg, "m-r1-0", "the raw match id must not reach the operator") + assert.Contains(t, msg, "Match 1 (Semifinals)", "the corrected match is named by its operator label instead") +} + func TestDownstreamKnockoutCorrection_Force(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "kcdg-force" @@ -258,6 +301,79 @@ func TestDownstreamKnockoutCorrection_ByeDoesNotBlock(t *testing.T) { assert.Equal(t, state.MatchStatusCompleted, got.Rounds[1][0].Status) } +// TestReopenMatch_ByeResolvedDownstream_IsUnwound: the REOPEN door on the +// SAME bye-completed-downstream shape TestDownstreamKnockoutCorrection_ByeDoesNotBlock +// proves the CORRECTION door sails through. It used to be refused (a 409 +// naming Save correction as the remedy), but a correction KEEPS a recorded +// withdrawal (KeepsWithdrawalRuling), so a kiken +// recorded by mistake whose winner went through a bye could not be removed by +// any door. Nobody fought the bye, so the reopen unwinds it instead: the slot +// returns to its feeder placeholder and the match to the completed, +// winner-less shape generation gave it. reopen_bye_unwind_test.go covers the +// same rule on a generated draw, with a played or running match past the bye. +func TestReopenMatch_ByeResolvedDownstream_IsUnwound(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "kcdg-bye-reopen" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "kcdg-bye-reopen", Status: state.CompStatusKnockout, + })) + b := &state.Bracket{ + Rounds: [][]state.BracketMatch{ + { + // Decision must be a default-win one: reopenResultPreconditionTx + // refuses a reopen on any other decided-by-fight result for a + // non-kachinuki match, before it ever reaches the downstream + // check this test targets. + {ID: "m-r1-0", SideA: "Alice", SideB: "Bob", SideAID: "alice", SideBID: "bob", + Winner: "Alice", WinnerID: "alice", Status: state.MatchStatusCompleted, + Decision: "kiken-voluntary", MatchNumber: 1, DisplayRound: 2}, + }, + { + {ID: "m-r2-0", SideA: "Alice", SideB: "", SideAID: "alice", + Winner: "Alice", WinnerID: "alice", Status: state.MatchStatusCompleted, + MatchNumber: 2, DisplayRound: 1}, + }, + }, + } + require.NoError(t, store.SaveBracket(compID, b)) + + _, err := eng.ReopenMatch(compID, "m-r1-0", "test reason") + require.NoError(t, err, "a bye nobody fought must not refuse the reopen") + + got, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusRunning, got.Rounds[0][0].Status) + bye := got.Rounds[1][0] + assert.Equal(t, winnerOfPlaceholder(2, 0), bye.SideA) + assert.Empty(t, bye.SideAID) + assert.Empty(t, bye.Winner) + assert.Empty(t, bye.WinnerID) + assert.Equal(t, state.MatchStatusCompleted, bye.Status, "back to the latent-bye shape generation gives it") +} + +// TestReopenBracketDownstreamCheck_ScheduledWithStrayDataIsNotResolvedByBye +// covers the second half of bc-cse item 8: bracketMatchStartedOrScored is +// true on more than a completed match (Winner/SubResults/Ippons set is +// enough), so a downstream row that is still SCHEDULED but carries stray +// Winner data must not be classified "resolved by a bye" -- that label +// promises the specific, clean completed-via-bye shape +// TestReopenMatch_ByeResolvedDownstream_IsUnwound pins, +// and propagatedDownstream.played's OWN gate (bracketMatchCarriesOwnResult) +// requires Completed too, so a scheduled row can never be "played" either. +func TestReopenBracketDownstreamCheck_ScheduledWithStrayDataIsNotResolvedByBye(t *testing.T) { + bracket := &state.Bracket{ + Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Alice", SideB: "Bob"}}, + // Stray/transitional data: SCHEDULED but a Winner is already + // set (never happens on a normal write path; a hand-edited or + // mid-migration file is the reachable shape). + {{ID: "m-r2-0", SideA: "Alice", Winner: "Alice", Status: state.MatchStatusScheduled}}, + }, + } + err := reopenBracketDownstreamCheck(bracket, 0, 0, false) + assert.NoError(t, err, "a still-scheduled downstream row must not be labelled resolved-by-bye") +} + func TestDownstreamKnockoutCorrection_RestoreBypassesGuard(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "kcdg-restore" @@ -274,7 +390,7 @@ func TestDownstreamKnockoutCorrection_RestoreBypassesGuard(t *testing.T) { Status: state.MatchStatusCompleted, } txErr := inTx(t, store, compID, func(tx state.StoreTx) error { - _, err := eng.recordBracketMatchResult(tx, compID, "m-r1-0", snapshot, matchWriteRestore, false) + _, _, err := eng.recordBracketMatchResult(tx, compID, "m-r1-0", snapshot, matchWriteRestore, false) return err }) require.NoError(t, txErr, "matchWriteRestore must never be refused by the downstream guard") @@ -905,12 +1021,12 @@ func TestDownstreamKnockoutCorrection_ReopenedCarriesTheMatchNumber(t *testing.T { {ID: "m-r1-0", SideA: "Alice", SideB: "Bob", SideAID: "alice", SideBID: "bob", Winner: "Alice", WinnerID: "alice", Status: state.MatchStatusCompleted, - IpponsA: []string{"M"}, MatchNumber: 1}, + IpponsA: []string{"M"}, MatchNumber: 1, DisplayRound: 2}, }, { {ID: "m-r2-0", SideA: "Alice", SideB: "Charlie", SideAID: "alice", SideBID: "charlie", Winner: "Alice", WinnerID: "alice", Status: state.MatchStatusCompleted, - IpponsA: []string{"M", "M"}, MatchNumber: 3}, + IpponsA: []string{"M", "M"}, MatchNumber: 3, DisplayRound: 1}, }, }, })) @@ -925,7 +1041,7 @@ func TestDownstreamKnockoutCorrection_ReopenedCarriesTheMatchNumber(t *testing.T require.ErrorAs(t, txErr, &dkErr) require.Len(t, dkErr.Blocking, 1) assert.Equal(t, 3, dkErr.Blocking[0].Number, "the operator's label, not the id") - assert.Contains(t, dkErr.Error(), "Match 3") + assert.Contains(t, dkErr.Error(), "Match 3 (Final)", "the number, qualified by its knockout round") assert.NotContains(t, dkErr.Error(), "m-r2-0", "the internal id must not be shown") // And so does what the forced write reports back. @@ -938,7 +1054,7 @@ func TestDownstreamKnockoutCorrection_ReopenedCarriesTheMatchNumber(t *testing.T require.Len(t, reopened, 1) assert.Equal(t, "m-r2-0", reopened[0].ID, "the id still travels, for addressing the match") assert.Equal(t, 3, reopened[0].Number) - assert.Equal(t, "Match 3", MatchLabel(reopened[0])) + assert.Equal(t, "Match 3 (Final)", MatchLabel(reopened[0])) // A match with no number (a bye placeholder, or a pre-numbering bracket) // falls back to the id rather than printing "Match 0". @@ -992,3 +1108,40 @@ func TestDownstreamKnockoutCorrection_ReopenedSiblingsDoNotHoldACourt(t *testing } assert.Zero(t, running, "a reopen starts nothing; the operator starts the next match themselves") } + +// The confirmation reopens the next match, and what THAT match had sent on is +// taken back out of a round nobody has touched: the final stops advertising a +// competitor whose semifinal win was just cleared. It is the retraction a pool +// correction's reopen applies (retractIntoUntouched). A round that was already +// played is left for its own confirmation (TestDownstreamKnockoutCorrection_Force). +func TestDownstreamKnockoutCorrection_ForceRetractsFromAnUntouchedRound(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "kcdg-untouched-final" + seedThreeRoundBracket(t, store, compID) + require.NoError(t, store.UpdateBracket(compID, func(b *state.Bracket) error { + final := &b.Rounds[2][0] + final.Winner, final.WinnerID = "", "" + final.IpponsA = nil + final.Status = state.MatchStatusScheduled + return nil + })) + + var reopened []ReopenedMatch + txErr := inTx(t, store, compID, func(tx state.StoreTx) error { + _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", correctR1ToBob("confirmed override"), + ForceOptions{Force: true, Reopened: &reopened}) + return err + }) + require.NoError(t, txErr) + require.Equal(t, []string{"m-r2-0"}, reopenedIDs(reopened)) + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, "Bob", b.Rounds[1][0].SideA, "the corrected winner is seated in the reopened match") + final := b.Rounds[2][0] + assert.Equal(t, winnerOfPlaceholder(len(b.Rounds)-1, 0), final.SideA, + "the untouched final waits for the reopened match again instead of naming Alice") + assert.Empty(t, final.SideAID, "a placeholder carries no id") + assert.Equal(t, "Dave", final.SideB, "the other side is not this correction's") + assert.Equal(t, state.MatchStatusScheduled, final.Status) +} diff --git a/internal/engine/downstream_guard_review_test.go b/internal/engine/downstream_guard_review_test.go index 53f599cd5..b8e430458 100644 --- a/internal/engine/downstream_guard_review_test.go +++ b/internal/engine/downstream_guard_review_test.go @@ -114,6 +114,7 @@ func TestDownstreamGuard_RefusalNamesNoOneWhenTwoMatchesBlock(t *testing.T) { // The single-blocker message still names them, where it is true. single := &DownstreamKnockoutPlayedError{ MatchID: "m-r1-0", + Label: "Match 1 (Semifinals)", BlockingMatchID: "m-r2-0", Blocking: []ReopenedMatch{{ID: "m-r2-0", Number: 9}}, Displaced: "Alice", @@ -122,6 +123,25 @@ func TestDownstreamGuard_RefusalNamesNoOneWhenTwoMatchesBlock(t *testing.T) { assert.Contains(t, single.Error(), "Match 9") } +// TestDownstreamKnockoutPlayedError_ErrorHasNoBareMatchIDFallback pins bc-cse +// item 14's removal of the bare-MatchID fallback: every production +// constructor of DownstreamKnockoutPlayedError sets Label +// (newDownstreamKnockoutPlayedError, answerRequalification), so Error() +// trusts Label as-is and no longer substitutes the internal MatchID when +// Label is empty. A raw id ("m-internal-42") reaching the operator would be +// exactly the leak MatchLabel/OperatorMatchLabel exist to prevent. +func TestDownstreamKnockoutPlayedError_ErrorHasNoBareMatchIDFallback(t *testing.T) { + err := &DownstreamKnockoutPlayedError{ + MatchID: "m-internal-42", + BlockingMatchID: "m-r2-0", + Blocking: []ReopenedMatch{{ID: "m-r2-0", Number: 9}}, + Displaced: "Alice", + } + msg := err.Error() + assert.NotContains(t, msg, "m-internal-42", + "an empty Label must not fall back to the raw MatchID") +} + // TestDownstreamGuard_EngiKnockoutIsGuardedToo closes the last write path. // // The engi dispatch seam returns before writeToPoolOrBracket, so the guard on diff --git a/internal/engine/eligibility.go b/internal/engine/eligibility.go index de6e39ca9..59aa62511 100644 --- a/internal/engine/eligibility.go +++ b/internal/engine/eligibility.go @@ -24,6 +24,35 @@ var ErrIneligibleCompetitor = errors.New("ineligible competitor") type IneligibleCompetitorError struct { PlayerID string Reason string + // MatchID and Decision (bc-cse/bc-rawm) describe the CompetitorStatus + // record that barred PlayerID: the match they were barred IN (an + // operator label, via engine.OperatorMatchLabel, names it on the wire) + // and which withdrawal decision recorded it (domain.Decision, e.g. + // "kiken-voluntary"). Populated wherever this error is constructed from + // a barred status (BarredSides). MatchID may legitimately be empty even + // for a barred-status producer: a status set directly via + // POST /competitor-status carries no matchId. So MatchID alone cannot + // tell that producer apart from the OTHER producer, the simultaneity + // gate (checkSimultaneousMatchTx), which is not about a barred status at + // all -- Simultaneous is the explicit discriminator a handler checks + // instead (see reasonHumanForBarredCompetitor, mobileapp/errors.go). + MatchID string + Decision string + // Simultaneous is true ONLY when this error was produced by the + // simultaneity gate (checkSimultaneousMatchTx), whose Reason is already + // a complete operator sentence. False (including for a barred-status + // refusal with no recorded MatchID) for every other producer, which + // needs reasonHumanForBarredCompetitor to build one instead of a + // handler echoing a raw, possibly free-text Reason to the operator. + Simultaneous bool + // BothSidesBarred is true when eligibilityErrorForSides found BOTH + // sides barred, not just the one this error otherwise names (bc-cse + // item 10). PlayerID/Reason/MatchID/Decision still describe side A + // alone (the tiebreak eligibilityErrorForSides has always used), but a + // handler checking this flag can build the "Neither X nor Y can fight" + // sentence instead of naming one side and silently dropping the other's + // own bar. + BothSidesBarred bool } func (e *IneligibleCompetitorError) Error() string { @@ -70,12 +99,108 @@ type AlreadyIneligibleError struct { PlayerID string MatchID string Reason string + // Decision mirrors IneligibleCompetitorError's own field (bc-cse), + // populated the same way from the barred status: the withdrawal + // decision that recorded MatchID's ineligibility. Both errors share ONE + // operator-sentence builder (reasonHumanForBarredCompetitor, + // mobileapp/errors.go), which needs the same facts from either. Always + // produced from a barred status with a real MatchID (that IS the point + // of this error: the loser is already ineligible from a DIFFERENT + // match), so unlike IneligibleCompetitorError this type carries no + // Simultaneous discriminator. + Decision string } func (e *AlreadyIneligibleError) Error() string { return fmt.Sprintf("competitor %q already ineligible (match %s)", e.PlayerID, e.MatchID) } +// BarredSides reports which of sideAID/sideBID currently carry a +// CompetitorStatus{Eligible: false} recorded by a match OTHER than matchID +// -- the "not eligible AND the status was recorded by a different match" +// check, extracted to ONE place (bc-cse) from what used to be three +// hand-copied loops: StartMatchTx (scoring_tx.go), checkEligibilityExcludingMatch +// (below), and the mobileapp ineligibleSides read-time annotation +// (annotateIneligibleSides, handlers_match.go), which needs the SAME +// per-side answer to stamp a scheduled match without gating a write. +// +// A side is barred when it carries a non-empty id, a status entry exists +// for it, that status is Eligible:false, AND the status's own MatchID does +// not equal matchID -- the undo-path exemption (checkEligibilityExcludingMatch's +// prior contract, and StartMatchTx's): a match may always be re-scored even +// when ITS OWN prior kiken/fusenpai is what marked a participant ineligible. +// a/b are independent: either, both, or neither may be non-nil. Returns a +// COPY of the stored status (never an alias into the caller's map), so a +// caller may safely hold onto it after the map goes out of scope. +func BarredSides(statuses map[string]domain.CompetitorStatus, matchID, sideAID, sideBID string) (a, b *domain.CompetitorStatus) { + barred := func(pid string) *domain.CompetitorStatus { + if pid == "" { + return nil + } + if st, ok := statuses[pid]; ok && !st.Eligible && st.MatchID != matchID { + out := st + return &out + } + return nil + } + return barred(sideAID), barred(sideBID) +} + +// eligibilityErrorForSides extracts (sideAID, sideBID) from ids -- always +// exactly [sideAID, sideBID] from every caller's own participant-id +// resolver, handled defensively rather than assumed to be length 2 -- and +// applies BarredSides against matchID, returning *IneligibleCompetitorError +// for the first barred side, A before B. Shared by StartMatchTx and +// checkEligibilityExcludingMatch, the tx and non-tx pre-flight eligibility +// gates, so the extraction and A-then-B ordering has one owner. +func eligibilityErrorForSides(statuses map[string]domain.CompetitorStatus, matchID string, ids []string) error { + var sideAID, sideBID string + if len(ids) > 0 { + sideAID = ids[0] + } + if len(ids) > 1 { + sideBID = ids[1] + } + a, b := BarredSides(statuses, matchID, sideAID, sideBID) + if a != nil { + errA := ineligibleCompetitorErrorFor(sideAID, a) + errA.BothSidesBarred = b != nil + return errA + } + if b != nil { + return ineligibleCompetitorErrorFor(sideBID, b) + } + return nil +} + +// ineligibleCompetitorErrorFor builds *IneligibleCompetitorError from a +// barred status entry (a BarredSides return value), the ONE construction +// site for this error from a barred status, so MatchID/Decision can never +// drift out of sync with Reason at a hand-copied call site. Simultaneous is +// left at its zero value (false): this producer is never the simultaneity +// gate. st must be non-nil. +func ineligibleCompetitorErrorFor(playerID string, st *domain.CompetitorStatus) *IneligibleCompetitorError { + decision, _, _ := domain.SplitStatusReason(st.Reason) + return &IneligibleCompetitorError{ + PlayerID: playerID, + Reason: st.Reason, + MatchID: st.MatchID, + Decision: decision, + } +} + +// alreadyIneligibleErrorFor is ineligibleCompetitorErrorFor's twin for +// *AlreadyIneligibleError. st must be non-nil. +func alreadyIneligibleErrorFor(playerID string, st *domain.CompetitorStatus) *AlreadyIneligibleError { + decision, _, _ := domain.SplitStatusReason(st.Reason) + return &AlreadyIneligibleError{ + PlayerID: playerID, + MatchID: st.MatchID, + Reason: st.Reason, + Decision: decision, + } +} + // participantIDByName resolves name to a participant id against compID's // roster (comp.Players plus a fresh LoadParticipants read, combinedPlayerPool // -- the same union every other name-lookup call site in this file uses). @@ -160,12 +285,13 @@ func (e *Engine) checkConcurrentIneligibility(h state.StoreTx, compID, matchID, log.Printf("engine: checkConcurrentIneligibility LoadCompetitorStatus compId=%s: %v (T105 guard skipped)", compID, err) return nil } - if st, ok := statuses[playerID]; ok && !st.Eligible && st.MatchID != matchID { - return &AlreadyIneligibleError{ - PlayerID: playerID, - MatchID: st.MatchID, - Reason: st.Reason, - } + // This call site has only ONE candidate, not two match sides, so it + // passes playerID as sideA and "" as sideB: BarredSides is a per-side + // check and its sideB arm always returns nil for an empty id, so the + // A-side return is the only one that can ever fire here. + barred, _ := BarredSides(statuses, matchID, playerID, "") + if barred != nil { + return alreadyIneligibleErrorFor(playerID, barred) } return nil } @@ -176,22 +302,6 @@ func (e *Engine) checkConcurrentIneligibility(h state.StoreTx, compID, matchID, // the store, which means default-eligible per FR-034). // // FR-035. -func (e *Engine) CheckEligibility(compID string, playerIDs []string) error { - statuses, err := e.store.LoadCompetitorStatus(compID) - if err != nil { - return err - } - for _, pid := range playerIDs { - if pid == "" { - continue - } - if st, ok := statuses[pid]; ok && !st.Eligible { - return &IneligibleCompetitorError{PlayerID: pid, Reason: st.Reason} - } - } - return nil -} - // StartMatch gates the scheduled → running transition by checking // every participant's competitor-status and ensuring that no participant // is already Running in a different match within the same competition @@ -337,15 +447,7 @@ func (e *Engine) checkEligibilityExcludingMatch(compID string, playerIDs []strin if err != nil { return err } - for _, pid := range playerIDs { - if pid == "" { - continue - } - if st, ok := statuses[pid]; ok && !st.Eligible && st.MatchID != excludeMatchID { - return &IneligibleCompetitorError{PlayerID: pid, Reason: st.Reason} - } - } - return nil + return eligibilityErrorForSides(statuses, excludeMatchID, playerIDs) } // RecordDecision auto-fills the scoreline from decision/decisionBy/encho @@ -439,19 +541,29 @@ func (e *Engine) RecordDecisionWithOptions(compID, matchID, decision, decisionBy // bracket matches the BracketMatch fields are projected onto a // MatchResult so callers (losingSide, etc.) see a uniform shape; // only the fields the kiken-undo path needs are populated. Returns a -// *NotFoundError when the match is unknown. +// *NotFoundError when the match is unknown, and a store load error as it is. func (e *Engine) lookupExistingResult(h state.StoreTx, compID, matchID string) (*state.MatchResult, error) { + // A load error is the caller's error, never "not found": a missing file + // already loads as empty (a knockout-only competition has no pool file, a + // pool-only one no bracket), so an error here is a file that exists and + // cannot be read. Swallowing it answered a corrupt pool-matches.csv with + // a 404 "match not found", which the offline queue drops as final, + // instead of the corrupt-file 500 that names the file to repair. poolMatches, err := h.LoadPoolMatches(compID) - if err == nil { - for i := range poolMatches { - if poolMatches[i].ID == matchID { - r := poolMatches[i] - return &r, nil - } + if err != nil { + return nil, err + } + for i := range poolMatches { + if poolMatches[i].ID == matchID { + r := poolMatches[i] + return &r, nil } } bracket, err := h.LoadBracket(compID) - if err == nil && bracket != nil { + if err != nil { + return nil, err + } + if bracket != nil { for _, round := range bracket.Rounds { for i := range round { if round[i].ID == matchID { @@ -493,19 +605,26 @@ func (e *Engine) hasDownstreamMatchStarted(h state.StoreTx, compID string, playe isStarted := func(s state.MatchStatus) bool { return s == state.MatchStatusRunning || s == state.MatchStatusCompleted } + // A load error is returned, not read as "nothing started" (see + // lookupExistingResult): this answer unlocks a kiken undo, so a file + // that cannot be read must not unlock it by default. poolMatches, err := h.LoadPoolMatches(compID) - if err == nil { - for _, m := range poolMatches { - if m.ID == excludeMatchID { - continue - } - if isStarted(m.Status) && involvesAny(m.SideA, m.SideB) { - return true, nil - } + if err != nil { + return false, err + } + for _, m := range poolMatches { + if m.ID == excludeMatchID { + continue + } + if isStarted(m.Status) && involvesAny(m.SideA, m.SideB) { + return true, nil } } bracket, err := h.LoadBracket(compID) - if err == nil && bracket != nil { + if err != nil { + return false, err + } + if bracket != nil { for _, round := range bracket.Rounds { for _, bm := range round { if bm.ID == excludeMatchID { @@ -542,28 +661,24 @@ func (e *Engine) hasDownstreamMatchStarted(h state.StoreTx, compID string, playe // other half of the same gate. func (e *Engine) matchSideParticipantIDs(h state.StoreTx, compID, matchID string) ([]string, error) { var sideA, sideB, idA, idB string - found := false - if poolMatches, err := h.LoadPoolMatches(compID); err == nil { - if m, ok := findPoolMatch(poolMatches, matchID); ok { - sideA, sideB, idA, idB, found = m.SideA, m.SideB, m.SideAID, m.SideBID, true - } - } - if !found { - if bracket, err := h.LoadBracket(compID); err == nil { - if bm := findBracketMatchInBracket(bracket, matchID); bm != nil { - sideA, sideB, idA, idB, found = bm.SideA, bm.SideB, bm.SideAID, bm.SideBID, true - } - } + // Load errors are returned as they are (see lookupExistingResult): an + // unreadable file is not a match that does not exist. + poolMatches, err := h.LoadPoolMatches(compID) + if err != nil { + return nil, err } - if !found { - // Neither store held the row under this id, or one of them failed to - // load: lookupMatchSides is the shared resolver that reports which, - // returning the not-found error this method's callers already surface. - var err error - sideA, sideB, err = e.lookupMatchSides(h, compID, matchID) + if m, ok := findPoolMatch(poolMatches, matchID); ok { + sideA, sideB, idA, idB = m.SideA, m.SideB, m.SideAID, m.SideBID + } else { + bracket, err := h.LoadBracket(compID) if err != nil { return nil, err } + bm := findBracketMatchInBracket(bracket, matchID) + if bm == nil { + return nil, notFoundErrorf("match %q not found in competition %q", matchID, compID) + } + sideA, sideB, idA, idB = bm.SideA, bm.SideB, bm.SideAID, bm.SideBID } if idA == "" || idB == "" { fallbackA, fallbackB := resolvePlayerIDs(h, compID, sideA, sideB) @@ -590,24 +705,21 @@ func (e *Engine) resolveMatchParticipantIDs(compID, matchID string) ([]string, e // non-transactional (checkSimultaneousMatch, resolveMatchParticipantIDs, // test) callers share one body (bc-twin follow-up). func (e *Engine) lookupMatchSides(h state.StoreTx, compID, matchID string) (string, string, error) { + // Load errors are returned as they are (see lookupExistingResult): an + // unreadable file is not a match that does not exist. poolMatches, err := h.LoadPoolMatches(compID) - if err == nil { - if m, ok := findPoolMatch(poolMatches, matchID); ok { - return m.SideA, m.SideB, nil - } + if err != nil { + return "", "", err + } + if m, ok := findPoolMatch(poolMatches, matchID); ok { + return m.SideA, m.SideB, nil } bracket, err := h.LoadBracket(compID) - if err == nil && bracket != nil { - for _, round := range bracket.Rounds { - for _, bm := range round { - if bm.ID == matchID { - return bm.SideA, bm.SideB, nil - } - } - } - if bracket.ThirdPlaceMatch != nil && bracket.ThirdPlaceMatch.ID == matchID { - return bracket.ThirdPlaceMatch.SideA, bracket.ThirdPlaceMatch.SideB, nil - } + if err != nil { + return "", "", err + } + if bm := findBracketMatchInBracket(bracket, matchID); bm != nil { + return bm.SideA, bm.SideB, nil } return "", "", notFoundErrorf("match %q not found in competition %q", matchID, compID) } @@ -794,12 +906,11 @@ func (e *Engine) recordIneligibilityFromDecision(h state.StoreTx, compID, matchI if err != nil { return nil, err } - if st, ok := statuses[playerID]; ok && !st.Eligible && st.MatchID != matchID { - return nil, &AlreadyIneligibleError{ - PlayerID: playerID, - MatchID: st.MatchID, - Reason: st.Reason, - } + // This call site has only ONE candidate, not two match sides, so it + // passes playerID as sideA and "" as sideB (BarredSides is a per-side + // check whose sideB arm always returns nil for an empty id). + if barred, _ := BarredSides(statuses, matchID, playerID, ""); barred != nil { + return nil, alreadyIneligibleErrorFor(playerID, barred) } if err := h.SetCompetitorStatus(compID, status); err != nil { return nil, err diff --git a/internal/engine/eligibility_test.go b/internal/engine/eligibility_test.go index e00bbbc2e..a1d40cb7e 100644 --- a/internal/engine/eligibility_test.go +++ b/internal/engine/eligibility_test.go @@ -367,50 +367,11 @@ func TestRecordDecision_FusenshoSkipsConcurrentCheck(t *testing.T) { // TestCheckEligibility_AllEligible verifies that CheckEligibility returns // nil when no competitor-status records exist (default-eligible per FR-034). -func TestCheckEligibility_AllEligible(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "elig-all" - createTestCompetition(t, store, compID, "league", 2) - - err := eng.CheckEligibility(compID, []string{"pid1", "pid2", ""}) - assert.NoError(t, err) -} - // TestCheckEligibility_OneIneligible verifies that CheckEligibility // returns *IneligibleCompetitorError when one of the player IDs has // Eligible: false. The empty-string player ID must be skipped. -func TestCheckEligibility_OneIneligible(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "elig-one" - createTestCompetition(t, store, compID, "league", 2) - - require.NoError(t, store.SetCompetitorStatus(compID, domain.CompetitorStatus{ - PlayerID: "ineligible-pid", - Eligible: false, - Reason: "kiken at match-1", - MatchID: "match-1", - })) - - err := eng.CheckEligibility(compID, []string{"eligible-pid", "ineligible-pid"}) - require.Error(t, err) - assert.True(t, errors.Is(err, ErrIneligibleCompetitor)) - var ie *IneligibleCompetitorError - require.ErrorAs(t, err, &ie) - assert.Equal(t, "ineligible-pid", ie.PlayerID) - assert.Equal(t, "kiken at match-1", ie.Reason) -} - // TestCheckEligibility_EmptyIDsSkipped verifies that empty-string IDs // are silently skipped (no lookup, no error). -func TestCheckEligibility_EmptyIDsSkipped(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "elig-empty" - createTestCompetition(t, store, compID, "league", 2) - - err := eng.CheckEligibility(compID, []string{"", ""}) - assert.NoError(t, err) -} - // TestRecordDecision_OnBracketMatch exercises the bracket-match paths of // lookupMatchSides and lookupExistingResult. A kiken decision on a // bracket match must succeed, write the competitor status, and set the @@ -1223,8 +1184,8 @@ func TestRollback_BracketSubResults_ClearedTx(t *testing.T) { // TestStartMatch_RejectsSimultaneousMatch verifies the simultaneity gate // (Phase 2c): when a participant is already Running in another match within // the same competition, StartMatch must return *IneligibleCompetitorError -// matching errors.Is(err, ErrIneligibleCompetitor) with a reason that -// mentions "already fighting". +// matching errors.Is(err, ErrIneligibleCompetitor) with a reason that names +// the running match and says to finish it first (bc-cse). func TestStartMatch_RejectsSimultaneousMatch(t *testing.T) { t.Run("pool match running blocks second pool match for same participant", func(t *testing.T) { eng, store, _ := setupTestEngine(t) @@ -1255,7 +1216,10 @@ func TestStartMatch_RejectsSimultaneousMatch(t *testing.T) { var ineligErr *IneligibleCompetitorError require.ErrorAs(t, err, &ineligErr) - assert.Contains(t, ineligErr.Reason, "already fighting") + // bc-cse: the operator sentence names the match by its own operator + // label (A-0 is not "Pool "-prefixed, so OperatorMatchLabel falls + // through to the bare id) and the court. + assert.Equal(t, "Alice is fighting now in A-0 on Shiaijo A. Finish that match first.", ineligErr.Reason) }) t.Run("SideB participant running blocks second match", func(t *testing.T) { @@ -1287,7 +1251,7 @@ func TestStartMatch_RejectsSimultaneousMatch(t *testing.T) { var ineligErr *IneligibleCompetitorError require.ErrorAs(t, err, &ineligErr) - assert.Contains(t, ineligErr.Reason, "already fighting") + assert.Equal(t, "Bob is fighting now in A-0 on Shiaijo B. Finish that match first.", ineligErr.Reason) }) t.Run("completed match does not block new match for same participant", func(t *testing.T) { @@ -1373,7 +1337,7 @@ func TestStartMatch_RejectsSimultaneousMatch(t *testing.T) { var ineligErr *IneligibleCompetitorError require.ErrorAs(t, err, &ineligErr) - assert.Contains(t, ineligErr.Reason, "already fighting") + assert.Equal(t, "Alice is fighting now in B-1 on Shiaijo C. Finish that match first.", ineligErr.Reason) }) // bc-pnum review finding 5: the pool-vs-pool half of @@ -1702,9 +1666,14 @@ func TestRecordDecision_TeamWithdrawalKeepsSubResults(t *testing.T) { result, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil, false) require.NoError(t, err) assert.Equal(t, "Team White", result.Winner) - require.Len(t, result.SubResults, 2, "team sub-bouts already fought are preserved (FIK Art. 32)") + // bc-tmfn follow-up: bout 3 (TeamSize 3, never fought) is padded with an + // empty row alongside the two preserved fought bouts, so state. + // DefaultWinCreditSide's readers have a row to credit it through. + require.Len(t, result.SubResults, 3, "team sub-bouts already fought are preserved (FIK Art. 32), plus bout 3 padded") assert.Equal(t, "Team Red", result.SubResults[0].Winner) assert.Equal(t, "Team White", result.SubResults[1].Winner) + assert.Equal(t, 3, result.SubResults[2].Position) + assert.False(t, result.SubResults[2].HasResult(), "bout 3 was never fought; padded empty") } // TestRecordDecision_BracketLoserKeepsStruckPoints is the bracket-shaped @@ -1793,9 +1762,13 @@ func TestRecordDecision_BracketTeamWithdrawalKeepsSubResults(t *testing.T) { assert.Equal(t, "Team White", result.Winner) assert.Equal(t, []string{"M"}, result.IpponsA, "withdrawing side (Team Red) keeps its struck ippon (FIK Art. 32) -- this is the assertion preserveLoserScore actually pins") - require.Len(t, result.SubResults, 2, "team sub-bouts already fought are preserved (FIK Art. 32) -- via applyBracketMatchResult's own merge rule, not preserveLoserScore") + // bc-tmfn follow-up: bout 3 (TeamSize 3, never fought) is padded with an + // empty row alongside the two preserved fought bouts. + require.Len(t, result.SubResults, 3, "team sub-bouts already fought are preserved (FIK Art. 32) -- via applyBracketMatchResult's own merge rule, not preserveLoserScore -- plus bout 3 padded") assert.Equal(t, "Team Red", result.SubResults[0].Winner) assert.Equal(t, "Team White", result.SubResults[1].Winner) + assert.Equal(t, 3, result.SubResults[2].Position) + assert.False(t, result.SubResults[2].HasResult(), "bout 3 was never fought; padded empty") } // TestRecordDecision_ReDecisionFlipNoPhantomMaru guards the T103 correction @@ -1917,3 +1890,66 @@ func TestK2ChecksItsHandleIsTransactional(t *testing.T) { })) }) } + +// TestBarredSides pins the ONE "not eligible AND recorded by a different +// match" check (bc-cse), extracted from what used to be three hand-copied +// loops in StartMatchTx, checkEligibilityExcludingMatch and (now) +// mobileapp's ineligibleSides annotation. +func TestBarredSides(t *testing.T) { + statuses := map[string]domain.CompetitorStatus{ + "p-barred": { + PlayerID: "p-barred", Eligible: false, MatchID: "Pool A-0", + Reason: "kiken-voluntary at Pool A-0", Reinstateable: false, + }, + "p-eligible": {PlayerID: "p-eligible", Eligible: true, MatchID: "Pool A-0"}, + "p-self-recorded": { + PlayerID: "p-self-recorded", Eligible: false, MatchID: "Pool A-1", + Reason: "fusenpai at Pool A-1", + }, + } + + t.Run("a barred side is reported, the other is not", func(t *testing.T) { + a, b := BarredSides(statuses, "Pool A-2", "p-barred", "p-eligible") + require.NotNil(t, a) + assert.Equal(t, "Pool A-0", a.MatchID) + assert.Equal(t, "kiken-voluntary at Pool A-0", a.Reason) + assert.Nil(t, b) + }) + + t.Run("no entry for an id is not barred", func(t *testing.T) { + a, b := BarredSides(statuses, "Pool A-2", "unknown-id", "p-eligible") + assert.Nil(t, a) + assert.Nil(t, b) + }) + + t.Run("an empty id is never barred", func(t *testing.T) { + a, b := BarredSides(statuses, "Pool A-2", "", "") + assert.Nil(t, a) + assert.Nil(t, b) + }) + + t.Run("the undo path: a status THIS match itself recorded does not bar it", func(t *testing.T) { + a, _ := BarredSides(statuses, "Pool A-1", "p-self-recorded", "") + assert.Nil(t, a, "re-scoring the match that recorded the status must not block itself") + }) + + t.Run("both sides barred are both reported", func(t *testing.T) { + statuses2 := map[string]domain.CompetitorStatus{ + "p1": {PlayerID: "p1", Eligible: false, MatchID: "Pool A-0", Reason: "kiken at Pool A-0"}, + "p2": {PlayerID: "p2", Eligible: false, MatchID: "Pool B-0", Reason: "fusenpai at Pool B-0"}, + } + a, b := BarredSides(statuses2, "Pool C-0", "p1", "p2") + require.NotNil(t, a) + require.NotNil(t, b) + assert.Equal(t, "Pool A-0", a.MatchID) + assert.Equal(t, "Pool B-0", b.MatchID) + }) + + t.Run("the returned status is a copy, not an alias into the map", func(t *testing.T) { + a, _ := BarredSides(statuses, "Pool A-2", "p-barred", "") + require.NotNil(t, a) + a.Reason = "mutated" + assert.Equal(t, "kiken-voluntary at Pool A-0", statuses["p-barred"].Reason, + "mutating the returned copy must not reach the caller's map") + }) +} diff --git a/internal/engine/engi.go b/internal/engine/engi.go index 5d93a0acb..b4de9fb4c 100644 --- a/internal/engine/engi.go +++ b/internal/engine/engi.go @@ -140,7 +140,9 @@ func (e *Engine) recordEngiMatch( } bm := &b.Rounds[rIdx][mIdx] if !bracketMatchPlayable(bm) { - return validationErrorf("knockout match %s is not ready to score: a feeder pool or match has not finished", matchID) + // PURE (bm already carries Number/DisplayRound): safe + // inside UpdateBracket's mutate callback (bc-cse item 14). + return validationErrorf("%s is not ready to score: a feeder pool or match has not finished", SentenceCase(MatchLabel(bracketMatchRef(bm)))) } // bc-kcdg: engi is a knockout like any other, so a correction // here repaints the next round exactly as the kendo path's @@ -162,7 +164,7 @@ func (e *Engine) recordEngiMatch( return err } } - priorWinner, priorWinnerID := bm.Winner, bm.WinnerID + priorWinner, priorWinnerID := propagatedWinnerOf(b, rIdx, mIdx, bm) result = applyEngiToBracketMatch(bm, flagsA, flagsB, winnerSide, correctionReason) e.propagateBracketWinner(b, rIdx, mIdx) if force && winnerActuallyChanged(priorWinner, priorWinnerID, bm) { @@ -174,7 +176,8 @@ func (e *Engine) recordEngiMatch( if b.ThirdPlaceMatch != nil && b.ThirdPlaceMatch.ID == matchID { bm := b.ThirdPlaceMatch if !bracketMatchPlayable(bm) { - return validationErrorf("knockout match %s is not ready to score: a feeder pool or match has not finished", matchID) + // PURE, same reasoning as the round branch above. + return validationErrorf("%s is not ready to score: a feeder pool or match has not finished", SentenceCase(MatchLabel(bracketMatchRef(bm)))) } result = applyEngiToBracketMatch(bm, flagsA, flagsB, winnerSide, correctionReason) // No propagation out of bronze. @@ -185,6 +188,7 @@ func (e *Engine) recordEngiMatch( if updateErr != nil { return nil, updateErr } + e.restoreForceReopened(h, compID, reopened) if fo.Reopened != nil { *fo.Reopened = append(*fo.Reopened, reopened...) } diff --git a/internal/engine/errors.go b/internal/engine/errors.go index b94245683..a6a83453f 100644 --- a/internal/engine/errors.go +++ b/internal/engine/errors.go @@ -58,37 +58,6 @@ func notFoundErrorf(format string, args ...any) *NotFoundError { // T103, CHK024. var ErrDecisionLocked = errors.New("decision locked: a subsequent match has started") -// ErrDownstreamKnockoutScored is the sentinel matched by errors.Is for -// DownstreamKnockoutScoredError. Handlers should return HTTP 409. -// -// mp-e2k1. -var ErrDownstreamKnockoutScored = errors.New("downstream knockout match already scored") - -// DownstreamKnockoutScoredError is returned when a pool re-score would -// change a pool finisher who has already been consumed by a started or -// completed knockout (bracket) match. The operator must reset the -// knockout match first before correcting the pool result. -// -// mp-e2k1. -type DownstreamKnockoutScoredError struct { - // Pool is the name of the pool whose re-score was rejected. - Pool string - // Finisher is the name of the pool finisher whose bracket placement - // would be displaced by the re-score. - Finisher string - // MatchID is the ID of the downstream knockout match that has already - // been started (running or completed) with the current finisher as a side. - MatchID string -} - -func (e *DownstreamKnockoutScoredError) Error() string { - return fmt.Sprintf("pool %q re-score rejected: finisher %q is already in a started knockout match %q, reset that match first", e.Pool, e.Finisher, e.MatchID) -} - -func (e *DownstreamKnockoutScoredError) Is(target error) bool { - return target == ErrDownstreamKnockoutScored -} - // ErrDownstreamKnockoutPlayed is the sentinel matched by errors.Is for // DownstreamKnockoutPlayedError. Handlers should return HTTP 409. // @@ -114,15 +83,36 @@ var ErrDownstreamKnockoutPlayed = errors.New("downstream knockout match already // Never raised for a matchWriteRestore (a K3 rollback replaying a trusted // snapshot), and never for a downstream slot merely auto-completed by a bye // (see bracketMatchCarriesOwnResult). +// +// Also returned for a POOL correction in a mixed competition that moves who +// holds a qualifying place while a knockout match the old qualifier already +// fought stands on it (requalifyAfterPoolWrite): MatchID is then the pool +// match, Blocking the knockout matches fought, QualifierChange the places +// that move, and Force reopens those matches with the new qualifier seated. +// A pool-rank override (OverridePoolRanks) is answered the same way, with an +// empty MatchID: it moves the pool's order without correcting any match. type DownstreamKnockoutPlayedError struct { - // MatchID is the id of the match being corrected. + // MatchID is the id of the match being corrected; "" for a pool-rank + // override, which corrects none. MatchID string + // Label is MatchID's operator-facing label (bc-cse item 14), populated + // PURELY at construction (MatchLabel/OperatorMatchLabel take an + // already-loaded comp/bracket, no store I/O), never resolved inside + // Error() itself: Error() has no store handle to resolve one with, and + // this error is typically raised from inside a live transaction, where + // a label helper that reads through the store would deadlock against + // the lock the transaction already holds (non-reentrant). Empty only + // for a pool-rank override (MatchID is also empty there; Error() + // reports the pool by name instead). Every production constructor + // sets it otherwise; there is no bare-MatchID fallback in Error(), so a + // hand-built struct (a test value) must set it too. + Label string // BlockingMatchID is the FIRST blocking match (bronze before the next // round), kept as the single-value form every existing consumer reads. BlockingMatchID string // Blocking is every match this correction is blocked on, one hop down and - // closed with a result of its own, each with the MATCH NUMBER the operator - // knows it by. Almost always one; a semifinal feeds both the final and the + // closed with a result of its own, each with the MATCH NUMBER and knockout + // round the operator knows it by (MatchLabel). Almost always one; a semifinal feeds both the final and the // bronze match, so it can be two, and both are cleared by the one // confirmation that names them (see newDownstreamKnockoutPlayedError for // why they cannot be split). @@ -139,9 +129,60 @@ type DownstreamKnockoutPlayedError struct { // see the plural arm below and downstreamKnockoutPlayedConfirm in // write_result.jsx, which mirrors it. Displaced string + // QualifierChange is set only when the write being corrected is a POOL + // match in a mixed competition: the correction moves who holds one or more + // of that pool's qualifying places, and Blocking lists the knockout matches + // the old qualifier has already fought (planRequalification). Each entry + // names the place and both occupants, so the operator is told who moves, + // not just which match reopens. Empty for a knockout correction. + QualifierChange []QualifierChange +} + +// QualifierChange is one pool place a correction moves: from the competitor +// seated in the knockout for it to the one the corrected standings now put +// there. Tied means the corrected standings leave that place undecided until +// a tie-break is fought, so the slot returns to its draw label ("Pool A-1st") +// and To is empty. +type QualifierChange struct { + Pool string `json:"pool"` + Rank int `json:"rank"` + Place string `json:"place"` + From QualifierIdentity `json:"from"` + To QualifierIdentity `json:"to"` + Tied bool `json:"tied,omitempty"` +} + +// QualifierIdentity names a competitor on the wire the way every other +// payload does: the name to show, the participant id to address them by. +type QualifierIdentity struct { + Name string `json:"name"` + ID string `json:"id,omitempty"` } func (e *DownstreamKnockoutPlayedError) Error() string { + // bc-cse item 14: Label is the operator-facing spelling of MatchID + // (populated at construction; see the struct doc for why not here). + // Every production constructor sets it, so there is no bare-id fallback + // here; a hand-built test value must set it too. + label := e.Label + if len(e.QualifierChange) > 0 { + labels := make([]string, 0, len(e.Blocking)) + for _, b := range e.Blocking { + labels = append(labels, MatchLabel(b)) + } + verb, them, who := "was", "it", "the competitor being replaced" + if len(labels) > 1 { + verb, them, who = "were", "them", "the competitors being replaced" + } + pool := e.QualifierChange[0].Pool + change := fmt.Sprintf("correcting %s changes who qualified from %s", label, pool) + if e.MatchID == "" { + // A pool-rank override (OverridePoolRanks) corrects no match. + change = fmt.Sprintf("changing the ranking of %s changes who qualified from it", pool) + } + return fmt.Sprintf("%s, and %s %s already fought by %s. Retry with forceDownstreamReopen to apply the change and reopen %s to be fought again", + change, strings.Join(labels, " and "), verb, who, them) + } labels := make([]string, 0, len(e.Blocking)) for _, b := range e.Blocking { labels = append(labels, MatchLabel(b)) @@ -153,22 +194,94 @@ func (e *DownstreamKnockoutPlayedError) Error() string { if len(e.Blocking) > 1 { // No Displaced clause: it names one competitor, and these matches do // not share one. - return fmt.Sprintf("correcting match %q would change the winner already propagated into %s, which have recorded their own results. Retry with forceDownstreamReopen to apply the correction and reopen both to be fought again", - e.MatchID, blocked) + return fmt.Sprintf("correcting %s would change the winner already propagated into %s, which have recorded their own results. Retry with forceDownstreamReopen to apply the correction and reopen both to be fought again", + label, blocked) } - return fmt.Sprintf("correcting match %q would change the winner already propagated into %s, which has recorded its own result; this would displace %q without updating that result. Retry with forceDownstreamReopen to apply the correction and reopen %s to be fought again", - e.MatchID, blocked, e.Displaced, blocked) + return fmt.Sprintf("correcting %s would change the winner already propagated into %s, which has recorded its own result; this would displace %q without updating that result. Retry with forceDownstreamReopen to apply the correction and reopen %s to be fought again", + label, blocked, e.Displaced, blocked) } func (e *DownstreamKnockoutPlayedError) Is(target error) bool { return target == ErrDownstreamKnockoutPlayed } -// MatchLabel names a match the way the OPERATOR sees it: "Match 3", the label -// on the score sheet, the bracket and the Excel tree sheet. +// ErrDownstreamKnockoutRunning is the sentinel matched by errors.Is for +// DownstreamKnockoutRunningError. Handlers should return HTTP 409. +var ErrDownstreamKnockoutRunning = errors.New("downstream knockout match is being fought") + +// DownstreamKnockoutRunningError refuses a write that would move a +// qualifier out of a knockout match somebody is fighting RIGHT NOW. Two +// producers construct it, distinguished by Reopening: a pool correction in +// a mixed competition (pool_requalify.go's requalifyAfterPoolWrite, the +// default Reopening:false), and the reopen / requeue-blocker-and-reopen +// doors (reopenBracketDownstreamCheck, kachinuki.go, Reopening:true). Unlike +// DownstreamKnockoutPlayedError it cannot be confirmed past: reopening a +// match mid-bout would wipe strikes being scored at the shiaijo, so the +// operator finishes the match or sends it back to the queue first, THEN +// retries -- which of those two doors "retries" means is Reopening's whole +// reason to exist (see Error()). Checked before the played case, so the +// operator is never asked to confirm something that would then be refused. +type DownstreamKnockoutRunningError struct { + // MatchID is the pool match being corrected; "" for a pool-rank override + // (OverridePoolRanks), which corrects none. + MatchID string + // Running is every knockout match the move would reach that is being + // fought, each with the number the operator knows it by. + Running []ReopenedMatch + // Reopening is true ONLY from the reopen/requeue-blocker-and-reopen + // construction site. A reopen has no "save" step to retry -- bc-cse: + // the shared sentence used to end "...then save again" regardless of + // door, which is simply wrong advice on a reopen (there was never a + // save to repeat); Reopening picks the correct remedy clause instead. + Reopening bool +} + +// Error is the operator-facing sentence (the SPA shows the same words, from +// write_result.jsx's downstreamKnockoutRunningMessage) for the SAVE-path +// wording (Reopening:false); the reopen path's own wording is Reopening's +// whole reason to exist, see the struct doc. +func (e *DownstreamKnockoutRunningError) Error() string { + labels := make([]string, 0, len(e.Running)) + for _, r := range e.Running { + labels = append(labels, MatchLabel(r)) + } + subject := strings.Join(labels, " and ") + if subject == "" { + subject = "A knockout match" + } + verb := "is" + them := "it" + if len(labels) > 1 { + verb, them = "are", "them" + } + retry := "then save again" + if e.Reopening { + retry = "then reopen this match again" + } + return fmt.Sprintf("%s %s being fought now. Finish %s or send %s back to the queue, %s.", + SentenceCase(subject), verb, them, them, retry) +} + +func (e *DownstreamKnockoutRunningError) Is(target error) bool { + return target == ErrDownstreamKnockoutRunning +} + +// MatchLabel names a KNOCKOUT match the way the OPERATOR sees it: its match +// number, the label on the score sheet, the bracket and the Excel tree sheet, +// qualified by its round, "Match 3 (Final)". The round is what tells it apart +// from a pool match: pool matches are numbered from 1 inside each pool, so a +// bare "Match 1" in a dialog raised while correcting "Pool A · Match 1" read as +// the match on screen. The round name is the one the bracket prints over the +// match's column (bracketRoundLabel in web-mobile/js/bracket.jsx, mirrored by +// roundLabelFromEnd below); a bracket saved before DisplayRound existed has no +// round to name, so it says "knockout Match 3" instead. +// +// The client shows this string as it is (the payloads carry it as `label` +// beside `number`), so the dialog and the server's own message say the same +// words and the format is decided here only. // // The 3rd-place match is the one match that has a NAME instead of a number: -// assignBracketMatchNumbers walks Bracket.Rounds, and the bronze hangs off the +// state.Bracket.NumberMatches walks Bracket.Rounds, and the bronze hangs off the // separate ThirdPlaceMatch field, so it is numbered neither here nor on the // printed tree (helper.AssignMatchNumbers walks the same rounds). It is called // the 3rd-place match on every surface an operator sees, so that is what this @@ -179,9 +292,29 @@ func (e *DownstreamKnockoutPlayedError) Is(target error) bool { // cannot name (a bye placeholder, or a bracket saved before numbering // existed): a bare id still beats "Match 0", but it is a fallback, not a // normal case. +// SentenceCase upper-cases s's first byte, leaving the rest untouched, and +// returns "" unchanged. Every operator match label in this codebase +// (MatchLabel above, OperatorMatchLabel, operatorMatchLabel) deliberately +// starts lowercase mid-sentence ("the 3rd-place match", "knockout Match 3", +// a bare pool name), which reads wrong the moment one is interpolated at +// the START of a sentence ("%s is not completed yet..."). Extracted from a +// hand-rolled copy (DownstreamKnockoutRunningError.Error) so every OTHER +// sentence-initial use -- in this package and in mobileapp, which already +// imports engine -- shares the one capitalisation rule instead of another +// hand-rolled copy. +func SentenceCase(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + func MatchLabel(m ReopenedMatch) string { if m.Number > 0 { - return fmt.Sprintf("Match %d", m.Number) + if m.DisplayRound > 0 { + return fmt.Sprintf("Match %d (%s)", m.Number, roundLabelFromEnd(m.DisplayRound-1)) + } + return fmt.Sprintf("knockout Match %d", m.Number) } if m.ID == state.BronzeMatchID { return "the 3rd-place match" @@ -189,6 +322,24 @@ func MatchLabel(m ReopenedMatch) string { return m.ID } +// roundLabelFromEnd names a knockout round by how many rounds come after it: +// 0 = Final, 1 = Semifinals, 2 = Quarterfinals, then the bracket-size form +// "R16", "R32", .... It mirrors roundLabelFromEnd in web-mobile/js/bracket.jsx, +// which names the bracket's columns, and both are pinned by +// testdata/round_labels.json so the round a dialog names is the column the +// operator finds the match under. +func roundLabelFromEnd(fromEnd int) string { + switch fromEnd { + case 0: + return "Final" + case 1: + return "Semifinals" + case 2: + return "Quarterfinals" + } + return fmt.Sprintf("R%d", 1<<(fromEnd+1)) +} + // ErrSwissExportUnsupported is returned by Engine.ExportCompetitionXlsx (and // therefore by ExportTournamentWorkbooks), and is aliased by // internal/export.ErrSwissExportUnsupported for BuildResultsWorkbook. Swiss diff --git a/internal/engine/excel_draw_parity_test.go b/internal/engine/excel_draw_parity_test.go index d86845642..26f6398b7 100644 --- a/internal/engine/excel_draw_parity_test.go +++ b/internal/engine/excel_draw_parity_test.go @@ -508,16 +508,12 @@ func assertWorkbookMatchesBracket(t *testing.T, eng *Engine, store *state.Store, } // The bouts a tree page cannot show are exactly the ones above the page - // roots (they belong to no single shiaijo); there are len(pages)-1 of them - // and, because both numbering walks run deepest round first, they are the - // last numbers. + // roots (they belong to no single shiaijo); there are len(pages)-1 of them. assert.Equalf(t, len(engineBouts)-(len(pages)-1), len(printed), "every bout except the %d above the page roots must be printed", len(pages)-1) // A bout prints on a tree page exactly when its whole subtree lies on - // that page. The numbering follows the risen-aware walk, so a cross-page - // bout can hold ANY number -- including Match 1, when two lone-leaf pages' - // occupants fight in round 1 -- which is why this is a set equality - // against the bracket rather than a 1..N contiguity check. + // that page, so this compares sets against the bracket rather than + // assuming which numbers the bouts above the pages hold. pageOfEntrant := map[string]string{} for _, page := range pages { for _, l := range page.leaves { @@ -565,12 +561,8 @@ func assertWorkbookMatchesBracket(t *testing.T, eng *Engine, store *state.Store, // The entrant's first bout is its parent junction, one level up, // whose round counted from the page's own final is exactly the // leaf's level. Level 0 (a page that is a lone entrant) means the - // first bout is above the page entirely -- the page cannot say - // which round that bout fights in (a cross-page pair may fight in - // round 1 under the risen walk), so it carries no offset evidence. - if l.level == 0 { - continue - } + // first bout sits directly above the page, so its round is the + // offset itself. entryRound := l.level got := firstDR[l.label] - entryRound if offset < 0 { diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go index a5880dc7a..576ed4d95 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -34,6 +34,7 @@ import ( "log" "sort" "strings" + "time" "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/gitrgoliveira/bracket-creator/internal/state" @@ -739,13 +740,21 @@ func (e *Engine) MaybeAdvanceKachinuki(compID, matchID string) (bool, []state.Su // condition has exactly ONE sentinel and one wire shape across the score // and reopen paths. var ( - // ErrReopenNotCompleted: only a COMPLETED kachinuki match can be - // reopened; a running match needs no reopen and a scheduled one has - // nothing to reopen. - ErrReopenNotCompleted = errors.New("match is not completed; only a completed kachinuki match can be reopened") - // ErrReopenDownstreamFought: the reopened match's winner was already - // propagated into a downstream knockout match that has started or - // recorded results; reopening would corrupt the bracket. + // ErrReopenNotCompleted: only a COMPLETED match can be reopened; a + // running match needs no reopen and a scheduled one has nothing to + // reopen. + ErrReopenNotCompleted = errors.New("match is not completed; only a completed match can be reopened") + // ErrReopenDownstreamFought is retractPropagatedWinner's own defensive + // backstop re-check at the actual mutation point (mp-gmcg review), not + // the primary interactive refusal: an operator reopening through the + // normal doors sees *DownstreamKnockoutRunningError instead (bc-cse), + // raised earlier in the same transaction by reopenBracketDownstreamCheck. + // A downstream knockout match CLOSED with a result of its own is not + // this error either: that one is the operator's to confirm + // (DownstreamKnockoutPlayedError, see reopenBracketDownstreamCheck), and + // one a bye resolved is unwound, not refused. What reaches a client + // through this sentinel is only a downstream row no write path produces + // (see reopenBracketDownstreamCheck's last paragraph). ErrReopenDownstreamFought = errors.New("cannot reopen: a downstream knockout match has already started or recorded a result") // ErrRemoveBoutNotRunning: bouts are removed from a RUNNING encounter only. // A completed kachinuki match already had its trailing unscored bouts @@ -759,29 +768,18 @@ var ( ErrNoRemovableBout = errors.New("no unscored bout to remove; only an empty appended bout can be removed") ) -// loadKachinukiComp loads the target competition and rejects it unless it is a -// kachinuki team competition — the shared prologue of ReopenKachinukiMatch and -// RequeueBlockerAndReopenKachinuki (mp-gmcg review R7: the two used to copy-paste -// this LoadCompetition → IsKachinuki → same literal operator sentence verbatim). -// It touches no court state, so callers run it OUTSIDE WithCourtExclusivityLock: -// a bad-input rejection need not serialize on the tournament-global lock. The -// reason is NOT handled here — reopenKachinukiUnderCourtLock, the single shared -// consumer, trims it (mp-gmcg review). -func (e *Engine) loadKachinukiComp(compID string) (*state.Competition, error) { - comp, err := e.store.LoadCompetition(compID) - if err != nil { - return nil, err - } - if !comp.IsKachinuki() { - return nil, validationErrorf("reopen is only supported for kachinuki team matches; correct other results via the score editor (correctionReason)") - } - return comp, nil -} - -// ReopenKachinukiMatch is the sanctioned "Reopen match" path for a -// COMPLETED kachinuki team match (mp-gmcg, spec 006 decision 4): status -// back to running, match-level winner/decision cleared, the full bout log -// kept, so the operator can add more bouts and later End match again. +// ReopenMatch is the sanctioned "Reopen match" path for a COMPLETED match +// that is either a kachinuki team match (mp-gmcg, spec 006 decision 4) or +// any match, team or individual, decided by a WITHDRAWAL or a DEFAULT WIN +// (domain.IsDefaultWinDecisionStr: kiken/kiken-voluntary/kiken-injury, +// fusenpai, or fusensho -- operator ruling 2026-09-24: "Everything should be +// able to be fixed, in case of a wrong entry"; widened to fusensho by +// bc-cse, "Clear default win and reopen"): status back to running, the +// match-level verdict cleared, the bouts already fought kept, so the +// operator scores what is left and finishes the match again through the +// normal score path. For a withdrawal or default win that is the whole +// point: the opponent received the default score, so removing one means the +// match was never decided. The gate is reopenResultPreconditionTx. // // `reason` is an OPTIONAL audit justification, persisted as the match's // CorrectionReason when supplied. Reopening is the only way to rewrite a @@ -790,12 +788,11 @@ func (e *Engine) loadKachinukiComp(compID string) (*state.Competition, error) { // justification cannot simply be dropped — but demanding it HERE was too // much friction: an operator who ended a match by mistake, at a shiaijo, // mid-session, had to compose a reason before they could get back in. -// Reopen is therefore one tap, and when no reason is given the match is -// flagged ReopenPending instead: the score path then refuses to complete it -// again without a correctionReason and clears the flag once one lands -// (mp-gmcg). The audit record is written LATER than the action it -// justifies; it is never written at all only if the match is never ended -// again, in which case there is no rewritten result to justify. +// Reopen is therefore one tap, and ending the match again asks for no +// reason either (operator ruling 2026-09-25: a match can be reopened without +// any reason, and nothing is gated on that). When no reason is given the +// match is flagged ReopenPending, which only lets a reason sent with the next +// completion be kept as its correction reason; that completion clears it. // // The flag is persisted rather than held client-side because the score // editor mounts per match: navigating away and back would lose it. @@ -819,6 +816,17 @@ func (e *Engine) loadKachinukiComp(compID string) (*state.Competition, error) { // already lets it through on a busy court. DO NOT remove this guard as a // redundant-looking check. // +// The one reopen that does NOT take the court is a match-level fusensho whose +// barred competitor is still barred: it goes back to SCHEDULED +// (reopenTargetStatus), so neither gate applies to it. It used to be refused +// court_busy whenever another bout was running on its court, and the only +// remedy offered (requeue the court's occupant) wiped that live bout to free +// a court the reopen never takes. Whether the reopen lands scheduled is read +// before the cross-competition gate (checkTargetReopenable) and HELD inside +// the tx, so a reinstatement landing in between cannot turn a gate-skipped +// reopen into a running match; that competitor's match simply starts from +// the queue the normal way. +// // The cross-competition half runs BEFORE WithTransaction // (CheckCrossCompCourtBusy takes read locks on other competitions, so // calling it while holding this competition's write lock risks a @@ -827,26 +835,61 @@ func (e *Engine) loadKachinukiComp(compID string) (*state.Competition, error) { // checkCourtExclusivityTx. Both are skipped when the match has no court // assigned. // -// Kachinuki ONLY: for every other competition type the correction path -// (completed -> completed with a correctionReason) remains the sole -// sanctioned edit of a finished result, and this returns a -// *ValidationError (HTTP 400). The score path's stale-write guard -// (a plain running write against a completed match silently no-ops) is -// intentionally untouched; reopen is explicit and separate. +// Any other completed match (not kachinuki, not decided by a withdrawal or +// default win) returns a *ValidationError (HTTP 400): the correction path +// (completed -> completed with a correctionReason) remains its sanctioned +// edit. The score +// path's stale-write guard (a plain running write against a completed match +// silently no-ops) is intentionally untouched; reopen is explicit and +// separate. // // Bracket matches: the completed result may already have been propagated // downstream (propagateBracketWinner fills the next round's slot, and a -// semifinal feeds its loser to the bronze match). If any downstream -// target has started or recorded a result, reopen is rejected with -// ErrReopenDownstreamFought rather than corrupting the bracket. When the -// downstream slot is merely filled but unfought, the slot is reset the -// same way generation fills it: the next-round side returns to its -// "Winner of rX-mY" placeholder (the exact string propagateBracketWinner -// re-resolves on the next completion) and the bronze side to empty. -func (e *Engine) ReopenKachinukiMatch(compID, matchID, reason string) error { - comp, err := e.loadKachinukiComp(compID) +// semifinal feeds its loser to the bronze match). When the downstream slot +// is merely filled but unfought, the slot is reset the same way generation +// fills it: the next-round side returns to its "Winner of rX-mY" placeholder +// (the exact string propagateBracketWinner re-resolves on the next +// completion) and the bronze side to empty. A next-round match a BYE resolved +// off this winner (nobody fought it) is unwound the same way, back to the +// completed-with-no-winner shape generation gave it, and so is every further +// bye the winner was passed through; the match past them answers as the next +// round does here (propagatedDownstreamOf). A downstream match CLOSED with a +// result of its own is the operator's call, exactly as on a knockout +// correction (bc-kcdg, operator ruling 2026-09-24: "The operator just needs +// to be aware of the consequences"): refused with +// *DownstreamKnockoutPlayedError naming it, and on a retry with +// opts.Force it is reopened for re-entry (forceReopenDownstreamChain, one +// hop past any byes) and its id reported through opts.Reopened. A downstream +// match that is RUNNING is still refused outright -- through the +// operator-facing doors that is *DownstreamKnockoutRunningError, raised by +// reopenBracketDownstreamCheck earlier in the same transaction; +// ErrReopenDownstreamFought is only the defensive re-check at the actual +// mutation point (see its own doc comment above), not what a client normally +// receives. +// +// ELIGIBILITY. A reopen clears the match's decision, so reopening a match a +// withdrawal (kiken, fusenpai) ended removes that withdrawal, and the +// eligibility record follows the ruling exactly as on the score path +// (restoreIfWithdrawalRemoved, operator ruling 2026-09-24: "Everything should +// be able to be fixed, in case of a wrong entry"): the competitor it barred is +// restored in the same transaction as the reopen, and the restored status is +// returned so the handler broadcasts competitor_status_updated. nil when the +// reopened result was not a withdrawal -- which now INCLUDES a match-level +// fusensho (domain.IsWithdrawalDecisionStr, scoring_tx.go, deliberately +// excludes it): recording a fusensho never wrote a CompetitorStatus for +// anyone, so there is nothing to restore, and reopening one correctly +// restores nobody's eligibility either. A downstream match force-reopened +// with it gets the same treatment (restoreForceReopened), reported on its +// opts.Reopened entry's Restored. +// +// STAMP. The reopen sets ModifiedAt to the server's now (reopenPoolMatch, +// reopenBracketMatch), so a write stamped before it cannot win +// last-write-wins against it and complete the running match again. +func (e *Engine) ReopenMatch(compID, matchID, reason string, opts ...ForceOptions) (*domain.CompetitorStatus, error) { + fo := firstForceOptions(opts) + comp, err := e.store.LoadCompetition(compID) if err != nil { - return err + return nil, err } // Court exclusivity is held HERE, inside the engine, so the @@ -860,7 +903,8 @@ func (e *Engine) ReopenKachinukiMatch(compID, matchID, reason string) error { // and the ordering is courtCheckMu → per-comp lock, the same as the score // path. The LoadCompetition/validation above stay outside: they touch no // court state, so a bad-input rejection need not serialize on the lock. - return e.store.WithCourtExclusivityLock(func() error { + var restored *domain.CompetitorStatus + err = e.store.WithCourtExclusivityLock(func() error { // Read-only RESULT preconditions BEFORE the court gates, so a plain reopen // of a permanently-unreopenable target (not completed, or its result fed a // fought downstream) reports THAT — not a transient court_busy. The admin @@ -876,43 +920,62 @@ func (e *Engine) ReopenKachinukiMatch(compID, matchID, reason string) error { // requeue path already pre-checks before its revert and need not re-run it // (the check is read-only, so a second run would be wasted work, not a // hazard). - if verr := e.checkTargetReopenable(compID, comp, matchID); verr != nil { + landsScheduled, verr := e.checkTargetReopenable(compID, comp, matchID, fo.Force) + if verr != nil { return verr } - return e.reopenKachinukiUnderCourtLock(compID, comp, matchID, reason) + var rerr error + restored, rerr = e.reopenUnderCourtLock(compID, comp, matchID, reason, fo, landsScheduled) + return rerr }) + if err != nil { + return nil, err + } + return restored, nil } -// reopenKachinukiUnderCourtLock runs the reopen body assuming the store's +// reopenUnderCourtLock runs the reopen body assuming the store's // court-exclusivity lock is ALREADY held by the caller (mp-gmcg review A4), so a // preceding blocker requeue and this reopen can share ONE lock section -// (RequeueBlockerAndReopenKachinuki) with no window for another match to grab +// (RequeueBlockerAndReopen) with no window for another match to grab // the freed court. It MUST NOT take the court lock itself (the mutex is -// non-reentrant). comp is the kachinuki-validated target competition the caller -// already loaded. This is the SINGLE shared consumer of `reason`, so it trims -// it here (mp-gmcg review): a padded reason can never reach reopenPending / -// CorrectionReason regardless of which entry point called in. -func (e *Engine) reopenKachinukiUnderCourtLock(compID string, comp *state.Competition, matchID, reason string) error { +// non-reentrant). comp is the target competition the caller already loaded; +// whether this match may be reopened at all is reopenResultPreconditionTx's +// call, made below under the tx. This is the SINGLE shared consumer of +// `reason`, so it trims it here (mp-gmcg review): a padded reason can never +// reach reopenPending / CorrectionReason regardless of which entry point +// called in. fo carries the operator's confirmation for a downstream match +// closed with its own result (see ReopenMatch). landsScheduled is the +// caller's read-only pre-read (checkTargetReopenable) that this reopen goes +// back to the queue rather than onto the court; it skips the cross-competition +// gate, which cannot wait for the tx to decide (see the COURT GATE note on +// ReopenMatch), and holds the reopen to scheduled. +func (e *Engine) reopenUnderCourtLock(compID string, comp *state.Competition, matchID, reason string, fo ForceOptions, landsScheduled bool) (*domain.CompetitorStatus, error) { reason = strings.TrimSpace(reason) // Cross-competition court gate, deliberately OUTSIDE the transaction (see // the doc comment). Its own *NotFoundError is now only a backstop: both entry // points surface an unknown match earlier (the plain entry via // checkTargetReopenable, the requeue entry via requireBlockerHoldsCourt), so // this gate's 404 is reachable only if the competition is deleted in the gap. - if err := e.CheckCrossCompCourtBusy(compID, matchID); err != nil { - return err + // Skipped for a reopen that lands scheduled: it takes no court. + if !landsScheduled { + if err := e.CheckCrossCompCourtBusy(compID, matchID); err != nil { + return nil, err + } } var opErr error + var restored *domain.CompetitorStatus txErr := e.store.WithTransaction(compID, func(tx state.StoreTx) error { // courtGate is the same-competition half of the reopen court gate (see - // the COURT GATE note above): reopening flips the match back to running, - // so a court that already has a running match would end up with two, - // wedging the exclusivity check for BOTH. DO NOT remove it as a - // redundant-looking check. It reuses the pool matches + bracket - // findMatchHome already loaded under this tx for the same-comp scan, - // instead of the re-load a nil/nil call would do (mp-gmcg review E4); a - // nil slice (pool-load error, or the bracket on a pool home) reloads. + // the COURT GATE note above): a reopen that flips the match back to + // running on a court that already has a running match would leave it + // with two, wedging the exclusivity check for BOTH. DO NOT remove it as + // a redundant-looking check. It runs only for a reopen that lands + // RUNNING: one that lands scheduled takes no court. It reuses the pool + // matches + bracket findMatchHome already loaded under this tx for the + // same-comp scan, instead of the re-load a nil/nil call would do + // (mp-gmcg review E4); a nil one (the bracket on a pool home) reloads. courtGate := func(h matchHome, court string) error { return courtFreeInCompTxWith(tx, compID, matchID, court, h.PoolMatches, h.BracketRoot) } @@ -921,50 +984,105 @@ func (e *Engine) reopenKachinukiUnderCourtLock(compID string, comp *state.Compet // Read-only RESULT preconditions (completed + downstream-not-fought), // the SAME check checkTargetReopenable runs, shared via // reopenResultPreconditionTx so neither path can add one the other - // misses (mp-gmcg review). The pool branch's downstream refusal - // matters because a pool finisher feeds the knockout INDIRECTLY via - // the standings the bracket was seeded from (the score path's mp-e2k1 - // guard can't catch it: by re-End the reopened match is already out of - // the standings baseline). These precede the SAME-competition court + // misses (mp-gmcg review). A pool target has no downstream check: + // reopening it moves no knockout slot, and the write that finishes + // it answers for any qualifier it moves (requalifyAfterPoolWrite + // measures that against the bracket, so the reopen dropping the + // match out of the standings cannot hide it). These precede the SAME-competition court // gate below; the cross-comp gate (CheckCrossCompCourtBusy) already ran - // before this tx, and the plain-reopen entry (ReopenKachinukiMatch) + // before this tx, and the plain-reopen entry (ReopenMatch) // pre-checks these preconditions before THAT — so an unreopenable target // is not masked by a transient court_busy on either gate, EXCEPT in the // same accepted race the requeue path documents: a /decision or // /bulk-score completing a downstream between the entry pre-check's tx // close and CheckCrossCompCourtBusy can still surface court_busy for a // now-unreopenable target (a retry then reports the permanent 409). - if rerr := e.reopenResultPreconditionTx(tx, compID, comp, matchID, h); rerr != nil { + if rerr := e.reopenResultPreconditionTx(tx, compID, comp, matchID, h, fo.Force); rerr != nil { opErr = rerr return nil } - if h.Pool != nil { - if cerr := courtGate(h, h.Pool.Court); cerr != nil { + // Where the reopen lands decides whether it needs the court, so + // it is settled BEFORE the court gate. A pre-read of scheduled + // holds: the cross-competition gate was skipped on its word, so + // this reopen must not become running (a competitor reinstated in + // between simply gets a scheduled match to start the normal way). + targetStatus := state.MatchStatusScheduled + if !landsScheduled { + targetStatus = e.reopenTargetStatusOfHome(tx, compID, matchID, h) + } + if targetStatus == state.MatchStatusRunning { + court := "" + if h.Pool != nil { + court = h.Pool.Court + } else { + court = h.Bracket.Court + } + if cerr := courtGate(h, court); cerr != nil { opErr = cerr return nil } - reopenPoolMatch(h.Pool, reason) + } + if h.Pool != nil { + prior := h.Pool.Decision + fight, single := singleBoutFightOf(h.Pool.SubResults, h.Pool.IpponsA, h.Pool.IpponsB, h.Pool.HansokuA, h.Pool.HansokuB, h.Pool.Encho) + // Who fought a team -DH-/-TB- rep bout is a fact of that bout + // too; a pool match is the only home that names them. + fight.RepPlayerA, fight.RepPlayerB = h.Pool.RepPlayerA, h.Pool.RepPlayerB + reopenPoolMatch(h.Pool, reason, targetStatus) + if single { + h.Pool.IpponsA, h.Pool.IpponsB = fight.IpponsA, fight.IpponsB + h.Pool.HansokuA, h.Pool.HansokuB = fight.HansokuA, fight.HansokuB + h.Pool.Encho = fight.Encho + h.Pool.RepPlayerA, h.Pool.RepPlayerB = fight.RepPlayerA, fight.RepPlayerB + } // SavePoolMatches funnels through the normal save chokepoint, // so standings caches invalidate via the usual version bump. - return h.Save() - } - if cerr := courtGate(h, h.Bracket.Court); cerr != nil { - opErr = cerr + if serr := h.Save(); serr != nil { + return serr + } + // The reopen cleared the decision: a withdrawal it removed + // bars nobody (see ELIGIBILITY on ReopenMatch). + restored = e.restoreIfWithdrawalRemoved(tx, compID, matchID, prior, h.Pool.Decision, nil) return nil } - // A bracket ROUND winner may already be propagated downstream; the - // bronze (3rd-place) match is a sibling with no downstream, so it - // needs no retraction. downstreamFoughtForRound was already verified - // above; retractPropagatedWinner re-checks it as its documented - // check-before-mutate contract — a no-op here — then does the mutation. + // A bracket ROUND winner may already be propagated downstream, + // through any byes it resolved (propagatedDownstreamOf); the bronze + // (3rd-place) match is a sibling with no downstream, so it needs + // no retraction. The precondition above already refused a + // downstream being fought, and one closed with its own result + // unless the operator confirmed it (fo.Force). That confirmed one + // is reopened for re-entry FIRST, exactly as a forced knockout + // correction does, which leaves it an untouched scheduled slot; + // retractPropagatedWinner then re-checks the targets as its + // documented check-before-mutate contract (a no-op here), unwinds + // the byes and does the retraction. + var reopenedDownstream []ReopenedMatch if !h.Bronze { + if fo.Force { + reopenedDownstream = forceReopenDownstreamChain(h.BracketRoot, h.RIdx, h.MIdx, matchID) + } if derr := retractPropagatedWinner(h.BracketRoot, h.RIdx, h.MIdx); derr != nil { opErr = derr return nil } } - reopenBracketMatch(h.Bracket, reason) - return h.Save() + prior := h.Bracket.Decision + fight, single := singleBoutFightOf(h.Bracket.SubResults, h.Bracket.IpponsA, h.Bracket.IpponsB, h.Bracket.HansokuA, h.Bracket.HansokuB, h.Bracket.Encho) + reopenBracketMatch(h.Bracket, reason, targetStatus) + if single { + h.Bracket.IpponsA, h.Bracket.IpponsB = fight.IpponsA, fight.IpponsB + h.Bracket.HansokuA, h.Bracket.HansokuB = fight.HansokuA, fight.HansokuB + h.Bracket.Encho = fight.Encho + } + if serr := h.Save(); serr != nil { + return serr + } + e.restoreForceReopened(tx, compID, reopenedDownstream) + if fo.Reopened != nil { + *fo.Reopened = reopenedDownstream + } + restored = e.restoreIfWithdrawalRemoved(tx, compID, matchID, prior, h.Bracket.Decision, nil) + return nil }) if ferr != nil { return ferr @@ -975,12 +1093,15 @@ func (e *Engine) reopenKachinukiUnderCourtLock(compID string, comp *state.Compet return nil }) if txErr != nil { - return txErr + return nil, txErr } - return opErr + if opErr != nil { + return nil, opErr + } + return restored, nil } -// RequeueBlockerAndReopenKachinuki atomically frees a court and reopens a +// RequeueBlockerAndReopen atomically frees a court and reopens a // kachinuki match onto it: under ONE hold of the court-exclusivity lock it // requeues the blocking match and then reopens the target. A court hosts // matches from ANY competition, and one competition spreads its matches across @@ -999,13 +1120,22 @@ func (e *Engine) reopenKachinukiUnderCourtLock(compID string, comp *state.Compet // without touching anything (mp-gmcg review R1). Without that gate a wrongly // named bystander on a different court would be wiped AND the reopen would then // fail on the court's real occupant, leaving the wipe committed but invisible -// behind a "court busy" response. -func (e *Engine) RequeueBlockerAndReopenKachinuki(targetComp, targetMatch, blockerComp, blockerMatch, reason string) error { - comp, err := e.loadKachinukiComp(targetComp) +// behind a "court busy" response. For the same reason a target that reopens +// to SCHEDULED (see the COURT GATE note on ReopenMatch) is refused before the +// revert with a *ValidationError pointing at the plain reopen: it needs no +// court, so there is nothing to free. +// +// The returned status is the target's, exactly as ReopenMatch +// returns it (see ELIGIBILITY there); the blocker was running, so its requeue +// removes no withdrawal. +func (e *Engine) RequeueBlockerAndReopen(targetComp, targetMatch, blockerComp, blockerMatch, reason string, opts ...ForceOptions) (*domain.CompetitorStatus, error) { + fo := firstForceOptions(opts) + comp, err := e.store.LoadCompetition(targetComp) if err != nil { - return err + return nil, err } - return e.store.WithCourtExclusivityLock(func() error { + var restored *domain.CompetitorStatus + err = e.store.WithCourtExclusivityLock(func() error { if verr := e.requireBlockerHoldsCourt(targetComp, targetMatch, blockerComp, blockerMatch); verr != nil { return verr } @@ -1027,41 +1157,132 @@ func (e *Engine) RequeueBlockerAndReopenKachinuki(targetComp, targetMatch, block // would need the pre-check, revert, and reopen under one target-comp // transaction, which the cross-comp revert (it takes the BLOCKER comp's // lock) cannot provide. - if perr := e.checkTargetReopenable(targetComp, comp, targetMatch); perr != nil { + landsScheduled, perr := e.checkTargetReopenable(targetComp, comp, targetMatch, fo.Force) + if perr != nil { return perr } + // A target that reopens to SCHEDULED (a default win whose barred + // competitor is still barred) takes no court, so requeuing the + // blocker would wipe its live score to free a court nobody needs. + // Refused before the revert, naming the one step that works: the + // plain reopen, which no busy court refuses for such a target. + if landsScheduled { + return validationErrorf("%s goes back to the queue when reopened, not onto the court, so %s does not need to be sent back to the queue. Reopen it directly.", + SentenceCase(e.operatorMatchLabel(targetComp, targetMatch)), e.operatorMatchLabel(blockerComp, blockerMatch)) + } if rerr := e.RevertMatchToQueue(blockerComp, blockerMatch); rerr != nil { return rerr } - return e.reopenKachinukiUnderCourtLock(targetComp, comp, targetMatch, reason) + var oerr error + restored, oerr = e.reopenUnderCourtLock(targetComp, comp, targetMatch, reason, fo, false) + return oerr }) + if err != nil { + return nil, err + } + return restored, nil } // reopenResultPreconditionTx runs the read-only RESULT preconditions a reopen -// requires for one already-located match home: the match must be COMPLETED, and -// its result must not have fed a fought downstream (winner to the next round, or -// a semifinal loser to the bronze) — a started knockout for a bracket round -// (downstreamFoughtForRound), or a started knockout seeded off this pool's -// current finisher for a pool match (checkPoolReopenDownstreamTx). It -// EXCLUDES the court gate (the requeue path frees the court itself; the plain -// reopen checks it separately) and performs NO mutation. checkTargetReopenable -// and reopenKachinukiUnderCourtLock both run it, so a RESULT precondition added to -// one path can't be missed by the other — the drift that reopens the -// wipe-for-nothing hazard (mp-gmcg review). -func (e *Engine) reopenResultPreconditionTx(tx state.StoreTx, compID string, comp *state.Competition, matchID string, h matchHome) error { +// requires for one already-located match home, in this order: +// +// 1. the match must be COMPLETED (ErrReopenNotCompleted); +// 2. it must be one a reopen is FOR: a kachinuki team match, or any match +// decided by a withdrawal OR a default win (domain.IsDefaultWinDecisionStr: +// kiken/kiken-voluntary/kiken-injury, fusenpai, or fusensho -- operator +// ruling 2026-09-24, widened to fusensho by bc-cse so "Clear default win +// and reopen" works on a match-level fusensho too; a fusensho reopen +// restores nobody's eligibility, since recording one never barred anyone +// -- see restoreIfWithdrawalRemoved, scoring_tx.go, which stays scoped to +// domain.IsWithdrawalDecisionStr for exactly that reason). Anything else +// is a *ValidationError (HTTP 400); its sanctioned edit is the correction +// path. This is the reopen's one entry gate, so both doors (ReopenMatch, +// RequeueBlockerAndReopen) apply it, and it reads the STORED decision +// under the tx, never a caller's claim about it; +// 3. for a bracket round, its result must not have fed a downstream it +// cannot unwind (reopenBracketDownstreamCheck), where force is the +// operator's confirmation for a downstream closed with its own result. A +// pool match has no such check: reopening it moves no knockout slot, and +// the write that finishes it answers for any qualifier it moves. +// +// It EXCLUDES the court gate (the requeue path frees the court itself; the +// plain reopen checks it separately) and performs NO mutation. +// checkTargetReopenable and reopenUnderCourtLock both run it, so a RESULT +// precondition added to one path can't be missed by the other — the drift +// that reopens the wipe-for-nothing hazard (mp-gmcg review). +func (e *Engine) reopenResultPreconditionTx(tx state.StoreTx, compID string, comp *state.Competition, matchID string, h matchHome, force bool) error { + var status state.MatchStatus + var decision string if h.Pool != nil { - if h.Pool.Status != state.MatchStatusCompleted { - return ErrReopenNotCompleted - } - return e.checkPoolReopenDownstreamTx(tx, compID, comp, matchID) + status, decision = h.Pool.Status, h.Pool.Decision + } else { + status, decision = h.Bracket.Status, h.Bracket.Decision } - if h.Bracket.Status != state.MatchStatusCompleted { + if status != state.MatchStatusCompleted { return ErrReopenNotCompleted } + if !comp.IsKachinuki() && !domain.IsDefaultWinDecisionStr(decision) { + // bc-cse item 14: no "(correctionReason)" jargon -- that named the + // internal API field, not anything the operator sees on the score + // editor's own correction-reason box. + return validationErrorf("reopen is only for kachinuki team matches and for matches decided by a withdrawal or default win (kiken, kiken-injury, fusenpai, or fusensho); correct other results via the score editor instead") + } + if h.Pool != nil { + // A pool reopen changes no knockout slot: the pool is incomplete + // while the match is open, and an incomplete pool is never + // re-resolved. What the reopen leads to is decided when it is + // finished: that write runs the requalification rule + // (requalifyAfterPoolWrite), which is silent for the same result and + // warns, naming the knockout matches, for a different one. + return nil + } // Bronze is a sibling of Rounds with no downstream, so it can never be - // downstream-fought (matches reopenKachinukiUnderCourtLock's !Bronze). - if !h.Bronze && downstreamFoughtForRound(h.BracketRoot, h.RIdx, h.MIdx) { - return ErrReopenDownstreamFought + // downstream-fought (matches reopenUnderCourtLock's !Bronze). + if h.Bronze { + return nil + } + return reopenBracketDownstreamCheck(h.BracketRoot, h.RIdx, h.MIdx, force) +} + +// reopenBracketDownstreamCheck decides, for a bracket ROUND match being +// reopened, what its already-propagated result means for the matches it fed: +// its bronze when it is a semifinal, and the first next-round match past any +// byes the winner was passed through (propagatedDownstreamOf; the byes themselves +// are unwound by the reopen, never refused, since nobody fought them). THREE +// cases, the terminal one checked FIRST so the operator is never asked to +// confirm a reopen that would then be refused anyway: +// +// - a target that is RUNNING: refused outright with +// *DownstreamKnockoutRunningError (bc-cse; the SAME 409 +// downstream_knockout_running shape the pool-requalification refusal +// already uses). Someone is fighting it now; finish it or send it back to +// the queue, then reopen; +// - a target CLOSED with a result of its own (propagatedDownstream.played, +// the same rule a knockout correction uses): without force, *DownstreamKnockoutPlayedError +// naming it (HTTP 409 downstream_knockout_played), so the operator is told +// the consequence and may proceed; with force it passes, and +// reopenUnderCourtLock reopens it for re-entry before retracting; +// - otherwise (a merely filled, unfought slot): nil. +// +// A target that is SCHEDULED yet carries stray result data, or COMPLETED with +// no result of its own and not in a bye's shape (hand-edited or pre-migration +// files; no write path produces either), is confidently none of these, so it +// passes here and retractPropagatedWinner's backstop refuses it with +// ErrReopenDownstreamFought. +func reopenBracketDownstreamCheck(bracket *state.Bracket, rIdx, mIdx int, force bool) error { + d := propagatedDownstreamOf(bracket, rIdx, mIdx) + var running []ReopenedMatch + for _, t := range []*state.BracketMatch{d.bronze, d.next} { + if t != nil && t.Status == state.MatchStatusRunning { + running = append(running, bracketMatchRef(t)) + } + } + matchID := bracket.Rounds[rIdx][mIdx].ID + if len(running) > 0 { + return &DownstreamKnockoutRunningError{MatchID: matchID, Running: running, Reopening: true} + } + if played := d.played(); len(played) > 0 && !force { + return newDownstreamKnockoutPlayedError(&bracket.Rounds[rIdx][mIdx], played, d.displacedSlot(played, mIdx)) } return nil } @@ -1074,15 +1295,25 @@ func (e *Engine) reopenResultPreconditionTx(tx state.StoreTx, compID string, com // path a target that can't reopen doesn't cost the blocker its score) and the // plain-reopen entry (before CheckCrossCompCourtBusy, so an unreopenable target // reports that rather than a transient court_busy). The preconditions live in -// reopenResultPreconditionTx, which reopenKachinukiUnderCourtLock also runs, so +// reopenResultPreconditionTx, which reopenUnderCourtLock also runs, so // the pre-check and the reopen share ONE rule set and cannot DRIFT — though a // /decision or /bulk-score racing the gap can still make the reopen reject after // the pre-check passed (the accepted window the requeue comment documents). -func (e *Engine) checkTargetReopenable(compID string, comp *state.Competition, matchID string) error { +// +// It also reports whether the reopen lands SCHEDULED (reopenTargetStatus: a +// match-level fusensho whose decisionBy side is still barred), read in the same +// tx. Such a reopen takes no court, so reopenUnderCourtLock skips both court +// gates for it and holds it to scheduled even if the bar lifts in between (see +// the COURT GATE note on ReopenMatch). +func (e *Engine) checkTargetReopenable(compID string, comp *state.Competition, matchID string, force bool) (bool, error) { var checkErr error + landsScheduled := false txErr := e.store.WithTransaction(compID, func(tx state.StoreTx) error { found, ferr := findMatchHome(tx, compID, matchID, func(h matchHome) error { - checkErr = e.reopenResultPreconditionTx(tx, compID, comp, matchID, h) + checkErr = e.reopenResultPreconditionTx(tx, compID, comp, matchID, h, force) + if checkErr == nil { + landsScheduled = e.reopenTargetStatusOfHome(tx, compID, matchID, h) == state.MatchStatusScheduled + } return nil }) if ferr != nil { @@ -1094,13 +1325,13 @@ func (e *Engine) checkTargetReopenable(compID string, comp *state.Competition, m return nil }) if txErr != nil { - return txErr + return false, txErr } - return checkErr + return landsScheduled, checkErr } // requireBlockerHoldsCourt verifies the client-named blocker is a RUNNING match -// on targetMatch's court, the precondition RequeueBlockerAndReopenKachinuki's +// on targetMatch's court, the precondition RequeueBlockerAndReopen's // destructive requeue depends on (mp-gmcg review R1). It rejects — WITHOUT // touching the blocker — when the target has no court, when the blocker sits on // a different court (the bystander-wipe case), or when the blocker is not @@ -1121,14 +1352,17 @@ func (e *Engine) requireBlockerHoldsCourt(targetComp, targetMatch, blockerComp, return err } if targetCourt == "" { - return validationErrorf("match %s has no court assigned, so no match can be blocking its court", targetMatch) + return validationErrorf("%s has no court assigned, so no match can be blocking it.", SentenceCase(e.operatorMatchLabel(targetComp, targetMatch))) } blockerCourt, err := e.lookupMatchCourt(blockerComp, blockerMatch) if err != nil { return err } if blockerCourt != targetCourt { - return validationErrorf("match %s is on court %q, not %s's court %q; requeuing it would not free the court", blockerMatch, blockerCourt, targetMatch, targetCourt) + // The second label is mid-sentence ("not %s's Shiaijo"), so only the + // first (sentence-initial) one is case-corrected. + return validationErrorf("%s is on Shiaijo %s, not %s's Shiaijo %s. Requeuing it would not free the court.", + SentenceCase(e.operatorMatchLabel(blockerComp, blockerMatch)), blockerCourt, e.operatorMatchLabel(targetComp, targetMatch), targetCourt) } status, found, err := e.store.MatchStatusByID(blockerComp, blockerMatch) if err != nil { @@ -1140,10 +1374,12 @@ func (e *Engine) requireBlockerHoldsCourt(targetComp, targetMatch, blockerComp, // between the two cached reads (DeleteCompetition takes the per-comp // lock, not the court lock we hold) — a clearer message than the // "not running (status \"\")" the check below would otherwise give. - return notFoundErrorf("blocker match %s not found in competition %s", blockerMatch, blockerComp) + // No label to resolve: the match is gone, so there is nothing to name + // it by beyond "it". + return notFoundErrorf("the blocking match could not be found; it may have been deleted. Retry the reopen.") } if status != state.MatchStatusRunning { - return validationErrorf("match %s is not running (status %q); it is not blocking the court, retry the reopen", blockerMatch, status) + return validationErrorf("%s is not running now, so it is not blocking the court. Retry the reopen.", SentenceCase(e.operatorMatchLabel(blockerComp, blockerMatch))) } return nil } @@ -1159,7 +1395,7 @@ func (e *Engine) requireBlockerHoldsCourt(targetComp, targetMatch, blockerComp, // still refused, so a non-numbered sub is never touched — daihyosen does not // exist in kachinuki and is not involved. // -// Walks the same three match homes as ReopenKachinukiMatch (pool → bracket +// Walks the same three match homes as ReopenMatch (pool → bracket // rounds → bronze) via the shared findMatchHome visitor (review F6), so a // fourth match home or a lookup-order change can't be forgotten in just one // of the two. Returns the updated match for the caller to broadcast. No @@ -1267,11 +1503,9 @@ type matchHome struct { Save func() error // PoolMatches / BracketRoot are the FULL slices findMatchHome already // loaded under this tx, exposed so a visitor's court check can reuse them - // instead of re-loading (mp-gmcg review E4). PoolMatches is nil when the - // pool load errored (findMatchHome swallows that error and tries the - // bracket), so a court check treating nil as "reload" surfaces the failure - // rather than skipping pool matches. BracketRoot is nil for a pool home - // (findMatchHome returns before loading the bracket). + // instead of re-loading (mp-gmcg review E4). A court check treats a nil + // PoolMatches as "reload", so it never skips pool matches. BracketRoot is + // nil for a pool home (findMatchHome returns before loading the bracket). PoolMatches []state.MatchResult } @@ -1279,28 +1513,26 @@ type matchHome struct { // bracket rounds, then the bronze (3rd-place) match — in that FIXED order, and // invokes visit for the owning home. It is the MUTATING, in-transaction walk, // and the engine's only copy of it, so a new caller cannot drop the bronze -// branch or the pool-load-error swallow by hand-copying the ~60-line skeleton -// (mp-gmcg review F6). found=false with a nil error means the ID is in neither -// store. A pool LOAD error is swallowed and the walk still tries the bracket -// (matching the open-coded copies this replaced); a bracket load error is -// returned. visit's own error propagates. CAVEAT (mp-gmcg review): swallowing the -// pool load error turns a pool-matches.csv I/O fault into found=false for a -// pool-home ID — a 404 "not found", not a 500 — so ReopenKachinukiMatch's -// checkTargetReopenable pre-check (now the first gate) reports "not found" during -// an FS fault where the pre-reorder cross-comp gate propagated the I/O error. -// Accepted as a transient-and-retried trade; propagating here instead would -// change every findMatchHome caller. +// branch by hand-copying the ~60-line skeleton (mp-gmcg review F6). +// found=false with a nil error means the ID is in neither store. A LOAD error +// from either store is returned as it is, never read as "not in this store": a +// missing file already loads as empty, so an error is a file that exists and +// cannot be read, and swallowing the pool one answered a corrupt +// pool-matches.csv with a 404 "not found" instead of the corrupt-file 500 that +// names the file. Every caller already returns the error. visit's own error +// propagates. func findMatchHome(tx state.StoreTx, compID, matchID string, visit func(matchHome) error) (bool, error) { poolMatches, lerr := tx.LoadPoolMatches(compID) - if lerr == nil { - for i := range poolMatches { - if poolMatches[i].ID == matchID { - return true, visit(matchHome{ - Pool: &poolMatches[i], - PoolMatches: poolMatches, - Save: func() error { return tx.SavePoolMatches(compID, poolMatches) }, - }) - } + if lerr != nil { + return false, lerr + } + for i := range poolMatches { + if poolMatches[i].ID == matchID { + return true, visit(matchHome{ + Pool: &poolMatches[i], + PoolMatches: poolMatches, + Save: func() error { return tx.SavePoolMatches(compID, poolMatches) }, + }) } } @@ -1337,48 +1569,6 @@ func findMatchHome(tx state.StoreTx, compID, matchID string, visit func(matchHom return false, nil } -// checkPoolReopenDownstreamTx is the pool-match twin of the bracket branch's -// retractPropagatedWinner guard (mp-gmcg). Reopening a pool match flips it back -// to running, which drops it from the standings the knockout bracket was seeded -// from. If this pool's CURRENT qualifying finishers already sit in a started -// (running/completed) knockout match, re-ending the reopened match with a -// different result would strand a displaced finisher there. It mirrors the -// existing mp-e2k1 score-path guard exactly (top-N pool finishers vs -// hasStartedKnockoutMatchTx), and only fires for mixed competitions — the sole -// format that feeds pool finishers into a bracket; league/swiss have no bracket -// to desync. Standings are read BEFORE reopenPoolMatch mutates, so they reflect -// the finishers actually committed to the bracket. -func (e *Engine) checkPoolReopenDownstreamTx(tx state.StoreTx, compID string, comp *state.Competition, matchID string) error { - if comp == nil || comp.Format != state.CompFormatMixed { - return nil - } - pn, ok := poolNameFromMatchID(matchID) - if !ok || !IsPoolMatchID(matchID) { - return nil - } - standings, err := e.computeStandingsFrom(tx, compID) - if err != nil { - return fmt.Errorf("reopen: pre-reopen standings for pool %q: %w", pn, err) - } - ps := standings[pn] - winners := comp.EffectivePoolWinners() - topN := make([]string, 0, winners) - for i := 0; i < winners && i < len(ps); i++ { - topN = append(topN, ps[i].Player.Name) - } - if len(topN) == 0 { - return nil - } - blockingFinisher, _, herr := e.hasStartedKnockoutMatchTx(tx, compID, topN) - if herr != nil { - return fmt.Errorf("reopen: checking started knockout matches: %w", herr) - } - if blockingFinisher != "" { - return ErrReopenDownstreamFought - } - return nil -} - // reopenPoolMatch is reopenBracketMatch's twin for a pool/league match. Same // rule, same field set: MatchResult and BracketMatch both carry the // scoreline as IpponsA/IpponsB (+ HansokuA/B) and, since bc-brid, the winner @@ -1389,11 +1579,86 @@ func (e *Engine) checkPoolReopenDownstreamTx(tx state.StoreTx, compID string, co // See reopenBracketMatch for why each of these is verdict rather than bout // record. // -// RepPlayerA/B name who fought a pool daihyosen. That bout's own record lives -// in SubResults like any other; these two fields are the discarded verdict's -// nomination for it, so they go with the rest of the verdict. -func reopenPoolMatch(m *state.MatchResult, reason string) { - m.Status = state.MatchStatusRunning +// RepPlayerA/B name who fought a pool daihyosen. On a team encounter that +// bout's own record lives in SubResults like any other, and these two fields +// are the discarded verdict's nomination for it, so they go with the rest of +// the verdict. On a match that IS the rep bout (a -DH-/-TB- match with no bout +// rows) they are who fought it, which reopenUnderCourtLock puts back +// (singleBoutFight) with the letters struck. +// +// The reopen stamps ModifiedAt with the server's now, the same revert fence +// requeueBracketMatch sets (mp-y3nk): a write stamped before the reopen, such +// as an offline-queued Save correction replayed afterwards, is older than the +// reopen and is refused as superseded instead of completing the match again. +// reopenTargetStatus decides RUNNING vs SCHEDULED for a reopen (bc-cse item +// 9). Every reopen but one goes to RUNNING, as it always has: the operator +// tapped Reopen to fight on. The exception is a match-level FUSENSHO +// (domain.DecisionFusensho) recorded to close a barred competitor's +// remaining match with a default win for the opponent -- fusensho itself +// never writes a CompetitorStatus (recordIneligibilityFromDecision only +// fires for domain.IsWithdrawalDecisionStr, which deliberately excludes it; +// see ReopenMatch's ELIGIBILITY doc), so the bar this match's decisionBy +// side carries, if any, was recorded by an EARLIER withdrawal elsewhere and +// this reopen restores nothing. If that earlier bar still holds, the +// competitor still cannot fight: reopening to RUNNING would only let +// StartMatchTx refuse every later write on this match with no way forward, +// so it goes to SCHEDULED instead, which re-shows the barred-match notice +// (annotateIneligibleSides) exactly as it did before the fusensho was ever +// recorded, and, taking no court, is not refused for a busy one (see the +// COURT GATE note on ReopenMatch). Once the competitor is reinstated or the +// earlier withdrawal is itself cleared, the SAME reopen goes to RUNNING like +// any other. +// +// decisionBy names the WITHDRAWING side (recordDecisionTx's own convention: +// "aka" -> sideA lost, "shiro" -> sideB lost), so it is read against +// BarredSides' A/B return the same way. statuses/matchID/sideAID/sideBID are +// the reopened match's OWN identity, read before reopenPoolMatch/ +// reopenBracketMatch clear Decision/DecisionBy -- BarredSides' own +// undo-path exemption (a status recorded BY matchID itself never bars it) +// does not apply here, since fusensho never recorded one. +func reopenTargetStatus(statuses map[string]domain.CompetitorStatus, matchID, decision, decisionBy, sideAID, sideBID string) state.MatchStatus { + if decision != string(domain.DecisionFusensho) { + return state.MatchStatusRunning + } + a, b := BarredSides(statuses, matchID, sideAID, sideBID) + stillBarred := (decisionBy == "aka" && a != nil) || (decisionBy == "shiro" && b != nil) + if stillBarred { + return state.MatchStatusScheduled + } + return state.MatchStatusRunning +} + +// reopenTargetStatusTx is reopenTargetStatus's tx-aware wrapper: it loads +// CompetitorStatus LAZILY, only when decision is a fusensho (the one case +// reopenTargetStatus needs it for), through the live transaction handle. A +// load failure is logged rather than discarded and defaults to RUNNING +// (today's behaviour), since a read the operator cannot diagnose must never +// silently trade one stuck state for another. +func (e *Engine) reopenTargetStatusTx(tx state.StoreTx, compID, matchID, decision, decisionBy, sideAID, sideBID string) state.MatchStatus { + if decision != string(domain.DecisionFusensho) { + return state.MatchStatusRunning + } + statuses, err := tx.LoadCompetitorStatus(compID) + if err != nil { + log.Printf("engine: ReopenMatch: LoadCompetitorStatus compId=%s matchId=%s: %v (defaulting to running)", compID, matchID, err) + return state.MatchStatusRunning + } + return reopenTargetStatus(statuses, matchID, decision, decisionBy, sideAID, sideBID) +} + +// reopenTargetStatusOfHome is reopenTargetStatusTx for a located match home, +// reading the stored result's own decision and sides, so the read-only +// pre-check (checkTargetReopenable) and the reopen itself +// (reopenUnderCourtLock) ask the one question the same way. +func (e *Engine) reopenTargetStatusOfHome(tx state.StoreTx, compID, matchID string, h matchHome) state.MatchStatus { + if h.Pool != nil { + return e.reopenTargetStatusTx(tx, compID, matchID, h.Pool.Decision, h.Pool.DecisionBy, h.Pool.SideAID, h.Pool.SideBID) + } + return e.reopenTargetStatusTx(tx, compID, matchID, h.Bracket.Decision, h.Bracket.DecisionBy, h.Bracket.SideAID, h.Bracket.SideBID) +} + +func reopenPoolMatch(m *state.MatchResult, reason string, targetStatus state.MatchStatus) { + m.Status = targetStatus m.Winner = "" m.WinnerID = "" m.IpponsA = nil @@ -1409,6 +1674,7 @@ func reopenPoolMatch(m *state.MatchResult, reason string) { m.RepPlayerB = "" m.CorrectionReason = reason m.ReopenPending = reopenPending(reason) + m.ModifiedAt = time.Now().UnixMilli() } // reopenBracketMatch discards the ENCOUNTER-LEVEL VERDICT and keeps the BOUT @@ -1447,8 +1713,8 @@ func reopenPoolMatch(m *state.MatchResult, reason string) { // fences any stale pre-completion offline write via ApplyByTimestamp and // never blocks the re-End. If you add a match-level verdict field a // kachinuki result CAN carry, clear it here too. -func reopenBracketMatch(bm *state.BracketMatch, reason string) { - bm.Status = state.MatchStatusRunning +func reopenBracketMatch(bm *state.BracketMatch, reason string, targetStatus state.MatchStatus) { + bm.Status = targetStatus bm.Winner = "" bm.WinnerID = "" // bc-brid: the verdict's id half, cleared with the name. bm.IpponsA = nil @@ -1463,12 +1729,15 @@ func reopenBracketMatch(bm *state.BracketMatch, reason string) { bm.ResultSource = "" bm.CorrectionReason = reason bm.ReopenPending = reopenPending(reason) + // Revert fence, as reopenPoolMatch's doc explains: every reopen door + // (ReopenMatch, RequeueBlockerAndReopen, forceReopenDownstreamChain) ends + // here, so a write stamped before the reopen is refused as superseded. + bm.ModifiedAt = time.Now().UnixMilli() } -// reopenPending reports whether a reopen still OWES an audit justification: -// true when no reason was supplied (the score path collects it on the next -// completion), false when the operator already gave one so this reopen is -// justified as it happens. One helper rather than a `reason == ""` test +// reopenPending reports whether a reopen was made without an audit reason: +// true when none was supplied, false when the operator gave one. Nothing is +// refused for it (see state.MatchResult.ReopenPending). One helper rather than a `reason == ""` test // inlined at each of its two call sites (reopenPoolMatch, reopenBracketMatch // — the latter already covers both the bracket-round and bronze homes), so a // third caller can't drift from the rule — the same reason the reopen keeps its @@ -1477,13 +1746,56 @@ func reopenPending(reason string) bool { return reason == "" } +// singleBoutFight is what a reopen keeps at MATCH level of a match that is a +// single bout: an individual match, or a team competition's -DH-/-TB- rep +// bout. On such a match the match-level scoreline is not a verdict ABOUT +// bouts (reopenBracketMatch's reason for clearing it), it IS the bout, so the +// letters actually struck, the fouls and the overtime are facts of the fight +// the reopen promises to keep. RepPlayerA/B name who fought a team +// competition's rep bout; only a pool match carries them (a bracket match has +// no rep nomination), so the pool branch fills them and the bracket branch +// leaves them empty. +type singleBoutFight struct { + IpponsA, IpponsB []string + HansokuA, HansokuB int + Encho *state.EnchoMetadata + RepPlayerA, RepPlayerB string +} + +// singleBoutFightOf reports whether a match about to be reopened is a single +// bout (it carries no bout rows) and, if so, what of its fight survives the +// reopen. The scoreline goes through struckIppons, which is the whole point: +// a withdrawal's scoreline is the winner's default-win maru plus the letters +// the withdrawing side had struck (preserveLoserScore, FIK Art. 32), so the +// maru (the discarded verdict) goes and the struck letters stay. The winner's +// own letters from before the withdrawal are NOT recoverable here: the +// withdrawal replaced them with the maru when it was recorded. +// +// Called by reopenUnderCourtLock for the match being reopened ONLY. The +// primitives reopenPoolMatch/reopenBracketMatch stay "discard the verdict" +// and nothing more, because forceReopenDownstreamChain also uses +// reopenBracketMatch on a DOWNSTREAM match whose sides were just repainted, +// where every letter belongs to a pairing no longer in it. +func singleBoutFightOf(subs []state.SubMatchResult, ipponsA, ipponsB []string, hansokuA, hansokuB int, encho *state.EnchoMetadata) (singleBoutFight, bool) { + if len(subs) > 0 { + return singleBoutFight{}, false + } + return singleBoutFight{ + IpponsA: struckIppons(ipponsA), + IpponsB: struckIppons(ipponsB), + HansokuA: hansokuA, + HansokuB: hansokuB, + Encho: encho, + }, true +} + // downstreamTargets returns the bracket matches this result was propagated into: // next (the next-round slot, which received the WINNER) and bronze (the 3rd-place // match, which received the semifinal LOSER, and only when this is a semifinal // that feeds it). Either may be nil. This is the SINGLE derivation of downstream -// LOCATION on the REOPEN side, so the read-only downstreamFoughtForRound predicate -// and the destructive retractPropagatedWinner mutation cannot drift on WHERE a -// result went — the drift that would split "check passes" from "mutation misses" +// LOCATION on the REOPEN side (propagatedDownstreamOf composes it hop by hop), so the +// read-only reopenBracketDownstreamCheck and the destructive +// retractPropagatedWinner mutation cannot drift on WHERE a result went — the drift that would split "check passes" from "mutation misses" // and wipe a blocker's live score for a reopen that then fails (mp-gmcg review). // propagateBracketWinner (scoring.go) independently encodes the same slot rule // (the mIdx/2 next-round index and the bronze-feeding round index), so a @@ -1500,31 +1812,218 @@ func downstreamTargets(bracket *state.Bracket, rIdx, mIdx int) (bronze, next *st return bronze, next } -// downstreamFoughtForRound reports whether the match at bracket round rIdx / slot -// mIdx has a downstream (next-round match, or the bronze it feeds) that is -// already started or scored — the read-only predicate that makes reopening it -// unsafe. It and the destructive retractPropagatedWinner both consume -// downstreamTargets, so they key on ONE definition of both WHERE the winner went -// and WHAT "downstream fought" means (mp-gmcg review). -func downstreamFoughtForRound(bracket *state.Bracket, rIdx, mIdx int) bool { - bronze, next := downstreamTargets(bracket, rIdx, mIdx) - return (bronze != nil && bracketMatchStartedOrScored(bronze)) || - (next != nil && bracketMatchStartedOrScored(next)) +// bracketPos is a bracket ROUND match's coordinates in Bracket.Rounds. +type bracketPos struct{ R, M int } + +// resolvedByByeFrom reports whether bm is a match propagateBracketWinner's bye +// auto-resolution closed off the winner fed into it from slot feedM: completed, +// the fed side holding a competitor, the other side the empty bye, a winner, +// and no result of its own (bracketMatchCarriesOwnResult). That is the whole of +// what the auto-resolution writes (Winner, WinnerID, Status) onto a match +// generation left completed with no winner (buildBracketFromDraw's latent-bye +// pass: a feeder placeholder against an empty side), so nobody fought it and +// unresolveBye can take it back exactly. +func resolvedByByeFrom(bm *state.BracketMatch, feedM int) bool { + fed, other := bm.SideB, bm.SideA + if feedM%2 == 0 { + fed, other = bm.SideA, bm.SideB + } + return bm.Status == state.MatchStatusCompleted && + bm.Winner != "" && + other == "" && + !isUnresolvedBracketSide(fed) && + !bracketMatchCarriesOwnResult(bm) +} + +// unresolveBye returns a match resolvedByByeFrom accepts to the shape +// generation gave it, once the caller has put its fed side back to the +// feeder's placeholder: still completed, with no winner. Completed is +// deliberate, not an oversight. It is what the latent-bye pass writes so the +// match is never counted as one to play, and a SCHEDULED one would be: the +// queue numbers every scheduled match on a court (annotateBracketQueuePositions), +// so a hidden bye would push "N before yours" up by one. The feeder's next +// winner re-resolves it through propagateBracketWinner exactly as the first +// one did. +func unresolveBye(bm *state.BracketMatch) { + bm.Winner = "" + bm.WinnerID = "" +} + +// propagatedDownstream is everything the result of a bracket ROUND match was +// propagated into, which is what each door that takes that result back or +// changes it answers for: bronze is the 3rd-place match this semifinal's loser +// went to (nil otherwise); byes are the matches a bye resolved off the winner, +// nearest first, each passing the winner one round further; next is the first +// match past them (nil past the final), fed by the match at feed (the last +// bye, or the match itself when there is none). +// +// A bye passes a winner on without being fought, so it is never the match a +// door answers to: next is, the first one a person could have fought. A +// REOPEN unwinds the byes (retractPropagatedWinner, and retractIntoUntouched +// for a match a correction reopened) rather than being refused by them: +// there is nothing to finish, requeue or confirm on a match nobody +// fought, and refusing left no way at all to remove a wrongly recorded +// withdrawal whose winner went through one, since a correction keeps the +// withdrawal (KeepsWithdrawalRuling). A CORRECTION re-resolves them with its +// new winner (propagateBracketWinner), and without looking past them it +// repainted a played match beyond a bye with no warning at all, the very +// defect its guard exists for. +// +// A bye never feeds the bronze: the empty side it beat is its loser, and +// propagateBracketWinner feeds no empty loser. So only the match's own bronze +// is followed, never a bye's. +type propagatedDownstream struct { + bronze *state.BracketMatch + byes []bracketPos + feed bracketPos + next *state.BracketMatch +} + +// propagatedDownstreamOf locates the propagatedDownstream of the ROUND match +// at (rIdx, mIdx), off downstreamTargets, the one owner of where a winner goes. +func propagatedDownstreamOf(bracket *state.Bracket, rIdx, mIdx int) propagatedDownstream { + bronze, _ := downstreamTargets(bracket, rIdx, mIdx) + d := propagatedDownstream{bronze: bronze, feed: bracketPos{rIdx, mIdx}} + for { + _, next := downstreamTargets(bracket, d.feed.R, d.feed.M) + if next == nil || !resolvedByByeFrom(next, d.feed.M) { + d.next = next + return d + } + d.feed = bracketPos{d.feed.R + 1, d.feed.M / 2} + d.byes = append(d.byes, d.feed) + } +} + +// played returns the matches ONE HOP down that the result reached and that +// are closed with a result of their own (bracketMatchCarriesOwnResult): next, +// and for a semifinal the bronze it also feeds, bronze first. Nil when +// neither is closed. Every door that would displace someone from a played +// match names these (reopenBracketDownstreamCheck, +// guardDownstreamKnockoutCorrection, guardOverrideDownstreamKnockoutCorrection) +// and forceReopenDownstreamChain reopens exactly these, so the refusal and the +// confirmation cannot disagree. +// +// ONE HOP, not the whole chain (operator ruling 2026-09-19): "if a correction +// is applied then that match is completed and reopens the next one, if that +// one is also completed". The deeper rounds are not this write's business and +// are not silently unwound behind one confirmation. They are reached in their +// own turn: once the operator re-fights the reopened match and enters THAT +// result, the write propagates a round further, meets this same check against +// the round after it, and asks again. One decision per round, each one the +// operator's, instead of a single dialog quietly clearing three matches. A bye +// is not a round anyone decided, so the hop is counted past it. +func (d propagatedDownstream) played() []*state.BracketMatch { + var blocking []*state.BracketMatch + // Bronze first: it is the slot the operator forgets, and naming it first + // keeps the order stable for the dialog and for the tests. + if d.bronze != nil && bracketMatchCarriesOwnResult(d.bronze) { + blocking = append(blocking, d.bronze) + } + if d.next != nil && bracketMatchCarriesOwnResult(d.next) { + blocking = append(blocking, d.next) + } + return blocking +} + +// displacedSlot is the feeder position newDownstreamKnockoutPlayedError reads +// the displaced competitor's slot by, for blocking = d.played(): the match +// itself (at mIdx) for its own bronze, and feed for next, which past a bye is +// the last bye rather than the match. +func (d propagatedDownstream) displacedSlot(blocking []*state.BracketMatch, mIdx int) int { + if blocking[0] == d.bronze { + return mIdx + } + return d.feed.M } // retractPropagatedWinner undoes what propagateBracketWinner did for the -// match at (rIdx, mIdx): the next round's slot returns to its "Winner of -// rX-mY" placeholder and, for a semifinal feeding a bronze match, the -// bronze slot returns to empty. All downstream targets are CHECKED before -// any is mutated, so a rejection leaves the bracket untouched. A -// downstream match that has started, recorded bouts, or completed (which -// includes a bye auto-resolution off this match's winner) rejects the -// reopen with ErrReopenDownstreamFought. +// match at (rIdx, mIdx): every bye resolved off its winner is unresolved +// (its fed side back to the feeder's "Winner of rX-mY" placeholder, no +// winner), the first match past them gets the same placeholder in the slot +// the winner reached, and, for a semifinal feeding a bronze match, the bronze +// slot returns to empty. The targets are CHECKED before anything is mutated, +// so a rejection leaves the bracket untouched: a bronze or next-round match +// that has started, recorded bouts, or completed rejects the reopen with +// ErrReopenDownstreamFought -- THIS function's own return, not what an operator +// sees through the normal reopen doors, which are refused earlier in the +// transaction by reopenBracketDownstreamCheck (see ErrReopenDownstreamFought's +// own doc comment). Reaching this rejection is therefore the defensive-backstop +// case: the earlier check missed it. func retractPropagatedWinner(bracket *state.Bracket, rIdx, mIdx int) error { - if downstreamFoughtForRound(bracket, rIdx, mIdx) { + d := propagatedDownstreamOf(bracket, rIdx, mIdx) + if (d.bronze != nil && bracketMatchStartedOrScored(d.bronze)) || + (d.next != nil && bracketMatchStartedOrScored(d.next)) { return ErrReopenDownstreamFought } - bronze, next := downstreamTargets(bracket, rIdx, mIdx) + clearPropagatedSlots(bracket, rIdx, mIdx, d.bronze, nil) + d.unwindChain(bracket, rIdx, mIdx) + return nil +} + +// unwindChain is the mutation both retractions share (retractPropagatedWinner +// and retractIntoUntouched): it undoes what propagateBracketWinner wrote along +// the chain past the ROUND match at (rIdx, mIdx). Every bye in d.byes is +// unresolved, nearest first (its fed side back to its feeder's "Winner of ..." +// placeholder, then unresolveBye), and the slot the last of them fed in d.next +// (the match itself when there is no bye) goes back to that feeder's +// placeholder. The bronze is not on the chain, since a bye never feeds one, +// so each caller clears it by its own rule. +func (d propagatedDownstream) unwindChain(bracket *state.Bracket, rIdx, mIdx int) { + feed := bracketPos{rIdx, mIdx} + for _, bye := range d.byes { + bm := &bracket.Rounds[bye.R][bye.M] + clearPropagatedSlots(bracket, feed.R, feed.M, nil, bm) + unresolveBye(bm) + feed = bye + } + clearPropagatedSlots(bracket, feed.R, feed.M, nil, d.next) +} + +// retractIntoUntouched is retractPropagatedWinner for a match a correction +// elsewhere has just reopened (applyRequalification for a pool correction, +// forceReopenDownstreamChain for a knockout one or a reopen): each target the old +// winner reached is cleared back to its feeder placeholder ONLY if nobody has +// touched it since, judged per target rather than all-or-nothing, so an +// untouched final is not left advertising a pairing because the 3rd-place +// match beside it was played. A target that was played is left exactly as it +// is: it is one hop further than this correction reaches, and it gets its own +// warning when the reopened match is fought again and its new result +// propagates (the operator's one-decision-per-round ruling). +// +// The targets are the ones propagatedDownstreamOf names, so the hop is counted +// past any bye the old winner was passed through, as every other door counts +// it: next is the first match past the byes, the one a person could have +// fought. An untouched next has the whole chain unwound (unwindChain): each +// bye unresolved and next's slot back to its placeholder. Judging the bye +// itself instead read it as played (generation completes it, and the +// auto-resolution gives it a winner), so a pool correction left the displaced +// qualifier seated past the bye, in a match that could then be started with +// both sides named. A played next keeps the byes before it as well: they carry +// the winner it shows, and re-fighting the reopened match warns about it +// through them. +func retractIntoUntouched(bracket *state.Bracket, rIdx, mIdx int) { + d := propagatedDownstreamOf(bracket, rIdx, mIdx) + kept := func(t *state.BracketMatch) bool { + if t == nil || !bracketMatchStartedOrScored(t) { + return false + } + log.Printf("engine: bracket match %s keeps the winner propagated into it: it is %s, so the reopened match %s does not retract it", t.ID, t.Status, bracket.Rounds[rIdx][mIdx].ID) + return true + } + if !kept(d.bronze) { + clearPropagatedSlots(bracket, rIdx, mIdx, d.bronze, nil) + } + if !kept(d.next) { + d.unwindChain(bracket, rIdx, mIdx) + } +} + +// clearPropagatedSlots is the mutation half of retractPropagatedWinner, shared +// with retractIntoUntouched: it returns the slot (rIdx, mIdx) fed in each +// given target to what it held before propagateBracketWinner wrote there. A +// nil target is left alone. +func clearPropagatedSlots(bracket *state.Bracket, rIdx, mIdx int, bronze, next *state.BracketMatch) { if bronze != nil { // Mirror propagateBracketWinner's positional assignment: semifinal // mIdx 0 feeds the bronze SideA, mIdx 1 feeds SideB. The id clears @@ -1556,7 +2055,6 @@ func retractPropagatedWinner(bracket *state.Bracket, rIdx, mIdx int) error { next.SideBID = "" } } - return nil } // bracketMatchStartedOrScored reports whether a downstream bracket match @@ -1837,17 +2335,23 @@ func preserveKachinukiMemberIDs(storedByPos map[int]state.SubMatchResult, in *st // so round-scoped lineup resolution prefers the bronze's own stage, // matching the client's derivedBracket.rounds.length). func (e *Engine) findTeamMatch(compID, matchID string) (*state.MatchResult, bool, int, error) { + // A load error is returned, never read as "no such match" (see + // findMatchHome): a nil parent is reserved for an id in neither store. poolMatches, err := e.store.LoadPoolMatches(compID) - if err == nil { - for i := range poolMatches { - if poolMatches[i].ID == matchID { - m := poolMatches[i] - return &m, false, 0, nil - } + if err != nil { + return nil, false, 0, err + } + for i := range poolMatches { + if poolMatches[i].ID == matchID { + m := poolMatches[i] + return &m, false, 0, nil } } bracket, err := e.store.LoadBracket(compID) - if err == nil && bracket != nil { + if err != nil { + return nil, false, 0, err + } + if bracket != nil { for rIdx, round := range bracket.Rounds { for _, bm := range round { if bm.ID == matchID { @@ -1938,52 +2442,16 @@ func (e *Engine) kachinukiRemainingRoster(compID, matchID string, comp *state.Co retiredA, retiredB := RetiredPlayersFromBoutLog(parent.SubResults, parent.SideA, parent.SideB) // Attempt lineup-based roster resolution. - lineups, err := e.store.LoadTeamLineups(compID) - if err != nil { - log.Printf("engine.kachinukiRemainingRoster compId=%s matchId=%s: lineup load error: %v; falling back to bout-log-only", compID, matchID, err) - lineups = nil - } - - // The lineup editor keys lineups by the team PARTICIPANT ID - // (player.id, a UUID) while match sides carry the team display NAME, - // so translate each side name to its participant ID and try both keys - // ("match on id OR name"). A participant load failure only degrades - // the lookup to name-only; the bout-log fallback below still applies. - var participants []domain.Player - if len(lineups) > 0 { - participants, err = e.store.LoadParticipants(compID, comp.EffectiveWithZekkenName()) - if err != nil { - log.Printf("engine.kachinukiRemainingRoster compId=%s matchId=%s: participant load error: %v; lineup lookup degrades to name-only", compID, matchID, err) - participants = nil - } - } - teamKeys := func(teamName string) []string { - // Participant ID FIRST, then the display name. The lineup editor's - // current storage key is the participant ID, so an id-keyed lineup - // must win a same-round tie over a legacy name-keyed one: - // FindBestLineupAny resolves same-tier ties by slice order. The name - // stays as a fallback for lineups saved under it (older data, or a - // team name that is not a participant id). Mirrors the id-first order - // in kachinuki_export.go's teamKeys. - var keys []string - for _, p := range participants { - if p.Name == teamName && p.ID != "" && p.ID != teamName { - keys = append(keys, p.ID) - } - } - return append(keys, teamName) - } + lineupFor := e.lineupInForce(compID, matchID, comp, roundIdx) resolveRoster := func(teamName string, retired RetiredMemberSet) ([]kachinukiFighter, bool) { - if lineups != nil { - if lineup, found := state.FindBestLineupAny(lineups, teamKeys(teamName), matchID, roundIdx); found { - full := lineup.OrderedMembers(comp.TeamSize) - fighters := make([]kachinukiFighter, len(full)) - for i, slot := range full { - fighters[i] = kachinukiFighter{Name: slot.Name, MemberID: slot.MemberID} - } - return filterRemainingFighters(fighters, retired), true + if lineup, found := lineupFor(teamName); found { + full := lineup.OrderedMembers(comp.TeamSize) + fighters := make([]kachinukiFighter, len(full)) + for i, slot := range full { + fighters[i] = kachinukiFighter{Name: slot.Name, MemberID: slot.MemberID} } + return filterRemainingFighters(fighters, retired), true } // Preserve first-appearance order from the bout log: AdvanceKachinuki // treats this slice as an ordered queue (index 0 is the next fighter @@ -2033,3 +2501,53 @@ func (e *Engine) kachinukiRemainingRoster(compID, matchID string, comp *state.Co remainingB, foundB := resolveRoster(parent.SideB, retiredB) return remainingA, remainingB, foundA || foundB } + +// lineupInForce loads a competition's saved lineups once and returns the +// resolver for "which lineup is in force for this side of matchID": a +// match-scoped lineup first, else the round-scoped one for roundIdx, per +// state.FindBestLineupAny's tiers. The resolver answers false when the side +// has no saved lineup, including when the lineups could not be loaded (the +// error is logged under the name of its one caller, kachinukiRemainingRoster). +func (e *Engine) lineupInForce(compID, matchID string, comp *state.Competition, roundIdx int) func(teamName string) (domain.TeamLineup, bool) { + lineups, err := e.store.LoadTeamLineups(compID) + if err != nil { + log.Printf("engine.kachinukiRemainingRoster compId=%s matchId=%s: lineup load error: %v; resolving no lineup", compID, matchID, err) + lineups = nil + } + + // The lineup editor keys lineups by the team PARTICIPANT ID + // (player.id, a UUID) while match sides carry the team display NAME, + // so translate each side name to its participant ID and try both keys + // ("match on id OR name"). A participant load failure only degrades + // the lookup to name-only. + var participants []domain.Player + if len(lineups) > 0 { + participants, err = e.store.LoadParticipants(compID, comp.EffectiveWithZekkenName()) + if err != nil { + log.Printf("engine.kachinukiRemainingRoster compId=%s matchId=%s: participant load error: %v; lineup lookup degrades to name-only", compID, matchID, err) + participants = nil + } + } + teamKeys := func(teamName string) []string { + // Participant ID FIRST, then the display name. The lineup editor's + // current storage key is the participant ID, so an id-keyed lineup + // must win a same-round tie over a legacy name-keyed one: + // FindBestLineupAny resolves same-tier ties by slice order. The name + // stays as a fallback for lineups saved under it (older data, or a + // team name that is not a participant id). Mirrors the id-first order + // in kachinuki_export.go's teamKeys. + var keys []string + for _, p := range participants { + if p.Name == teamName && p.ID != "" && p.ID != teamName { + keys = append(keys, p.ID) + } + } + return append(keys, teamName) + } + return func(teamName string) (domain.TeamLineup, bool) { + if lineups == nil { + return domain.TeamLineup{}, false + } + return state.FindBestLineupAny(lineups, teamKeys(teamName), matchID, roundIdx) + } +} diff --git a/internal/engine/kachinuki_export.go b/internal/engine/kachinuki_export.go index b26980d81..389cb5a03 100644 --- a/internal/engine/kachinuki_export.go +++ b/internal/engine/kachinuki_export.go @@ -347,7 +347,7 @@ func (e *Engine) buildKachinukiPositionMap(compID string, comp *state.Competitio if playerName == "" { continue } - index(playerName, formatPositionLabel(pos)) + index(playerName, pos.Label()) } // Every position held by id is indexed under the id as well, so a // nameless fighter (bc-dnst) still resolves; resolveKachinukiPosition @@ -371,7 +371,7 @@ func (e *Engine) buildKachinukiPositionMap(compID string, comp *state.Competitio continue } indexedIDs[memberID] = struct{}{} - index(memberKey(memberID), formatPositionLabel(pos)) + index(memberKey(memberID), pos.Label()) } } return out @@ -401,18 +401,3 @@ func resolveKachinukiBoutPosition(positions map[string]string, matchID, team, me } return resolveKachinukiPosition(positions, matchID, team, player) } - -// formatPositionLabel turns a domain.Position wire value into a -// title-cased label suitable for the Excel cell (e.g. "senpo" → "Senpo"). -// Numeric positions ("1", "2", …) pass through unchanged. -func formatPositionLabel(p domain.Position) string { - s := string(p) - if s == "" { - return "" - } - // Numeric positions stay numeric. - if s[0] >= '0' && s[0] <= '9' { - return s - } - return strings.ToUpper(s[:1]) + s[1:] -} diff --git a/internal/engine/kachinuki_export_test.go b/internal/engine/kachinuki_export_test.go index 5442188ff..5c1d66534 100644 --- a/internal/engine/kachinuki_export_test.go +++ b/internal/engine/kachinuki_export_test.go @@ -13,30 +13,6 @@ import ( "gopkg.in/yaml.v3" ) -// TestFormatPositionLabel exercises every named position and the -// numeric/empty fall-through paths. -func TestFormatPositionLabel(t *testing.T) { - tests := []struct { - pos domain.Position - want string - }{ - {domain.PosSenpo, "Senpo"}, - {domain.PosJiho, "Jiho"}, - {domain.PosChuken, "Chuken"}, - {domain.PosFukusho, "Fukusho"}, - {domain.PosTaisho, "Taisho"}, - {"1", "1"}, - {"2", "2"}, - {"", ""}, - {"unknown", "Unknown"}, - } - for _, tc := range tests { - t.Run(string(tc.pos), func(t *testing.T) { - assert.Equal(t, tc.want, formatPositionLabel(tc.pos)) - }) - } -} - // TestLineupKey verifies the composite key is stable and uses the // null-byte separator so a team named "A\x00B" can't collide with // team "A" + player "B\x00anything". diff --git a/internal/engine/kachinuki_reopen_withdrawal_test.go b/internal/engine/kachinuki_reopen_withdrawal_test.go new file mode 100644 index 000000000..b123564a4 --- /dev/null +++ b/internal/engine/kachinuki_reopen_withdrawal_test.go @@ -0,0 +1,124 @@ +package engine + +// Operator ruling 2026-09-24: "Everything should be able to be fixed, in case +// of a wrong entry." Reopening a kachinuki match clears its decision, so +// reopening one a withdrawal ended removes the withdrawal, and the +// eligibility record follows the ruling exactly as on the score path: the +// team the withdrawal barred is eligible again, in the reopen's own +// transaction, and the restored status is returned for the handler to +// broadcast. These pin that on both reopen doors and both branches, and pin +// that a reopen with no withdrawal restores nothing. + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedKachinukiPoolWithdrawal builds a kachinuki pool with Ryu v Tora on +// court A, bout 1 fought, then ends it by `decision` against Ryu (aka) +// through the real decision path. +func seedKachinukiPoolWithdrawal(t *testing.T, compID, decision string) (*Engine, *state.Store) { + t.Helper() + eng, store, _ := setupKachinukiComp(t, compID, 3) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Court: "A", Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{wrBout1("M")}, + }})) + _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "knee", nil, false) + require.NoError(t, err) + require.False(t, wrEligible(t, store, compID, wrTeamAID), "precondition: the withdrawal barred Ryu") + return eng, store +} + +func TestReopenKachinuki_WithdrawalRemovedRestoresEligibility(t *testing.T) { + for _, decision := range []string{"kiken-voluntary", "kiken-injury", "fusenpai"} { + t.Run(decision, func(t *testing.T) { + const compID = "kr-pool" + eng, store := seedKachinukiPoolWithdrawal(t, compID, decision) + + status, err := eng.ReopenMatch(compID, "Pool A-0", "Wrong entry: nobody withdrew") + require.NoError(t, err) + require.NotNil(t, status, "the restore is returned so the handler can broadcast it") + assert.Equal(t, wrTeamAID, status.PlayerID) + assert.True(t, status.Eligible) + assert.True(t, wrEligible(t, store, compID, wrTeamAID), "a removed withdrawal bars nobody") + + m := wrPoolMatch(t, store, compID) + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, "", m.Decision) + require.Len(t, m.SubResults, 1, "the reopen keeps the bout log") + }) + } +} + +// The requeue door runs the same shared body, so it restores too. +func TestRequeueAndReopenKachinuki_WithdrawalRemovedRestoresEligibility(t *testing.T) { + const compID = "kr-requeue" + eng, store := seedKachinukiPoolWithdrawal(t, compID, "kiken-voluntary") + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + ms = append(ms, state.MatchResult{ + ID: "Pool A-1", SideA: wrTeamB, SideAID: wrTeamBID, SideB: wrTeamC, SideBID: wrTeamCID, + Court: "A", Status: state.MatchStatusRunning, + }) + require.NoError(t, store.SavePoolMatches(compID, ms)) + + status, err := eng.RequeueBlockerAndReopen(compID, "Pool A-0", compID, "Pool A-1", "") + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, wrTeamAID, status.PlayerID) + assert.True(t, wrEligible(t, store, compID, wrTeamAID)) +} + +// The bracket branch. +func TestReopenKachinuki_BracketWithdrawalRemovedRestoresEligibility(t *testing.T) { + const compID = "kr-ko" + eng, store, _ := setupKachinukiComp(t, compID, 3) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{ + ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusRunning, + }}}})) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + require.NoError(t, err) + require.False(t, wrEligible(t, store, compID, wrTeamBID), "precondition: the no-show barred Tora") + + status, err := eng.ReopenMatch(compID, "m-r1-0", "") + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, wrTeamBID, status.PlayerID) + assert.True(t, wrEligible(t, store, compID, wrTeamBID)) +} + +// A reopen of a match no withdrawal ended removes none, so it restores +// nothing and returns no status to broadcast. An unrelated ineligibility +// recorded by ANOTHER match stays exactly as it was. +func TestReopenKachinuki_NoWithdrawalRestoresNothing(t *testing.T) { + const compID = "kr-none" + eng, store, _ := setupKachinukiComp(t, compID, 3) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + { + ID: "Pool A-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusCompleted, Winner: wrTeamA, WinnerID: wrTeamAID, + Decision: "kachinuki-exhaustion", + SubResults: []state.SubMatchResult{wrBout1("M")}, + }, + { + ID: "Pool A-1", SideA: wrTeamC, SideAID: wrTeamCID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusRunning, + }, + })) + _, _, err := eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "", nil, false) + require.NoError(t, err) + require.False(t, wrEligible(t, store, compID, wrTeamCID)) + + status, err := eng.ReopenMatch(compID, "Pool A-0", "") + require.NoError(t, err) + assert.Nil(t, status, "nothing was withdrawn, so nothing is restored") + assert.False(t, wrEligible(t, store, compID, wrTeamCID), "another match's withdrawal is untouched") +} diff --git a/internal/engine/kachinuki_test.go b/internal/engine/kachinuki_test.go index ed12d1253..6ac2ca249 100644 --- a/internal/engine/kachinuki_test.go +++ b/internal/engine/kachinuki_test.go @@ -43,6 +43,20 @@ func setupKachinukiComp(t *testing.T, id string, teamSize int, opts ...func(*sta return eng, store, comp } +// reopenErr is ReopenMatch for a test that asserts only the error; +// the restored status is pinned by kachinuki_reopen_withdrawal_test.go. +func reopenErr(eng *Engine, compID, matchID, reason string) error { + _, err := eng.ReopenMatch(compID, matchID, reason) + return err +} + +// requeueReopenErr is RequeueBlockerAndReopen for a test that +// asserts only the error. +func requeueReopenErr(eng *Engine, targetComp, targetMatch, blockerComp, blockerMatch, reason string) error { + _, err := eng.RequeueBlockerAndReopen(targetComp, targetMatch, blockerComp, blockerMatch, reason) + return err +} + // TestKachinukiWinnerAdvances covers the FR-044 happy path: when one // side's player wins a bout, that player stays on and faces the head // of the opposing side's remaining queue. @@ -1752,7 +1766,7 @@ func TestReopenKachinukiMatch(t *testing.T) { eng, store, _ := setupTestEngine(t) require.NoError(t, store.SaveCompetition(&state.Competition{ID: "fixed", TeamSize: 3})) require.NoError(t, store.SavePoolMatches("fixed", []state.MatchResult{completedPool()})) - err := eng.ReopenKachinukiMatch("fixed", "P1-0", "operator error") + err := reopenErr(eng, "fixed", "P1-0", "operator error") var verr *ValidationError require.ErrorAs(t, err, &verr, "non-kachinuki reopen must be a validation error (400)") }) @@ -1760,7 +1774,7 @@ func TestReopenKachinukiMatch(t *testing.T) { t.Run("pool match reopened: running, outcome cleared, bout log kept", func(t *testing.T) { eng, store, _ := setupKachinukiComp(t, "reopen-pool", 3) require.NoError(t, store.SavePoolMatches("reopen-pool", []state.MatchResult{completedPool()})) - require.NoError(t, eng.ReopenKachinukiMatch("reopen-pool", "P1-0", " wrong winner recorded ")) + require.NoError(t, reopenErr(eng, "reopen-pool", "P1-0", " wrong winner recorded ")) matches, err := store.LoadPoolMatches("reopen-pool") require.NoError(t, err) @@ -1780,13 +1794,13 @@ func TestReopenKachinukiMatch(t *testing.T) { m.Status = state.MatchStatusRunning m.Winner = "" require.NoError(t, store.SavePoolMatches("reopen-running", []state.MatchResult{m})) - err := eng.ReopenKachinukiMatch("reopen-running", "P1-0", "operator error") + err := reopenErr(eng, "reopen-running", "P1-0", "operator error") assert.ErrorIs(t, err, ErrReopenNotCompleted) }) t.Run("match not found", func(t *testing.T) { eng, _, _ := setupKachinukiComp(t, "reopen-missing", 3) - err := eng.ReopenKachinukiMatch("reopen-missing", "nope", "operator error") + err := reopenErr(eng, "reopen-missing", "nope", "operator error") var nfErr *NotFoundError assert.ErrorAs(t, err, &nfErr) }) @@ -1813,7 +1827,7 @@ func TestReopenKachinukiMatch(t *testing.T) { ID: "B0", SideA: "WhiteTeam", SideB: "", Status: state.MatchStatusScheduled, }, })) - require.NoError(t, eng.ReopenKachinukiMatch("reopen-semi", "SF0", "taisho bout must be re-fought")) + require.NoError(t, reopenErr(eng, "reopen-semi", "SF0", "taisho bout must be re-fought")) bracket, err := store.LoadBracket("reopen-semi") require.NoError(t, err) @@ -1847,8 +1861,10 @@ func TestReopenKachinukiMatch(t *testing.T) { }, }, })) - err := eng.ReopenKachinukiMatch("reopen-blocked", "SF0", "operator error") - assert.ErrorIs(t, err, ErrReopenDownstreamFought) + err := reopenErr(eng, "reopen-blocked", "SF0", "operator error") + // bc-cse: F0 is RUNNING, so this is DownstreamKnockoutRunningError + // now, not the bare ErrReopenDownstreamFought sentinel. + assert.ErrorIs(t, err, ErrDownstreamKnockoutRunning) bracket, lerr := store.LoadBracket("reopen-blocked") require.NoError(t, lerr) @@ -1870,7 +1886,7 @@ func TestReopenKachinukiMatch(t *testing.T) { }, }, })) - require.NoError(t, eng.ReopenKachinukiMatch("reopen-bronze", "B0", "bronze scored on the wrong sheet")) + require.NoError(t, reopenErr(eng, "reopen-bronze", "B0", "bronze scored on the wrong sheet")) bracket, err := store.LoadBracket("reopen-bronze") require.NoError(t, err) @@ -1928,7 +1944,7 @@ func TestReopenKachinukiMatch_ReopenPending(t *testing.T) { compID := "reopen-pending-pool" eng, store, _ := setupKachinukiComp(t, compID, 3) require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedPool()})) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "P1-0", tc.reason)) + require.NoError(t, reopenErr(eng, compID, "P1-0", tc.reason)) matches, err := store.LoadPoolMatches(compID) require.NoError(t, err) @@ -1940,7 +1956,7 @@ func TestReopenKachinukiMatch_ReopenPending(t *testing.T) { compID := "reopen-pending-bracket" eng, store, _ := setupKachinukiComp(t, compID, 3) require.NoError(t, store.SaveBracket(compID, completedBracket())) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "F0", tc.reason)) + require.NoError(t, reopenErr(eng, compID, "F0", tc.reason)) bracket, err := store.LoadBracket(compID) require.NoError(t, err) @@ -1951,7 +1967,7 @@ func TestReopenKachinukiMatch_ReopenPending(t *testing.T) { compID := "reopen-pending-bronze" eng, store, _ := setupKachinukiComp(t, compID, 3) require.NoError(t, store.SaveBracket(compID, completedBracket())) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "B0", tc.reason)) + require.NoError(t, reopenErr(eng, compID, "B0", tc.reason)) bracket, err := store.LoadBracket(compID) require.NoError(t, err) @@ -1977,7 +1993,7 @@ func TestRevertMatchToQueue_ClearsReopenPending(t *testing.T) { ID: "P1-0", SideA: "RedTeam", SideB: "WhiteTeam", Status: state.MatchStatusCompleted, Winner: "RedTeam", Decision: "kachinuki-exhaustion", }})) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "P1-0", "")) // reason-less → pending + require.NoError(t, reopenErr(eng, compID, "P1-0", "")) // reason-less → pending before, err := store.LoadPoolMatches(compID) require.NoError(t, err) require.True(t, before[0].ReopenPending, "precondition: reason-less reopen sets the flag") @@ -1998,7 +2014,7 @@ func TestRevertMatchToQueue_ClearsReopenPending(t *testing.T) { Winner: "RedTeam", Decision: "kachinuki-exhaustion", }}}, })) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "F0", "")) // reason-less → pending + require.NoError(t, reopenErr(eng, compID, "F0", "")) // reason-less → pending before, err := store.LoadBracket(compID) require.NoError(t, err) require.True(t, before.Rounds[0][0].ReopenPending, "precondition") @@ -2024,7 +2040,7 @@ func TestOverrideBracketWinner_ClearsReopenPending(t *testing.T) { Winner: "RedTeam", Decision: "kachinuki-exhaustion", }}}, })) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "F0", "")) // reason-less → pending + require.NoError(t, reopenErr(eng, compID, "F0", "")) // reason-less → pending before, err := store.LoadBracket(compID) require.NoError(t, err) require.True(t, before.Rounds[0][0].ReopenPending, "precondition") @@ -2082,7 +2098,7 @@ func TestReopenKachinukiMatch_DiscardsVerdictKeepsBoutLog(t *testing.T) { ResultSource: "admin", RepPlayerA: "R-2", RepPlayerB: "W-2", SubResults: boutLog, }})) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "P1-0", "ended too early")) + require.NoError(t, reopenErr(eng, compID, "P1-0", "ended too early")) matches, err := store.LoadPoolMatches(compID) require.NoError(t, err) @@ -2131,7 +2147,7 @@ func TestReopenKachinukiMatch_DiscardsVerdictKeepsBoutLog(t *testing.T) { }}, }, })) - require.NoError(t, eng.ReopenKachinukiMatch(compID, "F0", "ended too early")) + require.NoError(t, reopenErr(eng, compID, "F0", "ended too early")) bracket, err := store.LoadBracket(compID) require.NoError(t, err) @@ -2177,7 +2193,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { runningOnCourt("P1-1", "A"), })) - err := eng.ReopenKachinukiMatch("reopen-court-busy", "P1-0", "need more bouts") + err := reopenErr(eng, "reopen-court-busy", "P1-0", "need more bouts") require.Error(t, err) var busy *CourtBusyError require.ErrorAs(t, err, &busy) @@ -2204,7 +2220,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { runningOnCourt("P1-1", "B"), })) - require.NoError(t, eng.ReopenKachinukiMatch("reopen-court-free", "P1-0", "need more bouts")) + require.NoError(t, reopenErr(eng, "reopen-court-free", "P1-0", "need more bouts")) matches, lerr := store.LoadPoolMatches("reopen-court-free") require.NoError(t, lerr) assert.Equal(t, state.MatchStatusRunning, matches[0].Status) @@ -2227,7 +2243,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { runningOnCourt("P1-0", "A"), })) - err := eng.ReopenKachinukiMatch("reopen-bracket-pool-busy", "SF0", "need more bouts") + err := reopenErr(eng, "reopen-bracket-pool-busy", "SF0", "need more bouts") var busy *CourtBusyError require.ErrorAs(t, err, &busy) assert.Equal(t, "P1-0", busy.MatchID, "a pool match holding the court blocks a bracket reopen") @@ -2245,7 +2261,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { }}, })) - err := eng.ReopenKachinukiMatch("reopen-pool-bracket-busy", "P1-0", "need more bouts") + err := reopenErr(eng, "reopen-pool-bracket-busy", "P1-0", "need more bouts") var busy *CourtBusyError require.ErrorAs(t, err, &busy) assert.Equal(t, "SF1", busy.MatchID, "a bracket match holding the court blocks a pool reopen") @@ -2266,7 +2282,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { }, })) - err := eng.ReopenKachinukiMatch("reopen-court-busy-bracket", "SF0", "need more bouts") + err := reopenErr(eng, "reopen-court-busy-bracket", "SF0", "need more bouts") var busy *CourtBusyError require.ErrorAs(t, err, &busy) assert.Equal(t, "SF1", busy.MatchID) @@ -2289,7 +2305,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { }, })) - err := eng.ReopenKachinukiMatch("reopen-court-busy-bronze", "B0", "need more bouts") + err := reopenErr(eng, "reopen-court-busy-bronze", "B0", "need more bouts") var busy *CourtBusyError require.ErrorAs(t, err, &busy) assert.Equal(t, "F0", busy.MatchID) @@ -2313,7 +2329,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { runningOnCourt("P9-0", "A"), })) - err := eng.ReopenKachinukiMatch("reopen-cross-a", "P1-0", "need more bouts") + err := reopenErr(eng, "reopen-cross-a", "P1-0", "need more bouts") var busy *CourtBusyError require.ErrorAs(t, err, &busy) assert.Equal(t, "reopen-cross-b", busy.CompID, "the conflicting competition must be named") @@ -2325,7 +2341,7 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { // mp-gmcg review: an UNREOPENABLE target (its winner already fed a fought // downstream) whose court is held by a running match in ANOTHER competition - // must report the permanent ErrReopenDownstreamFought, NOT a transient + // must report the permanent downstream reason, NOT a transient // court_busy. Otherwise the admin remedy panel (which branches on // code=="court_busy") offers to requeue the court's occupant for a target that // can never reopen — a dead end. The plain-reopen entry pre-checks the result @@ -2339,8 +2355,10 @@ func TestReopenKachinukiMatchCourtBusy(t *testing.T) { runningOnCourt("P9-0", "A"), })) - err := eng.ReopenKachinukiMatch("reopen-cross-ds-a", "SF0", "") - require.ErrorIs(t, err, ErrReopenDownstreamFought, "the permanent reason must win over a transient court_busy") + err := reopenErr(eng, "reopen-cross-ds-a", "SF0", "") + // bc-cse: foughtDownstreamBracket's F0 is RUNNING, so this is + // DownstreamKnockoutRunningError now, not the bare sentinel. + require.ErrorIs(t, err, ErrDownstreamKnockoutRunning, "the permanent reason must win over a transient court_busy") var busy *CourtBusyError require.NotErrorAs(t, err, &busy, "an unreopenable target must not surface as court_busy") @@ -2363,10 +2381,10 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { })) // A plain reopen is refused: court A is busy with P1-1. - require.ErrorIs(t, eng.ReopenKachinukiMatch("requeue-reopen", "P1-0", ""), ErrCourtBusy) + require.ErrorIs(t, reopenErr(eng, "requeue-reopen", "P1-0", ""), ErrCourtBusy) // Requeue the blocker + reopen the target in one atomic operation. - require.NoError(t, eng.RequeueBlockerAndReopenKachinuki("requeue-reopen", "P1-0", "requeue-reopen", "P1-1", "")) + require.NoError(t, requeueReopenErr(eng, "requeue-reopen", "P1-0", "requeue-reopen", "P1-1", "")) byID := map[string]state.MatchResult{} matches, _ := store.LoadPoolMatches("requeue-reopen") @@ -2386,7 +2404,7 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { require.NoError(t, store.SavePoolMatches("rq-target", []state.MatchResult{completedOnCourt("P1-0", "A")})) require.NoError(t, store.SavePoolMatches("rq-blocker", []state.MatchResult{runningOnCourt("B1-0", "A")})) - require.NoError(t, eng.RequeueBlockerAndReopenKachinuki("rq-target", "P1-0", "rq-blocker", "B1-0", "")) + require.NoError(t, requeueReopenErr(eng, "rq-target", "P1-0", "rq-blocker", "B1-0", "")) assert.Equal(t, state.MatchStatusRunning, loadPoolMatchByID(t, store, "rq-target", "P1-0").Status) assert.Equal(t, state.MatchStatusScheduled, loadPoolMatchByID(t, store, "rq-blocker", "B1-0").Status) @@ -2403,7 +2421,7 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { runningOnCourt("P1-2", "B"), // sibling on court B — different court, NOT a blocker })) - require.NoError(t, eng.RequeueBlockerAndReopenKachinuki("rq-multicourt", "P1-0", "rq-multicourt", "P1-1", "")) + require.NoError(t, requeueReopenErr(eng, "rq-multicourt", "P1-0", "rq-multicourt", "P1-1", "")) byID := map[string]state.MatchResult{} matches, _ := store.LoadPoolMatches("rq-multicourt") @@ -2429,7 +2447,7 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { })) // Client wrongly names the court-B bystander as the blocker. - err := eng.RequeueBlockerAndReopenKachinuki("rq-wrongcourt", "P1-0", "rq-wrongcourt", "P1-2", "") + err := requeueReopenErr(eng, "rq-wrongcourt", "P1-0", "rq-wrongcourt", "P1-2", "") var verr *ValidationError require.ErrorAs(t, err, &verr, "a blocker on the wrong court is a client error") @@ -2459,8 +2477,10 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { })) require.NoError(t, store.SavePoolMatches("rq-ds-blocker", []state.MatchResult{scoredBlocker("B1-0", "A")})) - err := eng.RequeueBlockerAndReopenKachinuki("rq-downstream", "SF0", "rq-ds-blocker", "B1-0", "") - assert.ErrorIs(t, err, ErrReopenDownstreamFought) + err := requeueReopenErr(eng, "rq-downstream", "SF0", "rq-ds-blocker", "B1-0", "") + // bc-cse: foughtDownstreamBracket's F0 is RUNNING, so this is + // DownstreamKnockoutRunningError now, not the bare sentinel. + assert.ErrorIs(t, err, ErrDownstreamKnockoutRunning) assertBlockerIntact(t, store, "rq-ds-blocker", "B1-0") @@ -2469,41 +2489,6 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { assert.Equal(t, state.MatchStatusCompleted, bracket.Rounds[0][0].Status, "target stays completed") }) - // mp-gmcg review: the fought-downstream subtest above only exercises the - // BRACKET branch of checkTargetReopenable. checkPoolReopenDownstreamTx - // short-circuits unless Format == Mixed (kachinuki.go), so the pre-check's - // POOL branch is otherwise dead in this whole test — a refactor that dropped - // its wiring would pass every case. This pins it: a MIXED-format pool target - // whose finisher already sits in a started knockout is refused via the pool - // branch WITHOUT wiping the blocker. - t.Run("a mixed-pool target with a started knockout downstream is rejected WITHOUT wiping the blocker", func(t *testing.T) { - eng, store, compID := startedMixedKnockout(t) - - // Give the completed Pool A-0 a court so requireBlockerHoldsCourt passes - // and the flow reaches the pre-check (the fixture assigns none). - matches, err := store.LoadPoolMatches(compID) - require.NoError(t, err) - for i := range matches { - if matches[i].ID == "Pool A-0" { - matches[i].Court = "A" - } - } - require.NoError(t, store.SavePoolMatches(compID, matches)) - - // A blocker running on Pool A-0's court (A), in another competition, with - // a live score the destructive revert would clear. - require.NoError(t, store.SaveCompetition(&state.Competition{ - ID: "rq-pool-blocker", TeamSize: 2, TeamMatchType: state.TeamMatchTypeKachinuki, - })) - require.NoError(t, store.SavePoolMatches("rq-pool-blocker", []state.MatchResult{scoredBlocker("B1-0", "A")})) - - err = eng.RequeueBlockerAndReopenKachinuki(compID, "Pool A-0", "rq-pool-blocker", "B1-0", "") - assert.ErrorIs(t, err, ErrReopenDownstreamFought) - - assertBlockerIntact(t, store, "rq-pool-blocker", "B1-0") - assert.Equal(t, state.MatchStatusCompleted, loadPoolMatchByID(t, store, compID, "Pool A-0").Status, "pool target stays completed") - }) - // mp-gmcg review: the ErrReopenNotCompleted arm of the pre-check had no // requeue-path coverage either. A target that is not completed must be // refused BEFORE the destructive revert, so the blocker keeps its score. @@ -2516,7 +2501,7 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { require.NoError(t, store.SavePoolMatches("rq-notdone", []state.MatchResult{runningOnCourt("P1-0", "A")})) require.NoError(t, store.SavePoolMatches("rq-notdone-blocker", []state.MatchResult{scoredBlocker("B1-0", "A")})) - err := eng.RequeueBlockerAndReopenKachinuki("rq-notdone", "P1-0", "rq-notdone-blocker", "B1-0", "") + err := requeueReopenErr(eng, "rq-notdone", "P1-0", "rq-notdone-blocker", "B1-0", "") assert.ErrorIs(t, err, ErrReopenNotCompleted) assertBlockerIntact(t, store, "rq-notdone-blocker", "B1-0") @@ -2531,27 +2516,34 @@ func TestRequeueBlockerAndReopenKachinuki(t *testing.T) { completedOnCourt("P1-0", "A"), completedOnCourt("P1-1", "A"), })) - err := eng.RequeueBlockerAndReopenKachinuki("rq-done-blocker", "P1-0", "rq-done-blocker", "P1-1", "") + err := requeueReopenErr(eng, "rq-done-blocker", "P1-0", "rq-done-blocker", "P1-1", "") var verr *ValidationError require.ErrorAs(t, err, &verr) assert.Equal(t, state.MatchStatusCompleted, loadPoolMatchByID(t, store, "rq-done-blocker", "P1-0").Status, "the target must not reopen when the requeue is rejected") }) - t.Run("a non-kachinuki target is rejected", func(t *testing.T) { + t.Run("a non-kachinuki target no withdrawal decided is rejected WITHOUT wiping the blocker", func(t *testing.T) { eng, store, _ := setupTestEngine(t) require.NoError(t, store.SaveCompetition(&state.Competition{ ID: "rq-fixed", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed, })) - err := eng.RequeueBlockerAndReopenKachinuki("rq-fixed", "P1-0", "rq-fixed", "P1-1", "") + require.NoError(t, store.SavePoolMatches("rq-fixed", []state.MatchResult{ + completedOnCourt("P1-0", "A"), + scoredBlocker("P1-1", "A"), + })) + err := requeueReopenErr(eng, "rq-fixed", "P1-0", "rq-fixed", "P1-1", "") var verr *ValidationError require.ErrorAs(t, err, &verr) + assert.Equal(t, state.MatchStatusRunning, loadPoolMatchByID(t, store, "rq-fixed", "P1-1").Status, + "the gate runs before the destructive requeue, so the blocker keeps its live score") + assert.Equal(t, state.MatchStatusCompleted, loadPoolMatchByID(t, store, "rq-fixed", "P1-0").Status) }) t.Run("an unknown blocker errors and leaves the target finished", func(t *testing.T) { eng, store, _ := setupKachinukiComp(t, "rq-nf-blocker", 3) require.NoError(t, store.SavePoolMatches("rq-nf-blocker", []state.MatchResult{completedOnCourt("P1-0", "A")})) - err := eng.RequeueBlockerAndReopenKachinuki("rq-nf-blocker", "P1-0", "rq-nf-blocker", "nope", "") + err := requeueReopenErr(eng, "rq-nf-blocker", "P1-0", "rq-nf-blocker", "nope", "") require.Error(t, err) assert.Equal(t, state.MatchStatusCompleted, loadPoolMatchByID(t, store, "rq-nf-blocker", "P1-0").Status) }) @@ -3300,27 +3292,43 @@ func startedMixedKnockout(t *testing.T) (*Engine, *state.Store, string) { return eng, store, compID } -// TestReopenKachinukiPoolMatch_DownstreamKnockoutStarted_Rejected pins the -// pool-branch parity with the bracket branch (mp-gmcg): reopening a pool match -// whose current finisher already sits in a STARTED knockout match is refused -// with ErrReopenDownstreamFought, so a later re-End cannot strand the displaced -// finisher in the bracket. The score path's mp-e2k1 guard cannot catch that, -// because by re-End time the reopened match is excluded from the standings -// baseline it compares against. -func TestReopenKachinukiPoolMatch_DownstreamKnockoutStarted_Rejected(t *testing.T) { +// TestReopenKachinukiPoolMatch_DownstreamKnockoutStarted_FinishDecides pins +// the pool reopen's rule: reopening a pool match moves no knockout slot (the +// pool is incomplete while it is open), so it is allowed even while its +// finisher sits in a started knockout match. What the reopen leads to is +// decided when it is FINISHED: the finishing write runs the requalification +// rule against the bracket, so the same result is silent and a changed one is +// refused here, because the knockout match it reaches is being fought. +func TestReopenKachinukiPoolMatch_DownstreamKnockoutStarted_FinishDecides(t *testing.T) { eng, store, compID := startedMixedKnockout(t) - // Reopening Pool A-0 (whose finisher A1 sits in the running knockout) is refused. - err := eng.ReopenKachinukiMatch(compID, "Pool A-0", "") - require.Error(t, err) - assert.ErrorIs(t, err, ErrReopenDownstreamFought) + finish := func(winner string) error { + r := &state.MatchResult{SideA: "A1", SideB: "A2", Winner: winner, IpponsA: []string{"M"}, Status: state.MatchStatusCompleted} + if winner == "A2" { + r.IpponsA, r.IpponsB = nil, []string{"M"} + } + var werr error + require.NoError(t, store.WithTransaction(compID, func(tx state.StoreTx) error { + _, werr = eng.RecordMatchResultWithIneligibilityTx(tx, compID, "Pool A-0", r) + return nil + })) + return werr + } + + require.NoError(t, reopenErr(eng, compID, "Pool A-0", "scored the wrong bout")) + assert.Equal(t, state.MatchStatusRunning, loadPoolMatchByID(t, store, compID, "Pool A-0").Status) - // The pool match stays completed with its original winner (not reopened). + // Same result: nothing in the knockout moves, so nothing is said. + require.NoError(t, finish("A1")) + assert.Equal(t, state.MatchStatusCompleted, loadPoolMatchByID(t, store, compID, "Pool A-0").Status) + + // A different result would move A1 out of the knockout match being fought. + require.NoError(t, reopenErr(eng, compID, "Pool A-0", "scored the wrong bout again")) + err := finish("A2") + require.ErrorIs(t, err, ErrDownstreamKnockoutRunning) poolA0 := loadPoolMatchByID(t, store, compID, "Pool A-0") - require.NotNil(t, poolA0) - assert.Equal(t, state.MatchStatusCompleted, poolA0.Status) - assert.Equal(t, "A1", poolA0.Winner) - assert.False(t, poolA0.ReopenPending) + assert.Equal(t, state.MatchStatusRunning, poolA0.Status, "the refused finish must leave the reopened match as it was") + assert.Empty(t, poolA0.Winner) } // TestReopenKachinukiPoolMatch_KnockoutNotStarted_Allowed is the companion: with @@ -3338,7 +3346,7 @@ func TestReopenKachinukiPoolMatch_KnockoutNotStarted_Allowed(t *testing.T) { require.True(t, allResolved) // The knockout leaf is resolved but left SCHEDULED (never started). - err = eng.ReopenKachinukiMatch(compID, "Pool A-0", "scored the wrong bout") + err = reopenErr(eng, compID, "Pool A-0", "scored the wrong bout") require.NoError(t, err) poolA0 := loadPoolMatchByID(t, store, compID, "Pool A-0") diff --git a/internal/engine/knockout.go b/internal/engine/knockout.go index ca15a13db..ff9143991 100644 --- a/internal/engine/knockout.go +++ b/internal/engine/knockout.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "log" + "sort" + "strings" "github.com/gitrgoliveira/bracket-creator/internal/helper" "github.com/gitrgoliveira/bracket-creator/internal/state" @@ -60,18 +62,13 @@ type resolvedFinisher struct { Name, ID string } -// completedPoolNames returns poolName → isComplete for every pool in compID. A -// pool is complete when all of its matches (regular + any tiebreaker/daihyosen) -// are completed with a winner, no further tiebreaker/DH injection is pending for -// it, and, for team competitions, its daihyosen results actually broke the -// ties (no cycle). Tiebreaker/DH injection runs comp-wide first (idempotent). +// completedPoolNames returns poolName → isComplete for every pool in compID, +// after first injecting any supplementary tie-break bouts a newly tied pool +// needs (comp-wide, idempotent), so a pool that just became tied flips to "not +// complete" on this very call. The judgement itself is poolCompletion's; this +// is the bare-store door, which may write (the injectors do). func (e *Engine) completedPoolNames(compID string, comp *state.Competition) (map[string]bool, error) { - isTeam := comp != nil && comp.TeamSize > 0 - - // Inject supplementary tie-break matches for any tied pools. Both injectors - // are idempotent and only add matches for pools that need them, so a pool - // that just became tied flips to "not complete" on the next call. - if isTeam { + if comp != nil && comp.TeamSize > 0 { if _, err := e.InjectPoolDaihyosenMatches(compID); err != nil { return nil, err } @@ -80,12 +77,31 @@ func (e *Engine) completedPoolNames(compID string, comp *state.Competition) (map return nil, err } } + return e.poolCompletion(e.store, compID, comp, func() (map[string][]state.PlayerStanding, error) { + return e.CalculatePoolStandings(compID) + }) +} - pools, err := e.store.LoadPools(compID) +// poolCompletion returns poolName → isComplete for every pool in compID. A +// pool is complete when all of its matches (regular + any tiebreaker/daihyosen) +// are completed with a winner and, for team competitions, its daihyosen +// results actually broke the ties (no cycle). +// +// It only READS, through loader, so a transaction can ask it about the state +// its own uncommitted write produced (pool_requalify.go); completedPoolNames is +// the bare-store door that injects first. It does NOT ask whether a tie-break +// is still owed but not yet injected: inside a transaction nothing has been +// injected, so a caller there must also ask pendingTieBreaks. standings is +// consulted only for a team competition, and lazily, so the bare-store door +// can keep reading the standings cache. +func (e *Engine) poolCompletion(loader poolStandingsLoader, compID string, comp *state.Competition, standings func() (map[string][]state.PlayerStanding, error)) (map[string]bool, error) { + isTeam := comp != nil && comp.TeamSize > 0 + + pools, err := loader.LoadPools(compID) if err != nil { return nil, err } - matches, err := e.store.LoadPoolMatches(compID) + matches, err := loader.LoadPoolMatches(compID) if err != nil { return nil, err } @@ -129,7 +145,7 @@ func (e *Engine) completedPoolNames(compID string, comp *state.Competition) (map // Team competitions: a pool whose daihyosen results produced a cycle (ties // not broken) must not be treated as resolvable. if isTeam { - standings, serr := e.CalculatePoolStandings(compID) + st, serr := standings() if serr != nil { return nil, serr } @@ -145,7 +161,7 @@ func (e *Engine) completedPoolNames(compID string, comp *state.Competition) (map if !ok { continue } - scoped := map[string][]state.PlayerStanding{pn: standings[pn]} + scoped := map[string][]state.PlayerStanding{pn: st[pn]} if dhCycleExists(scoped, matches, poolRanks) { done[pn] = false } @@ -154,6 +170,211 @@ func (e *Engine) completedPoolNames(compID string, comp *state.Competition) (map return done, nil } +// qualifierResolver maps each placeholder label a completed pool can fill +// ("Pool A-1st", up to MatchWinnerRanksNeeded) onto the finisher now holding +// that rank. Incomplete pools contribute nothing, so their placeholders survive +// untouched. The ONE statement of "who a label means", shared by +// ResolveQualifiedPools and the in-transaction requalification planner. +// +// MatchWinnerRanksNeeded, not poolWinners: under the extra-qualifier modes +// (bc-qual) the bracket seats "Pool X-2nd" placeholders while PoolWinners is +// pinned to 1, so a resolver bounded by poolWinners never builds a -2nd key and +// those slots stay placeholders forever -- every pool done, the drafted/crossed +// matches never playable, the competition stuck in pools status. Keys for pools +// that send no 2nd are inert: the resolver is only ever consulted with the +// labels the draw actually seated. +// +// resolvedFinisher carries both the display name and the participant id +// (bc-brid), so a bracket slot's identity survives the resolution, not just its +// label. +func qualifierResolver(pools []helper.Pool, completed map[string]bool, standings map[string][]state.PlayerStanding, ranksNeeded int) map[string]resolvedFinisher { + resolver := make(map[string]resolvedFinisher) + for _, pool := range pools { + if !completed[pool.PoolName] { + continue + } + ps := standings[pool.PoolName] + for rank := 1; rank <= ranksNeeded; rank++ { + key := qualifierLabel(pool.PoolName, rank) + if rank-1 >= len(ps) { + // Degenerate pool (hand-edited data / legacy import): fewer + // finishers than PoolWinners. Map the unfillable placeholder + // to "" (bye) so the bracket slot auto-resolves. Draw-time + // validation prevents this in supported flows. + log.Printf("engine: pool %q has only %d ranked finisher(s) but placeholders may reference %d rank(s); treating rank %d as bye", pool.PoolName, len(ps), ranksNeeded, rank) + resolver[key] = resolvedFinisher{} + continue + } + p := ps[rank-1].Player + resolver[key] = resolvedFinisher{Name: p.Name, ID: p.ID} + } + } + return resolver +} + +// qualifierLabel is the placeholder a pool's rank holds in the draw ("Pool +// A-1st"), the key both resolvers look slots up by. +func qualifierLabel(poolName string, rank int) string { + return fmt.Sprintf("%s-%s", poolName, helper.GetOrdinal(rank)) +} + +// qualifierLabelPool is qualifierLabel's inverse for the pool half ("Pool +// A-East-2nd" -> "Pool A-East"): the ordinal never holds a hyphen, so the last +// one separates the two, whatever hyphens the pool name has. +func qualifierLabelPool(label string) string { + if i := strings.LastIndexByte(label, '-'); i > 0 { + return label[:i] + } + return label +} + +// resolveSlots writes resolver's finishers into every bracket slot whose +// draw-time label (PlaceholderA/B/Winner) is a resolver key, then completes and +// propagates any bye that leaves. It does no I/O, so it serves both the +// bare-store resolver and a transaction. Returns how many sides changed. +// +// A slot in a match that is RUNNING, or closed with a result of its own +// (bracketMatchCarriesOwnResult), is never repainted here: someone is fighting +// it, or it was fought by the competitor already in it, and silently swapping +// the name above that verdict is the defect the requalification planner exists +// to prevent. That planner reopens such a match (with the operator's +// confirmation) BEFORE calling this, so on its path nothing is left to skip, +// and so does the pool-rank override door (OverridePoolRanks). The skip is the +// safety net for the bare-store door, where standings can still move without +// either: a pool file edited by hand, or DELETE .../overrides (the corrupt-file +// repair door) clearing a chusen. It is all-or-nothing per pool: one locked +// slot that would change freezes every slot of its pool, so no door can ever +// half-apply a pool's new order. A skipped slot is logged, never silently +// dropped. +func (e *Engine) resolveSlots(bracket *state.Bracket, resolver map[string]resolvedFinisher) int { + n := 0 + isLocked := func(m *state.BracketMatch) bool { + return m.Status == state.MatchStatusRunning || bracketMatchCarriesOwnResult(m) + } + // The safety net is all-or-nothing PER POOL. Skipping only the locked slot + // while repainting the pool's others seats one competitor twice: a + // 1st/2nd swap kept A1 in the played "Pool A-1st" match and painted A1 + // into the scheduled "Pool A-2nd" one as well. So a pool any of whose + // slots is locked AND would get a different competitor (occupantChanges: + // an id backfill moves nobody and freezes nothing) is FROZEN, and none of + // its slots is written. frozen maps the pool to the first match that froze + // it, for the one log line per pool below. + frozen := map[string]string{} + scanLocked := func(m *state.BracketMatch) { + if !isLocked(m) { + return + } + for _, s := range [...]struct{ label, name, id string }{ + {m.PlaceholderA, m.SideA, m.SideAID}, + {m.PlaceholderB, m.SideB, m.SideBID}, + {m.PlaceholderWinner, m.Winner, m.WinnerID}, + } { + rf, ok := resolver[s.label] + if !ok || (s.name == rf.Name && s.id == rf.ID) || !occupantChanges(s.name, s.id, rf) { + continue + } + if pool := qualifierLabelPool(s.label); frozen[pool] == "" { + frozen[pool] = m.ID + } + } + } + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + scanLocked(&bracket.Rounds[ri][mi]) + } + } + if bracket.ThirdPlaceMatch != nil { + scanLocked(bracket.ThirdPlaceMatch) + } + frozenPools := make([]string, 0, len(frozen)) + for pool := range frozen { + frozenPools = append(frozenPools, pool) + } + sort.Strings(frozenPools) + for _, pool := range frozenPools { + log.Printf("engine: no knockout slot of %s is repainted: match %s is running or has its own result and would get a different competitor, and repainting the pool's other slots alone would seat one competitor twice; the pool's correction must go through the requalification planner, which reopens that match first", pool, frozen[pool]) + } + resolveMatch := func(m *state.BracketMatch) { + locked := isLocked(m) + // PlaceholderA/B/Winner hold the ORIGINAL draw labels (or + // "Winner of …"/""), stable across re-scores. Only completed-pool + // placeholders are resolver keys; "Winner of" and "" never are, so + // already-scored knockout sides and unresolved feeders are untouched. + // Compare against the current (name, id) pair so an unchanged + // re-run is a no-op, and so a bracket resolved once BEFORE bc-brid + // (name only, id still "") gets its id backfilled the next time its + // pool's placeholder is looked at. + paint := func(label string, name, id *string) { + rf, ok := resolver[label] + if !ok || (*name == rf.Name && *id == rf.ID) { + return + } + if frozen[qualifierLabelPool(label)] != "" { + return // logged once for the pool above + } + if locked { + log.Printf("engine: bracket match %s (%s) keeps %q in its %s slot: it is running or has its own result, so the new occupant %q is not written over it", m.ID, m.Status, *name, label, rf.Name) + return + } + *name, *id = rf.Name, rf.ID + n++ + } + paint(m.PlaceholderA, &m.SideA, &m.SideAID) + paint(m.PlaceholderB, &m.SideB, &m.SideBID) + // Winner-only changes count too, so a bye-propagated Winner fix is persisted. + paint(m.PlaceholderWinner, &m.Winner, &m.WinnerID) + } + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + resolveMatch(&bracket.Rounds[ri][mi]) + } + } + if bracket.ThirdPlaceMatch != nil { + // Inert in every current draw: the bronze is fed by semifinal + // losers, so its placeholders are empty and no lookup can hit. + // Present so a bronze that ever DOES carry one is not the single + // slot the resolver skips. + resolveMatch(bracket.ThirdPlaceMatch) + } + // Auto-complete newly created bye matches: a resolver mapping to "" + // (degenerate pool) leaves a match with one empty side still + // Scheduled. Mirror buildBracketFromDraw's bye logic and + // propagate winners so downstream matches become playable. + // Guard: only auto-complete when the non-empty side is a resolved + // competitor, NOT a still-unresolved placeholder (its feeder pool + // may still be in progress). + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + m := &bracket.Rounds[ri][mi] + if m.Status != state.MatchStatusScheduled { + continue + } + aEmpty := m.SideA == "" + bEmpty := m.SideB == "" + aResolved := !aEmpty && !isUnresolvedBracketSide(m.SideA) + bResolved := !bEmpty && !isUnresolvedBracketSide(m.SideB) + if aEmpty && bResolved { + m.Winner = m.SideB + m.WinnerID = m.SideBID + m.Status = state.MatchStatusCompleted + e.propagateBracketWinner(bracket, ri, mi) + n++ + } else if bEmpty && aResolved { + m.Winner = m.SideA + m.WinnerID = m.SideAID + m.Status = state.MatchStatusCompleted + e.propagateBracketWinner(bracket, ri, mi) + n++ + } else if aEmpty && bEmpty { + m.Status = state.MatchStatusCompleted + e.propagateBracketWinner(bracket, ri, mi) + n++ + } + } + } + return n +} + // ResolveQualifiedPools incrementally seeds the in-place knockout bracket of a // mixed (Pools + Knockout) competition. For EVERY pool whose results are final // it writes that pool's real finishers into the bracket slots their finalist @@ -188,20 +409,21 @@ func (e *Engine) completedPoolNames(compID string, comp *state.Competition) (map // such a file is detected; it is reconstructed once from the frozen v1 builder // (legacy_template_v1.go) and the fields are backfilled and saved on first use. // -// Known limitation (mp-e2k1): re-seeding repaints round-0 leaves and -// bye-propagated sides, but does NOT invalidate a DOWNSTREAM knockout match that -// was already scored during the pool phase if its feeder pool is later re-scored -// to a different finisher. +// It never repaints a slot whose match is running or carries its own result +// (resolveSlots' safety net): a pool CORRECTION that moves a qualifier out from +// under such a match is caught inside the correcting write's own transaction +// (planRequalification), which warns and, confirmed, reopens it. What still +// reaches this door after the fact (a pool-rank override, a league tie-break +// finalize) leaves that one slot as it is and logs it. // // Returns (resolvedNow, allResolved): how many bracket sides changed THIS call, // and whether the bracket now has zero pool-origin placeholders left (every pool // seeded). No-op (0, false, nil) for non-mixed competitions, standalone knockout // brackets carry no pool placeholders. // -// Identity (bc-brid): the resolver below writes both a competitor's display -// NAME and their participant id into the bracket slot -// (`resolver[key] = resolvedFinisher{Name: ps[rank-1].Player.Name, ID: -// ps[rank-1].Player.ID}`), closing the seam bc-cse's identity work left open +// Identity (bc-brid): the resolver (qualifierResolver) writes both a +// competitor's display NAME and their participant id into the bracket slot, +// closing the seam bc-cse's identity work left open // -- BracketMatch now persists per-side ids (state/models.go's // BracketMatch.SideAID doc), so two same-name-different-dojo pool finishers // qualifying into the same knockout stay distinguishable from here on. @@ -234,44 +456,7 @@ func (e *Engine) ResolveQualifiedPools(compID string) (int, bool, error) { return 0, false, err } poolWinners := comp.EffectivePoolWinners() - // MatchWinnerRanksNeeded, not poolWinners: under the extra-qualifier - // modes (bc-qual) the bracket seats "Pool X-2nd" placeholders while - // PoolWinners is pinned to 1, so a resolver bounded by poolWinners never - // builds a -2nd key and those slots stay placeholders forever -- every - // pool done, the drafted/crossed matches never playable, the competition - // stuck in pools status. This is the same bug the Excel export hit first - // (a crossed 2nd rendered as inert text), fixed by the same single owner - // of "how many ranks can a placeholder reference". Keys for pools that - // send no 2nd are inert: the resolver is only ever consulted with the - // labels the draw actually seated. - ranksNeeded := comp.MatchWinnerRanksNeeded() - - // Build a label→finisher resolver for COMPLETED pools only. Incomplete - // pools contribute nothing, so their placeholders survive untouched. - // resolvedFinisher carries both the display name and the participant id - // (bc-brid), so a bracket slot's identity survives the resolution, not - // just its label. - resolver := make(map[string]resolvedFinisher) - for _, pool := range pools { - if !completed[pool.PoolName] { - continue - } - ps := standings[pool.PoolName] - for rank := 1; rank <= ranksNeeded; rank++ { - key := fmt.Sprintf("%s-%s", pool.PoolName, helper.GetOrdinal(rank)) - if rank-1 >= len(ps) { - // Degenerate pool (hand-edited data / legacy import): fewer - // finishers than PoolWinners. Map the unfillable placeholder - // to "" (bye) so the bracket slot auto-resolves. Draw-time - // validation prevents this in supported flows. - log.Printf("engine.ResolveQualifiedPools: pool %q has only %d ranked finisher(s) but placeholders may reference %d rank(s); treating rank %d as bye", pool.PoolName, len(ps), ranksNeeded, rank) - resolver[key] = resolvedFinisher{} - continue - } - p := ps[rank-1].Player - resolver[key] = resolvedFinisher{Name: p.Name, ID: p.ID} - } - } + resolver := qualifierResolver(pools, completed, standings, comp.MatchWinnerRanksNeeded()) resolvedNow := 0 allResolved := false @@ -301,80 +486,7 @@ func (e *Engine) ResolveQualifiedPools(compID string) (int, bool, error) { // pools-times-winners layout this backfill reconstructs. backfilled = backfillDrawPlaceholdersV1(bracket, poolNames, poolWinners) } - n := 0 - resolveMatch := func(m *state.BracketMatch) { - // PlaceholderA/B/Winner hold the ORIGINAL draw labels (or - // "Winner of …"/""), stable across re-scores. Only completed-pool - // placeholders are resolver keys; "Winner of" and "" never are, so - // already-scored knockout sides and unresolved feeders are untouched. - // Compare against the current (name, id) pair so an unchanged - // re-run is a no-op, and so a bracket resolved once BEFORE bc-brid - // (name only, id still "") gets its id backfilled the next time its - // pool's placeholder is looked at. - if rf, ok := resolver[m.PlaceholderA]; ok && (m.SideA != rf.Name || m.SideAID != rf.ID) { - m.SideA = rf.Name - m.SideAID = rf.ID - n++ - } - if rf, ok := resolver[m.PlaceholderB]; ok && (m.SideB != rf.Name || m.SideBID != rf.ID) { - m.SideB = rf.Name - m.SideBID = rf.ID - n++ - } - if rf, ok := resolver[m.PlaceholderWinner]; ok && (m.Winner != rf.Name || m.WinnerID != rf.ID) { - m.Winner = rf.Name - m.WinnerID = rf.ID - n++ // count Winner-only changes so a bye-propagated Winner fix is persisted - } - } - for ri := range bracket.Rounds { - for mi := range bracket.Rounds[ri] { - resolveMatch(&bracket.Rounds[ri][mi]) - } - } - if bracket.ThirdPlaceMatch != nil { - // Inert in every current draw: the bronze is fed by semifinal - // losers, so its placeholders are empty and no lookup can hit. - // Present so a bronze that ever DOES carry one is not the single - // slot the resolver skips. - resolveMatch(bracket.ThirdPlaceMatch) - } - // Auto-complete newly created bye matches: a resolver mapping to "" - // (degenerate pool) leaves a match with one empty side still - // Scheduled. Mirror buildBracketFromDraw's bye logic and - // propagate winners so downstream matches become playable. - // Guard: only auto-complete when the non-empty side is a resolved - // competitor, NOT a still-unresolved placeholder (its feeder pool - // may still be in progress). - for ri := range bracket.Rounds { - for mi := range bracket.Rounds[ri] { - m := &bracket.Rounds[ri][mi] - if m.Status != state.MatchStatusScheduled { - continue - } - aEmpty := m.SideA == "" - bEmpty := m.SideB == "" - aResolved := !aEmpty && !isUnresolvedBracketSide(m.SideA) - bResolved := !bEmpty && !isUnresolvedBracketSide(m.SideB) - if aEmpty && bResolved { - m.Winner = m.SideB - m.WinnerID = m.SideBID - m.Status = state.MatchStatusCompleted - e.propagateBracketWinner(bracket, ri, mi) - n++ - } else if bEmpty && aResolved { - m.Winner = m.SideA - m.WinnerID = m.SideAID - m.Status = state.MatchStatusCompleted - e.propagateBracketWinner(bracket, ri, mi) - n++ - } else if aEmpty && bEmpty { - m.Status = state.MatchStatusCompleted - e.propagateBracketWinner(bracket, ri, mi) - n++ - } - } - } + n := e.resolveSlots(bracket, resolver) allResolved = !bracketHasPoolPlaceholders(bracket) if n == 0 && !backfilled { diff --git a/internal/engine/knockout_skeleton.go b/internal/engine/knockout_skeleton.go index a2a575f68..d1b19c5f2 100644 --- a/internal/engine/knockout_skeleton.go +++ b/internal/engine/knockout_skeleton.go @@ -251,7 +251,7 @@ func EliminationDraw(store *state.Store, comp *state.Competition, pools []helper // "Round N - Match N" numbering equal to the stored bracket's MatchNumber even // when seeds.csv has drifted, so overlayBracketScores writes each score into the // right block. The two numbering walks are equal-by-contract -// (assignBracketMatchNumbers vs helper.AssignMatchNumbers), but only over the +// (state.Bracket.NumberMatches vs helper.AssignMatchNumbers), but only over the // same SHAPE: the leaf order alone is not enough, the rebuild must also collapse // the "" slots below, or the numbering walks over a tree with extra nodes in it // (see EliminationDraw). Returns nil for a nil/empty bracket (e.g. a knockout diff --git a/internal/engine/load_error_propagation_test.go b/internal/engine/load_error_propagation_test.go new file mode 100644 index 000000000..dc2a002e7 --- /dev/null +++ b/internal/engine/load_error_propagation_test.go @@ -0,0 +1,149 @@ +package engine + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// corruptCompFile overwrites one of compID's files with bytes its parser +// rejects at the structural level, the class the store reports as a located +// *state.CorruptFileError. +func corruptCompFile(t *testing.T, store *state.Store, compID, file string) { + t.Helper() + var raw string + switch file { + case "pool-matches.csv": + raw = "a,\"unterminated\n" + case "bracket.json": + raw = "{not json" + default: + t.Fatalf("no corrupt shape for %s", file) + } + p := filepath.Join(store.GetFolder(), "competitions", compID, file) + require.NoError(t, os.WriteFile(p, []byte(raw), 0600)) +} + +// assertLoadError is the contract every match lookup now keeps: an unreadable +// file is the caller's error, carried as the store's own located +// CorruptFileError (which the handlers turn into the corrupt_file 500 naming +// the file), and never a *NotFoundError (a 404 the offline queue drops as +// final). +func assertLoadError(t *testing.T, err error, file string) { + t.Helper() + require.Error(t, err) + var notFound *NotFoundError + assert.False(t, errors.As(err, ¬Found), "an unreadable file is not a missing match: %v", err) + cf, ok := state.AsCorruptFile(err) + require.True(t, ok, "the load error reaches the caller as it is: %v", err) + assert.Equal(t, file, cf.File) +} + +func TestMatchLookupsPropagateLoadErrors(t *testing.T) { + for _, file := range []string{"pool-matches.csv", "bracket.json"} { + t.Run(file, func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "load-err" + createTestCompetition(t, store, compID, "league", 3) + corruptCompFile(t, store, compID, file) + + // The id is in neither store, so a swallowed load error reads as + // "not found": exactly the answer this pins against. + const matchID = "m-r1-0" + inTxErr := func(fn func(tx state.StoreTx) error) error { + var got error + require.NoError(t, store.WithTransaction(compID, func(tx state.StoreTx) error { + got = fn(tx) + return nil + })) + return got + } + + lookups := []struct { + name string + call func() error + }{ + {"lookupExistingResult", func() error { + return inTxErr(func(tx state.StoreTx) error { + _, err := eng.lookupExistingResult(tx, compID, matchID) + return err + }) + }}, + {"RecordMatchResultWithIneligibilityTx", func() error { + return inTxErr(func(tx state.StoreTx) error { + _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, matchID, &state.MatchResult{Status: state.MatchStatusCompleted}) + return err + }) + }}, + {"hasDownstreamMatchStarted", func() error { + return inTxErr(func(tx state.StoreTx) error { + _, err := eng.hasDownstreamMatchStarted(tx, compID, []string{"Alice"}, matchID) + return err + }) + }}, + {"matchSideParticipantIDs", func() error { + return inTxErr(func(tx state.StoreTx) error { + _, err := eng.matchSideParticipantIDs(tx, compID, matchID) + return err + }) + }}, + {"lookupMatchSides", func() error { + return inTxErr(func(tx state.StoreTx) error { + _, _, err := eng.lookupMatchSides(tx, compID, matchID) + return err + }) + }}, + {"checkSimultaneousMatchTx", func() error { + return inTxErr(func(tx state.StoreTx) error { + return eng.checkSimultaneousMatchTx(tx, compID, matchID) + }) + }}, + {"findMatchHome", func() error { + return inTxErr(func(tx state.StoreTx) error { + _, err := findMatchHome(tx, compID, matchID, func(matchHome) error { return nil }) + return err + }) + }}, + {"findTeamMatch", func() error { + _, _, _, err := eng.findTeamMatch(compID, matchID) + return err + }}, + {"ReopenMatch", func() error { + _, err := eng.ReopenMatch(compID, matchID, "") + return err + }}, + } + for _, l := range lookups { + t.Run(l.name, func(t *testing.T) { + assertLoadError(t, l.call(), file) + }) + } + }) + } +} + +// A competition that simply has no such file is not an error: a knockout-only +// competition has no pool-matches.csv and a pool-only one no bracket.json, +// and a lookup there must still answer "not found". +func TestMatchLookupsOnAMissingFileStayNotFound(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "no-files" + createTestCompetition(t, store, compID, "league", 3) + + var err error + require.NoError(t, store.WithTransaction(compID, func(tx state.StoreTx) error { + _, err = eng.lookupExistingResult(tx, compID, "m-r1-0") + return nil + })) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) + + parent, _, _, err := eng.findTeamMatch(compID, "m-r1-0") + require.NoError(t, err) + assert.Nil(t, parent) +} diff --git a/internal/engine/match_label.go b/internal/engine/match_label.go new file mode 100644 index 000000000..612eddf74 --- /dev/null +++ b/internal/engine/match_label.go @@ -0,0 +1,189 @@ +// Package engine, match_label.go owns ONE entry point that names ANY +// match -- pool, league, Swiss round, or knockout -- the way the operator +// sees it on screen. errors.go's MatchLabel already covers the knockout +// shape (via a *ReopenedMatch* carrying Number/DisplayRound); this file +// adds the pool-phase shapes and dispatches between the two, mirroring +// web-mobile/js/viewer_utils.jsx's leagueAwareLabel/poolLabel and +// web-mobile/js/pool_ids.jsx's poolMatchNumberOf for the pool/league/Swiss +// heading, and admin_schedule_score_editor.jsx's scoreRowMatchLabel for the +// " · Match N" join. testdata/match_labels.json pins the cases +// both languages share; see its own header for the schema. +// +// bc-cse: a refusal that names a match (the ineligible-competitor sentence, +// the simultaneity gate, a reopen/court-busy refusal) needs this exact +// operator-facing spelling, not the raw match id -- "Pool A · Match 1", not +// "Pool A-0". +package engine + +import ( + "fmt" + "log" + "strconv" + "strings" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// OperatorMatchLabel names matchID the way the operator sees it, PURE (no +// I/O): comp supplies Format (decides "League table" vs the pool's own +// name); bracket, when non-nil, resolves a knockout id's Number/DisplayRound +// through MatchLabel. Both may be nil/empty -- every call site here builds +// OPERATOR-FACING PROSE, never gates logic on the label, so an unresolved +// comp/bracket degrades to the bare id (MatchLabel's own fallback) rather +// than erroring. +// +// Callers already holding a live transaction (checkSimultaneousMatchTx's +// siblings, kachinuki.go's reopen/court-busy messages) load comp/bracket via +// their own `h state.StoreTx` handle and call this directly; a handler +// loads via its CompetitionStore interface (LoadCompetition/LoadBracket are +// already on it) and does the same. There is no separate "Tx" twin of this +// function: it takes no store handle at all, so bc-twin's rule (one body, +// not a hand-copied pair differing only by store handle) has nothing to +// duplicate here. +func OperatorMatchLabel(comp *state.Competition, bracket *state.Bracket, matchID string) string { + if label, ok := poolPhaseMatchLabel(comp, matchID); ok { + return label + } + return MatchLabel(bracketReopenedMatchFor(bracket, matchID)) +} + +// matchLabelStore is the narrow read surface OperatorMatchLabelFromStore +// needs. *state.Store satisfies it directly; mobileapp's CompetitionStore +// interface already declares both methods with the same signature, so it +// satisfies it structurally with no adapter. +type matchLabelStore interface { + LoadCompetition(compID string) (*state.Competition, error) + LoadBracket(compID string) (*state.Bracket, error) +} + +// OperatorMatchLabelFromStore resolves matchID's operator label within +// compID by loading the competition and bracket directly from store, +// falling back to the bare matchID when the competition cannot be loaded +// (OperatorMatchLabel's own contract: operator-facing prose degrades rather +// than errors). A bracket-load failure degrades only the label (a nil +// bracket still resolves a pool-phase id; a knockout id falls back to its +// bare form) and is LOGGED rather than silently swallowed, matching every +// other read in this codebase that discards an error on purpose. +// +// The two hand-copies this replaces (mobileapp.matchLabelOrID, +// Engine.operatorMatchLabel) now both delegate here (bc-cse/11b), so the +// load-then-degrade sequence has one owner. +// +// Reads DIRECTLY from store, never through a state.StoreTx handle: do NOT +// call this from inside a Store.WithTransaction body -- a caller already +// holding compID's per-comp write lock would deadlock against the second +// acquire (the lock is non-reentrant). A caller inside a transaction +// already has its `h state.StoreTx` handle's own LoadCompetition/LoadBracket +// and should call OperatorMatchLabel directly with what it loads through +// that handle instead (see e.g. checkSimultaneousMatchTx's +// simultaneousReason closure). +func OperatorMatchLabelFromStore(store matchLabelStore, compID, matchID string) string { + comp, err := store.LoadCompetition(compID) + if err != nil || comp == nil { + return matchID + } + bracket, err := store.LoadBracket(compID) + if err != nil { + log.Printf("engine: OperatorMatchLabelFromStore: LoadBracket compId=%s matchId=%s: %v (label degrades to pool-phase-only)", compID, matchID, err) + } + return OperatorMatchLabel(comp, bracket, matchID) +} + +// poolPhaseMatchLabel handles the three pool-matches.csv-backed shapes: a +// real pool, a league table (single pool, reused generation, different +// heading -- CompFormatLeague never changes the id shape, only the +// heading), and a Swiss round (its own "Swiss-R-" id shape). ok is +// false for anything else (a knockout id), which sends OperatorMatchLabel +// to the bracket branch. +func poolPhaseMatchLabel(comp *state.Competition, matchID string) (string, bool) { + if round, isSwiss := parseSwissMatchRound(matchID); isSwiss { + return supplementaryOrNumberedLabel(fmt.Sprintf("Round %d", round), matchID), true + } + if !IsPoolMatchID(matchID) { + return "", false + } + heading, ok := poolNameFromMatchID(matchID) + if !ok { + heading = matchID + } + if comp != nil && comp.Format == state.CompFormatLeague { + // leagueAwareLabel (viewer_utils.jsx): a league is one round-robin + // table over the whole roster, so its own pool name ("Pool A", the + // single pool the league generation reuses) is never shown. + heading = "League table" + } + return supplementaryOrNumberedLabel(heading, matchID), true +} + +// supplementaryOrNumberedLabel appends the daihyosen/tiebreaker qualifier +// for a supplementary bout id (scoreRowMatchLabel has no equivalent: a +// JS caller never asks for one, since poolMatchNumberOf returns 0 for a +// DH/TB id and the row simply hides its own match-number suffix -- see the +// _comment in testdata/match_labels.json), else the pool-relative match +// number (" · Match "), or the bare heading when the id +// carries no numbered ordinal at all (DH/TB parse to 0 too, which is why +// the supplementary check runs FIRST). +func supplementaryOrNumberedLabel(heading, matchID string) string { + switch { + case IsPoolDaihyosenMatchID(matchID): + return heading + " daihyosen" + case IsTiebreakerMatchID(matchID): + return heading + " tiebreaker" + } + n := poolPhaseMatchNumber(matchID) + if n == 0 { + return heading + } + return fmt.Sprintf("%s · Match %d", heading, n) +} + +// poolPhaseMatchNumber mirrors pool_ids.jsx's poolMatchNumberOf: the bout's +// 1-based match number WITHIN its pool/round, taken from the id's own +// trailing "-" ordinal (0-based on disk, so +1 here). Returns 0 for +// a supplementary id (DH/TB) or an id with no numeric ordinal at all. +func poolPhaseMatchNumber(matchID string) int { + if IsPoolDaihyosenMatchID(matchID) || IsTiebreakerMatchID(matchID) { + return 0 + } + i := strings.LastIndexByte(matchID, '-') + if i < 0 { + return 0 + } + n, err := strconv.Atoi(matchID[i+1:]) + if err != nil { + return 0 + } + return n + 1 +} + +// bracketReopenedMatchFor resolves matchID's Number/DisplayRound through +// bracket, via the SAME bracketMatchRef every other door that names a +// knockout match to the operator uses (scoring.go), so a match this +// function names cannot disagree with a reopen/correction dialog naming the +// same match. Falls back to a bare-id ReopenedMatch (MatchLabel's own id +// fallback) when bracket is nil or does not carry matchID -- a bracket- +// shaped id from a competition with no bracket loaded, or one genuinely +// unknown to it. +func bracketReopenedMatchFor(bracket *state.Bracket, matchID string) ReopenedMatch { + if bm := findBracketMatchInBracket(bracket, matchID); bm != nil { + return bracketMatchRef(bm) + } + return ReopenedMatch{ID: matchID} +} + +// operatorMatchLabel is OperatorMatchLabel's engine-internal, non-tx loader: +// callers OUTSIDE a transaction (requireBlockerHoldsCourt, which the doc +// comment on it notes holds only the court lock) resolve compID/matchID +// through the engine's own store this way rather than duplicating the +// load. Best-effort like OperatorMatchLabel's own contract: a load failure +// degrades to the bare matchID, since every call site here builds +// operator-facing prose, never gates logic on the label. +// +// There is no tx-aware twin: every current caller of this specific method +// runs outside a transaction (bc-twin's "one body, not a hand-copied pair" +// rule has nothing to duplicate until one is needed); a caller already +// holding a state.StoreTx should call OperatorMatchLabel directly with +// values loaded through that handle instead of adding one here unused. +func (e *Engine) operatorMatchLabel(compID, matchID string) string { + return OperatorMatchLabelFromStore(e.store, compID, matchID) +} diff --git a/internal/engine/match_label_test.go b/internal/engine/match_label_test.go new file mode 100644 index 000000000..14354237d --- /dev/null +++ b/internal/engine/match_label_test.go @@ -0,0 +1,112 @@ +package engine + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// UAT (bc-tmfn): correcting "Pool A · Match 1" in a mixed competition raised +// a confirm about "Match 1", the first KNOCKOUT match, which read as the pool +// match on screen: pool matches are numbered from 1 inside each pool. A +// knockout match is therefore named with its round, the heading of the +// bracket column the operator finds it under. +func TestMatchLabel_NamesTheKnockoutRound(t *testing.T) { + assert.Equal(t, "Match 1 (Semifinals)", MatchLabel(ReopenedMatch{ID: "m-r2-0", Number: 1, DisplayRound: 2})) + assert.Equal(t, "Match 3 (Final)", MatchLabel(ReopenedMatch{ID: "m-r3-0", Number: 3, DisplayRound: 1})) + assert.Equal(t, "Match 5 (R16)", MatchLabel(ReopenedMatch{ID: "m-r1-4", Number: 5, DisplayRound: 4})) + // A bracket saved before DisplayRound existed has no round to name, and + // still must not read as a pool match. + assert.Equal(t, "knockout Match 3", MatchLabel(ReopenedMatch{ID: "m-r3-0", Number: 3})) + // The bronze carries DisplayRound -1 and no number: it keeps its name. + assert.Equal(t, "the 3rd-place match", MatchLabel(ReopenedMatch{ID: state.BronzeMatchID, DisplayRound: -1})) +} + +// Every construction site goes through bracketMatchRef, so the round a stored +// bracket match carries reaches the label. +func TestBracketMatchRef_CarriesTheRound(t *testing.T) { + ref := bracketMatchRef(&state.BracketMatch{ID: "m-r2-1", MatchNumber: 2, DisplayRound: 2}) + assert.Equal(t, ReopenedMatch{ID: "m-r2-1", Number: 2, DisplayRound: 2}, ref) + assert.Equal(t, "Match 2 (Semifinals)", MatchLabel(ref)) +} + +// TestOperatorMatchLabel_GoldenTable is the Go half of the shared Go/JS +// table of operator-facing match labels -- see the `_comment` in +// testdata/match_labels.json for the schema and why the pool-phase and +// knockout rows are built differently below. +func TestOperatorMatchLabel_GoldenTable(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(filepath.Join("testdata", "match_labels.json")) + require.NoError(t, err, "shared Go/JS golden table is missing") + + var table struct { + Cases []struct { + ID string `json:"id"` + Format string `json:"format"` + Label string `json:"label"` + MatchNumber int `json:"matchNumber"` + DisplayRound int `json:"displayRound"` + } `json:"cases"` + } + require.NoError(t, json.Unmarshal(raw, &table)) + require.NotEmpty(t, table.Cases, "golden table parsed to zero cases: it would assert nothing") + + for _, tc := range table.Cases { + t.Run(tc.ID, func(t *testing.T) { + t.Parallel() + comp := &state.Competition{Format: tc.Format} + var bracket *state.Bracket + if tc.Format == "knockout" { + bracket = &state.Bracket{} + switch { + case tc.MatchNumber > 0: + bracket.Rounds = [][]state.BracketMatch{{{ + ID: tc.ID, MatchNumber: tc.MatchNumber, DisplayRound: tc.DisplayRound, + }}} + case tc.ID == state.BronzeMatchID: + bracket.ThirdPlaceMatch = &state.BracketMatch{ID: tc.ID} + } + // Anything else (the "unresolved knockout id" case) is left + // out of the bracket entirely, so OperatorMatchLabel falls + // through to MatchLabel's own bare-id fallback. + } + assert.Equal(t, tc.Label, OperatorMatchLabel(comp, bracket, tc.ID), + "Go label disagrees with the shared table; update BOTH spellings, not just this one") + }) + } +} + +// TestRoundLabelFromEnd_GoldenTable is the Go half of the shared Go/JS table +// of knockout round names -- see the `_comment` in testdata/round_labels.json. +// JS half: the "round label Go/JS mirror" describe in +// web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx. +func TestRoundLabelFromEnd_GoldenTable(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(filepath.Join("testdata", "round_labels.json")) + require.NoError(t, err, "shared Go/JS golden table is missing") + + var table struct { + Cases []struct { + DisplayRound int `json:"displayRound"` + Label string `json:"label"` + } `json:"cases"` + } + require.NoError(t, json.Unmarshal(raw, &table)) + require.NotEmpty(t, table.Cases, "golden table parsed to zero cases: it would assert nothing") + + for _, tc := range table.Cases { + t.Run(fmt.Sprintf("displayRound=%d", tc.DisplayRound), func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.Label, roundLabelFromEnd(tc.DisplayRound-1), + "Go round name disagrees with the shared table; update BOTH spellings, not just this one") + }) + } +} diff --git a/internal/engine/match_numbering_parity_test.go b/internal/engine/match_numbering_parity_test.go index de74d5e24..f548e4f08 100644 --- a/internal/engine/match_numbering_parity_test.go +++ b/internal/engine/match_numbering_parity_test.go @@ -52,8 +52,7 @@ func excelNumberBySignature(players []domain.Player) map[string]int { names[i] = p.Name } // Same construction as cmd/create-knockout.go: NewKnockoutDraw normalizes - // the tree through the slot codec (a phantom-risen pair fights in round 1, - // as the reference sheets print), and the workbook numbers the rounds of + // the tree through the slot codec, and the workbook numbers the rounds of // THAT tree. tree := helper.BuildSlotTree(helper.SlotArray(helper.CreateBalancedTree(names))) rounds := helper.BuildEliminationMatchRounds(tree) diff --git a/internal/engine/pool_daihyosen_test.go b/internal/engine/pool_daihyosen_test.go index 68928869f..1e136cb73 100644 --- a/internal/engine/pool_daihyosen_test.go +++ b/internal/engine/pool_daihyosen_test.go @@ -14,37 +14,6 @@ import ( ) // TestIsPoolDaihyosenMatchID covers the ID-recognition helper. -func TestIsPoolDaihyosenMatchID(t *testing.T) { - tests := []struct { - id string - want bool - }{ - {"Pool A-DH-0", true}, - {"Pool A-DH-1", true}, - {"Pool B-DH-42", true}, - {"My-Pool A-DH-0", true}, // hyphenated pool name, strings.Contains handles correctly - {"Pool A-East-DH-0", true}, // realistic hyphenated pool name - {"Pool A-0", false}, - {"Pool A-TB-0", false}, - {"Pool A-DH", false}, // no index after DH (no trailing dash) - {"Pool A-D-0", false}, // different prefix - {"Pool A-DHx-0", false}, // wrong prefix - {"DH-0", false}, // no pool separator - {"", false}, - // A pool literally named "Pool A-DH-East" produces regular match ids - // like "Pool A-DH-East-0". A plain strings.Contains(id, "-DH-") would - // misclassify these as daihyosen bouts; the suffix after the LAST - // "-DH-" here is "East-0", not all-digits, so it must be false. - {"Pool A-DH-East-0", false}, - {"Pool A-DH-East-12", false}, - } - for _, tc := range tests { - t.Run(tc.id, func(t *testing.T) { - assert.Equal(t, tc.want, IsPoolDaihyosenMatchID(tc.id)) - }) - } -} - // TestPoolNameFromMatchID covers the pool-name extraction helper for all ID // forms, including hyphenated pool names and prefix-overlap edge cases. func TestPoolNameFromMatchID(t *testing.T) { diff --git a/internal/engine/pool_rank_override.go b/internal/engine/pool_rank_override.go index 0e624a51c..6e4592db0 100644 --- a/internal/engine/pool_rank_override.go +++ b/internal/engine/pool_rank_override.go @@ -1,10 +1,17 @@ package engine -import "github.com/gitrgoliveira/bracket-creator/internal/helper" +import ( + "errors" + "fmt" + "log" + + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" +) // lookupPoolRankOverride resolves a manual pool-rank override // (state.Overrides.PoolRanks[poolName]) against a single competitor, -// identified by participant id ONLY, exactly as SaveRankOverrideChanged +// identified by participant id ONLY, exactly as SaveRankOverridesChanged // writes it (helper.CompetitorKey(id, "", ""), which resolves to "id:"+id). // A row without an id resolves to nothing (operator ruling bc-pnum): a // legacy bare-name-keyed entry, from an overrides.json written before @@ -18,3 +25,128 @@ func lookupPoolRankOverride(overrides map[string]int, id string) (int, bool) { rank, ok := overrides[helper.CompetitorKey(id, "", "")] return rank, ok } + +// RankOverride is one competitor's manual pool rank: the participant id and +// the rank to record for it. +type RankOverride struct { + PlayerID string + Rank int +} + +// OverridePoolRanks records manual pool ranks for one or more competitors of +// one pool, each identified by participant id only (the chusen door: PUT +// .../pools/:poolId/override-rank), as ONE change: every rank lands together +// and the pool's new order is answered for once, on the final order. A +// chusen's whole order goes through here in one call, because setting its +// ranks one at a time answers for orders nobody chose: changing A=1,B=2,C=3 +// to C=1,B=2,A=3 passes through A=3,B=2,C=3, where B holds 1st and A and C +// tie for 2nd, so the fought knockout match 2nd place feeds was named, and +// reopened on confirmation, although the final order keeps B 2nd. +// +// The answer is the one a pool result correction gets +// (answerRequalification, operator ruling: "Everything should be able to be +// fixed, in case of a wrong entry. The operator just needs to be aware of the +// consequences"). A place whose occupant moves is repainted; a knockout match +// the old qualifier already fought is named and refused until fo.Force +// confirms it, which reopens it with the new qualifier seated; a knockout +// match being fought refuses the override outright. A refusal takes back +// EVERY rank of the group. Before this door went through the planner it +// changed the standings with no answer at all, and the next resolver pass kept +// the played slot's competitor while repainting the pool's other slot to the +// same person (one competitor seated twice). +// +// overrides.json serializes on the store-wide lock, never the per-competition +// one (computeStandingsFrom reads it inside transactions, so moving it would +// self-deadlock), so it is written directly rather than staged: taken inside +// the transaction (compLock then s.mu, the documented order) and put back by +// hand on any refusal, and when the transaction fails without committing. A +// transaction that committed and then failed to apply (state.ErrTxCommitted) +// keeps it: its WAL is replayed on restart, reopening the knockout for this +// order, so taking the override back would repaint the old qualifier over +// that reopen (undoAfterFailedTx). Returns whether the override changed +// anything. The caller validates the group (ids in the pool, no competitor +// or rank twice); an empty group changes nothing. +func (e *Engine) OverridePoolRanks(compID, poolName string, ranks []RankOverride, opts ...ForceOptions) (bool, error) { + if len(ranks) == 0 { + return false, nil + } + fo := firstForceOptions(opts) + var changed bool + var undo func() + var engErr error + txErr := e.store.WithTransaction(compID, func(tx state.StoreTx) error { + changed, undo, engErr = e.overridePoolRanksTx(tx, compID, poolName, ranks, fo) + // A refusal returns its error so nothing the planner staged commits. + return engErr + }) + if engErr != nil { + return false, engErr + } + if txErr != nil { + undoAfterFailedTx(txErr, undo) + return false, txErr + } + return changed, nil +} + +// undoAfterFailedTx runs undo, the hand-made rollback of work a transaction +// did outside its WAL, when txErr says the transaction was dropped, and +// reports whether it ran. A transaction whose WAL committed before its Apply +// failed (state.ErrTxCommitted) was not dropped: the next startup replays it, +// so its outside work must stay to match, and the kept change is logged. +func undoAfterFailedTx(txErr error, undo func()) bool { + if undo == nil { + return false + } + if errors.Is(txErr, state.ErrTxCommitted) { + log.Printf("engine: transaction committed but not applied (%v); its work outside the WAL is kept for the replay on restart", txErr) + return false + } + undo() + return true +} + +// overridePoolRanksTx is OverridePoolRanks' body under the per-competition +// lock. On success it hands back the undo for a transaction that then fails +// without committing (undoAfterFailedTx); every refusal and error has already +// run it. +func (e *Engine) overridePoolRanksTx(tx state.StoreTx, compID, poolName string, ranks []RankOverride, fo ForceOptions) (bool, func(), error) { + comp, err := tx.LoadCompetition(compID) + if err != nil { + return false, nil, err + } + current, err := e.store.LoadOverrides(compID) + if err != nil { + return false, nil, err + } + // Every member's prior value is read before anything is written, so the + // undo puts back the order as it stood, not a half-changed one. + next := make(map[string]int, len(ranks)) + prior := make(map[string]state.PriorRank, len(ranks)) + for _, r := range ranks { + next[r.PlayerID] = r.Rank + var p state.PriorRank + if current != nil { + p.Rank, p.Present = current.PoolRanks[poolName][helper.CompetitorKey(r.PlayerID, "", "")] + } + prior[r.PlayerID] = p + } + changed, err := e.store.SaveRankOverridesChanged(compID, poolName, next) + if err != nil || !changed { + return changed, nil, err + } + undo := func() { + if rerr := e.store.RestoreRankOverrides(compID, poolName, prior); rerr != nil { + log.Printf("engine: OverridePoolRanks %s/%s: putting back the refused ranks failed: %v", compID, poolName, rerr) + } + } + reason := fmt.Sprintf("reopened: the ranking of %s was changed", poolName) + reopened, err := e.answerRequalification(tx, compID, comp, poolName, "", reason, fo.Force, undo) + if err != nil { + return false, nil, err + } + if fo.Reopened != nil { + *fo.Reopened = append(*fo.Reopened, reopened...) + } + return true, undo, nil +} diff --git a/internal/engine/pool_rank_override_test.go b/internal/engine/pool_rank_override_test.go index c47e8b7b3..7586b12e4 100644 --- a/internal/engine/pool_rank_override_test.go +++ b/internal/engine/pool_rank_override_test.go @@ -1,6 +1,8 @@ package engine import ( + "errors" + "fmt" "strconv" "testing" @@ -155,3 +157,33 @@ func TestCalculatePoolStandings_Override_LegacyBareNameKeyIsUnresolvable(t *test assert.False(t, byID["legacy-p1"].IsOverridden, "a legacy bare-name-keyed override no longer resolves (operator ruling bc-pnum)") assert.False(t, byID["legacy-p2"].IsOverridden) } + +// An empty group records nothing and asks nothing of the store. +func TestOverridePoolRanks_EmptyGroupChangesNothing(t *testing.T) { + eng, _, _ := setupTestEngine(t) + changed, err := eng.OverridePoolRanks("no-such-comp", "Pool A", nil) + require.NoError(t, err) + assert.False(t, changed) +} + +// undoAfterFailedTx is OverridePoolRanks' decision after a failed +// transaction. A transaction that committed nothing takes the directly written +// override back. One whose WAL committed before its Apply failed +// (state.ErrTxCommitted, pinned on the real WAL by +// TestWithTransaction_ApplyFailureAfterCommitIsMarked) is replayed on restart, +// reopening the knockout for the new order, so the override stays with it: +// taking it back would let the resolver repaint the old qualifier into the +// reopened match. +func TestUndoAfterFailedTx(t *testing.T) { + calls := 0 + undo := func() { calls++ } + + assert.True(t, undoAfterFailedTx(errors.New(`WithTransaction "c": Commit: disk full`), undo)) + assert.Equal(t, 1, calls, "an uncommitted transaction takes the override back") + + committed := fmt.Errorf(`WithTransaction "c": Apply: %w (%w)`, errors.New("disk full"), state.ErrTxCommitted) + assert.False(t, undoAfterFailedTx(committed, undo)) + assert.Equal(t, 1, calls, "a committed transaction keeps the override for its replay") + + assert.False(t, undoAfterFailedTx(errors.New("refused before any write"), nil), "nothing to undo") +} diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go new file mode 100644 index 000000000..bf35754d0 --- /dev/null +++ b/internal/engine/pool_requalify.go @@ -0,0 +1,412 @@ +package engine + +// pool_requalify.go keeps a mixed competition's knockout seated with the +// competitors its pools actually produced when a pool result is CORRECTED +// after the knockout has been seeded, or when a pool's order is set by hand +// (a pool-rank override, OverridePoolRanks). Both doors answer through +// answerRequalification; the bare-store resolver (ResolveQualifiedPools) is +// only the safety net behind them, all-or-nothing per pool (resolveSlots). +// +// Operator rulings: "Save correction should just save what the operator +// enters"; "Everything should be able to be fixed, in case of a wrong entry. +// The operator just needs to be aware of the consequences, if it affects +// downstream matches"; and, for a pool fix that changes who qualified after +// the knockout started: warn, naming the affected knockout matches, and on +// "Proceed anyway" reopen them and seat the new qualifier. +// +// The change is measured against the BRACKET, not against the standings +// before the write: the question is "does the competitor sitting in each slot +// this pool feeds still hold the place that seated them?", and only the +// bracket knows who is sitting there. Comparing standings before and after +// missed a 1st/2nd swap (the same two people still qualify), the "-2nd" slots +// of the extra-qualifier modes, a correction made while the knockout was still +// untouched (nothing re-seated it once the competition reached knockout +// status), and a correction finished after a reopen (the reopened match had +// already dropped out of the "before" standings). + +import ( + "fmt" + "log" + "sort" + + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// poolWriteCanMoveQualifiers reports whether a write of matchID that leaves it +// in status can change who holds one of its pool's qualifying places: a pool +// match (a tie-break or pool daihyosen row included, since those decide places +// too) left completed. A running or scheduled write (the live autosave, an +// undo, a reopen) leaves its pool incomplete, and an incomplete pool seats +// nobody, so it can move no qualifier. The ONE statement of that rule, shared +// by the in-transaction planner's door (requalifyAfterPoolWrite) and the +// after-write auto-complete (MaybeAutoCompletePoolsAfterWrite), so the two +// cannot disagree about which writes are worth the pool reads. +func poolWriteCanMoveQualifiers(matchID string, status state.MatchStatus) bool { + return IsPoolMatchID(matchID) && status != state.MatchStatusRunning && status != state.MatchStatusScheduled +} + +// requalifyPlan is what a pool change means for the knockout it feeds. +type requalifyPlan struct { + // resolver maps each of the written pool's qualifying labels onto who + // should now sit in its slots; a place the corrected standings leave tied + // maps onto the label itself, the value the slot held at draw time. + resolver map[string]resolvedFinisher + // repaint reports that some slot's (name, id) differs from the resolver, + // including an id backfill that changes nobody. + repaint bool + // changes are the places whose OCCUPANT changes, by rank. + changes []QualifierChange + // affected are the matches (in bracket order) that the old occupant has + // already fought: closed with a result of their own. + affected []string + blocking []ReopenedMatch + // displaced is the competitor sitting in blocking[0]'s moved slot: the + // name the operator sees in that match, which is not necessarily the + // first place's old occupant (only the 2nd-place match may be played). + displaced string + // running are the matches the move reaches that are being fought now. + running []ReopenedMatch +} + +// planRequalification reads, inside the write's own transaction and AFTER the +// pool write, what that write does to the knockout. nil means nothing: a +// non-mixed competition, a pool not complete (a reopened match, a tie-break +// still unplayed), a bracket with no draw labels yet (the bare-store resolver +// backfills those first), or no slot whose occupant moves. An engi (kata) +// competition is planned like any other; only the tie check is skipped for it +// (see below). +// +// Only OCCUPIED slots are considered. A slot still holding its label is being +// seated for the first time, which stays advanceMixedPools' job after the +// transaction: seating it here would leave that door nothing to report, and +// the "knockout match now playable" broadcast it sends would never go out. +func (e *Engine) planRequalification(tx state.StoreTx, compID string, comp *state.Competition, poolName string) (*requalifyPlan, error) { + if comp == nil || comp.Format != state.CompFormatMixed { + return nil, nil + } + bracket, err := tx.LoadBracket(compID) + if err != nil { + // Fail closed: a bracket that cannot be read cannot be shown safe. + return nil, fmt.Errorf("requalification: load bracket for %s: %w", compID, err) + } + if bracket == nil || len(bracket.Rounds) == 0 { + return nil, nil + } + if !bracketHasDrawPlaceholders(bracket) { + log.Printf("engine: requalification skipped for %s: its bracket predates draw labels; the pool resolver backfills them on its next run", compID) + return nil, nil + } + // Standings are computed at most once, and only when needed: an + // individual pool's completion does not read them, so an incomplete one + // returns before paying for them. + var standings map[string][]state.PlayerStanding + standingsOnce := func() (map[string][]state.PlayerStanding, error) { + if standings != nil { + return standings, nil + } + st, serr := e.computeStandingsFrom(tx, compID) + if serr != nil { + return nil, fmt.Errorf("requalification: standings for %s: %w", compID, serr) + } + standings = st + return st, nil + } + completed, err := e.poolCompletion(tx, compID, comp, standingsOnce) + if err != nil { + return nil, fmt.Errorf("requalification: pool completion for %s: %w", compID, err) + } + if !completed[poolName] { + return nil, nil + } + if _, err := standingsOnce(); err != nil { + return nil, err + } + pools, err := tx.LoadPools(compID) + if err != nil { + return nil, fmt.Errorf("requalification: load pools for %s: %w", compID, err) + } + var pool *helper.Pool + for i := range pools { + if pools[i].PoolName == poolName { + pool = &pools[i] + break + } + } + if pool == nil { + return nil, nil + } + ranksNeeded := comp.MatchWinnerRanksNeeded() + resolver := qualifierResolver([]helper.Pool{*pool}, map[string]bool{poolName: true}, standings, ranksNeeded) + + // A tie the corrected standings leave on a place the injectors would break + // is not a finisher: that place returns to its label until the tie-break + // is fought, and advanceMixedPools seats the winner then. + // + // Not for engi: it has no supplementary bouts (the injectors heal any + // that appear), and every engi standing carries Points 0, so the tie + // check would read every place as tied and send each slot back to its + // label. Engi places are decided by the standings' own order. + tied := map[string]bool{} + if !comp.Engi { + tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, standings[poolName], ranksNeeded) + if err != nil { + return nil, err + } + } + for label := range tied { + resolver[label] = resolvedFinisher{Name: label} + } + + plan := &requalifyPlan{resolver: resolver} + byLabel := map[string]*QualifierChange{} + seenAffected := map[string]bool{} + seenRunning := map[string]bool{} + visit := func(m *state.BracketMatch) { + check := func(label, name, id string) { + rf, ok := resolver[label] + if !ok || (name == rf.Name && id == rf.ID) || isUnresolvedBracketSide(name) { + return + } + plan.repaint = true + if !occupantChanges(name, id, rf) { + return // an id backfill: the same competitor stays + } + if byLabel[label] == nil { + rank := rankOfLabel(poolName, label, ranksNeeded) + c := QualifierChange{Pool: poolName, Rank: rank, Place: helper.GetOrdinal(rank), From: QualifierIdentity{Name: name, ID: id}, Tied: tied[label]} + if !c.Tied { + c.To = QualifierIdentity(rf) + } + byLabel[label] = &c + } + ref := bracketMatchRef(m) + switch { + case m.Status == state.MatchStatusRunning: + if !seenRunning[m.ID] { + seenRunning[m.ID] = true + plan.running = append(plan.running, ref) + } + case bracketMatchCarriesOwnResult(m): + if !seenAffected[m.ID] { + seenAffected[m.ID] = true + if len(plan.blocking) == 0 { + plan.displaced = name + } + plan.affected = append(plan.affected, m.ID) + plan.blocking = append(plan.blocking, ref) + } + } + } + check(m.PlaceholderA, m.SideA, m.SideAID) + check(m.PlaceholderB, m.SideB, m.SideBID) + check(m.PlaceholderWinner, m.Winner, m.WinnerID) + } + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + visit(&bracket.Rounds[ri][mi]) + } + } + if bracket.ThirdPlaceMatch != nil { + visit(bracket.ThirdPlaceMatch) + } + if !plan.repaint { + return nil, nil + } + for _, c := range byLabel { + plan.changes = append(plan.changes, *c) + } + sort.Slice(plan.changes, func(i, j int) bool { return plan.changes[i].Rank < plan.changes[j].Rank }) + return plan, nil +} + +// occupantChanges reports whether seating rf in a slot now holding (name, id) +// replaces its competitor. By id when both carry one; by name otherwise, the +// BracketMatch carve-out for the id-less shapes (a slot resolved before +// bracket ids existed keeps its name and gains its id, which moves nobody). +// +// Known limit, accepted: on a slot that still carries no id (a legacy bracket +// resolved before per-side ids existed, not yet backfilled) the comparison is +// by NAME, so two namesakes from different dojos, which the roster allows, are +// indistinguishable there. A correction that swaps one namesake for the other +// in such a slot reads as no move: nothing is warned about or reopened, and an +// unplayed slot simply takes the new one's id. The first resolver pass over a pool +// stamps its slots' ids, so the window is a bracket drawn before bc-brid whose +// pool has not been looked at since. +func occupantChanges(name, id string, rf resolvedFinisher) bool { + if id != "" && rf.ID != "" { + return id != rf.ID + } + return name != rf.Name +} + +// rankOfLabel returns the rank a pool's label stands for ("Pool A-2nd" -> 2). +func rankOfLabel(poolName, label string, ranksNeeded int) int { + for rank := 1; rank <= ranksNeeded; rank++ { + if qualifierLabel(poolName, rank) == label { + return rank + } + } + return 0 +} + +// tiedQualifyingLabels returns the pool's qualifying labels whose rank sits in +// a tied group that still owes a supplementary bout (pendingTieBreaks, the +// injectors' own rule), read through the transaction. +func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *state.Competition, poolName string, poolStandings []state.PlayerStanding, ranksNeeded int) (map[string]bool, error) { + rows, err := tx.LoadPoolMatches(compID) + if err != nil { + return nil, fmt.Errorf("requalification: load pool matches for %s: %w", compID, err) + } + daihyosen := comp.TeamSize > 0 + var existing []state.MatchResult + court, courtSet := "", false + for _, m := range rows { + if pn, ok := poolNameFromMatchID(m.ID); !ok || pn != poolName { + continue + } + if !courtSet { + court, courtSet = m.Court, true + } + if (daihyosen && IsPoolDaihyosenMatchID(m.ID)) || (!daihyosen && IsTiebreakerMatchID(m.ID)) { + existing = append(existing, m) + } + } + tied := map[string]bool{} + for _, p := range pendingTieBreaks(comp, poolName, poolStandings, existing, court, daihyosen) { + for _, pos := range p.positions { + if pos+1 <= ranksNeeded { + tied[qualifierLabel(poolName, pos+1)] = true + } + } + } + return tied, nil +} + +// applyRequalification writes a plan the operator has cleared (nothing +// affected, or confirmed): every affected match is reopened for re-entry, the +// winner each had propagated is taken back out of a next round nobody has +// touched, and every slot of the pool is repainted. The eligibility half of +// each reopen runs AFTER the bracket write, never inside its callback, where +// the bare store's per-competition lock is already held. +func (e *Engine) applyRequalification(tx state.StoreTx, compID, reason string, plan *requalifyPlan) ([]ReopenedMatch, error) { + var reopened []ReopenedMatch + err := tx.UpdateBracket(compID, func(b *state.Bracket) error { + if b == nil { + return errMatchNotFound + } + reopened = nil + for _, id := range plan.affected { + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + if b.Rounds[ri][mi].ID != id { + continue + } + reopened = append(reopened, reopenDisplacedBracketMatch(&b.Rounds[ri][mi], reason)) + retractIntoUntouched(b, ri, mi) + } + } + if b.ThirdPlaceMatch != nil && b.ThirdPlaceMatch.ID == id { + reopened = append(reopened, reopenDisplacedBracketMatch(b.ThirdPlaceMatch, reason)) + } + } + e.resolveSlots(b, plan.resolver) + return nil + }) + if err != nil { + return nil, fmt.Errorf("requalification: update bracket for %s: %w", compID, err) + } + e.restoreForceReopened(tx, compID, reopened) + return reopened, nil +} + +// requalifyMixedPoolWrite is the pool-write door's call into +// requalifyAfterPoolWrite, shared by the kendo path and the engi seam of +// RecordMatchResultWithIneligibilityTx so both answer a pool correction the +// same way: nothing for a competition that is not mixed, and what the forced +// correction reopened appended to fo.Reopened. +func (e *Engine) requalifyMixedPoolWrite(tx state.StoreTx, compID string, comp *state.Competition, matchID string, written, prior *state.MatchResult, fo ForceOptions) error { + if comp == nil || comp.Format != state.CompFormatMixed { + return nil + } + requalified, err := e.requalifyAfterPoolWrite(tx, compID, comp, matchID, written, prior, fo.Force) + if err != nil { + return err + } + if fo.Reopened != nil && len(requalified) > 0 { + *fo.Reopened = append(*fo.Reopened, requalified...) + } + return nil +} + +// requalifyAfterPoolWrite is the pool-write door's whole knockout rule, run +// after the write landed and before anything else answers for it: a write +// that cannot move a qualifier (poolWriteCanMoveQualifiers) is let through, +// and any other is answered by answerRequalification, with rolling the pool +// row back to prior as its undo. It returns the matches it reopened. +func (e *Engine) requalifyAfterPoolWrite(tx state.StoreTx, compID string, comp *state.Competition, matchID string, result, prior *state.MatchResult, force bool) ([]ReopenedMatch, error) { + if !poolWriteCanMoveQualifiers(matchID, result.Status) { + return nil, nil + } + poolName, ok := poolNameFromMatchID(matchID) + if !ok { + return nil, nil + } + rollback := func() { + if prior != nil { + e.rollbackMatchResultTx(tx, compID, matchID, prior) + } + } + return e.answerRequalification(tx, compID, comp, poolName, matchID, downstreamReopenReason(matchID), force, rollback) +} + +// answerRequalification is the knockout rule shared by every door that moves +// a pool's standings inside a transaction: a pool match written +// (requalifyAfterPoolWrite) or a pool rank set by hand (OverridePoolRanks). It +// runs after the change landed and before anything else answers for it. +// matchID is the pool match a correction wrote, "" for a door with no match; +// reason is the note each reopened match carries; undo takes the change back +// and runs before every refusal or error: +// +// - a match the move reaches is being fought: DownstreamKnockoutRunningError, +// never confirmable (checked first, so the operator is never asked to +// confirm something that would then be refused); +// - a match the old qualifier already fought, without force: +// DownstreamKnockoutPlayedError naming them and the places that move; +// - otherwise the plan is applied (with force, the fought matches reopen). +func (e *Engine) answerRequalification(tx state.StoreTx, compID string, comp *state.Competition, poolName, matchID, reason string, force bool, undo func()) ([]ReopenedMatch, error) { + plan, err := e.planRequalification(tx, compID, comp, poolName) + if err != nil { + undo() + return nil, err + } + if plan == nil { + return nil, nil + } + if len(plan.running) > 0 { + undo() + return nil, &DownstreamKnockoutRunningError{MatchID: matchID, Running: plan.running} + } + if len(plan.affected) > 0 && !force { + undo() + return nil, &DownstreamKnockoutPlayedError{ + MatchID: matchID, + // PURE: comp is already loaded and passed in, so this needs no + // store read (bc-cse item 14 -- see the struct doc on Label for + // why that matters here, inside a live transaction). matchID="" + // (OverridePoolRanks) resolves to "" too, harmlessly: Error()'s + // pool-rank-override branch never reads Label. + Label: OperatorMatchLabel(comp, nil, matchID), + BlockingMatchID: plan.blocking[0].ID, + Blocking: plan.blocking, + Displaced: plan.displaced, + QualifierChange: plan.changes, + } + } + reopened, err := e.applyRequalification(tx, compID, reason, plan) + if err != nil { + undo() + return nil, err + } + return reopened, nil +} diff --git a/internal/engine/pool_requalify_test.go b/internal/engine/pool_requalify_test.go new file mode 100644 index 000000000..591c95ba1 --- /dev/null +++ b/internal/engine/pool_requalify_test.go @@ -0,0 +1,776 @@ +package engine + +import ( + "fmt" + "testing" + "time" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rqFix is a mixed (pools then knockout) individual competition built the way +// a real draw builds it: round-robin pool matches with ids stamped, and a +// knockout from buildBracketFromDraw, so every slot carries its draw label. +type rqFix struct { + t *testing.T + eng *Engine + store *state.Store + compID string +} + +func rqID(name string) string { return "id-" + name } + +// newRQFixture builds pools named "Pool A", "Pool B", ... from names, each +// player "id-" from "Dojo ". The knockout is built from the draw; +// a test needing labels this draw builder does not seat saves its own over it. +func newRQFixture(t *testing.T, compID string, poolWinners int, names [][]string, opts ...func(*state.Competition)) *rqFix { + t.Helper() + eng, store, _ := setupTestEngine(t) + comp := &state.Competition{ + ID: compID, Name: compID, Kind: "individual", + Format: state.CompFormatMixed, Status: state.CompStatusPools, + Courts: []string{"A"}, StartTime: "09:00", PoolWinners: poolWinners, + } + for _, o := range opts { + o(comp) + } + require.NoError(t, store.SaveCompetition(comp)) + + var pools []helper.Pool + var roster []domain.Player + var matches []state.MatchResult + for pi, pn := range names { + pool := helper.Pool{PoolName: fmt.Sprintf("Pool %c", 'A'+pi)} + for _, n := range pn { + p := domain.Player{ID: rqID(n), Name: n, Dojo: "Dojo " + n} + pool.Players = append(pool.Players, p) + roster = append(roster, p) + } + k := 0 + for i := 0; i < len(pn); i++ { + for j := i + 1; j < len(pn); j++ { + matches = append(matches, state.MatchResult{ + ID: fmt.Sprintf("%s-%d", pool.PoolName, k), + SideA: pn[i], SideB: pn[j], SideAID: rqID(pn[i]), SideBID: rqID(pn[j]), + Status: state.MatchStatusScheduled, + }) + k++ + } + } + pools = append(pools, pool) + } + require.NoError(t, store.SavePools(compID, pools)) + require.NoError(t, store.SaveParticipants(compID, roster)) + require.NoError(t, store.SavePoolMatches(compID, matches)) + + draw := helper.BuildKnockoutDraw(pools, poolWinners, 1) + stored, err := store.LoadCompetition(compID) + require.NoError(t, err) + bracket, err := eng.buildBracketFromDraw(stored, draw, nil) + require.NoError(t, err) + require.NoError(t, store.SaveBracket(compID, bracket)) + return &rqFix{t: t, eng: eng, store: store, compID: compID} +} + +// poolResult is a completed result for a stored pool match, won by winner +// with one men, stamped with ids from the stored row. +func (f *rqFix) poolResult(matchID, winner string) *state.MatchResult { + f.t.Helper() + m := loadPoolMatchByID(f.t, f.store, f.compID, matchID) + require.NotNil(f.t, m, matchID) + r := &state.MatchResult{SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, Status: state.MatchStatusCompleted} + switch winner { + case m.SideA: + r.Winner, r.WinnerID, r.IpponsA = m.SideA, m.SideAID, []string{"M"} + case m.SideB: + r.Winner, r.WinnerID, r.IpponsB = m.SideB, m.SideBID, []string{"M"} + default: + f.t.Fatalf("%s is not in %s", winner, matchID) + } + return r +} + +// write runs one score write through the transaction door every handler uses. +func (f *rqFix) write(matchID string, r *state.MatchResult, fo ...ForceOptions) error { + f.t.Helper() + var werr error + require.NoError(f.t, f.store.WithTransaction(f.compID, func(tx state.StoreTx) error { + _, werr = f.eng.RecordMatchResultWithIneligibilityTx(tx, f.compID, matchID, r, fo...) + return nil + })) + return werr +} + +func (f *rqFix) scorePool(matchID, winner string) { + f.t.Helper() + require.NoError(f.t, f.write(matchID, f.poolResult(matchID, winner))) +} + +func (f *rqFix) resolve() { + f.t.Helper() + _, _, err := f.eng.ResolveQualifiedPools(f.compID) + require.NoError(f.t, err) +} + +func (f *rqFix) bracket() *state.Bracket { + f.t.Helper() + b, err := f.store.LoadBracket(f.compID) + require.NoError(f.t, err) + return b +} + +// slot finds the round-0 match seated for label and which side holds it. +func (f *rqFix) slot(label string) (state.BracketMatch, string) { + f.t.Helper() + for _, m := range f.bracket().Rounds[0] { + if m.PlaceholderA == label { + return m, "A" + } + if m.PlaceholderB == label { + return m, "B" + } + } + f.t.Fatalf("no round-0 slot for %s", label) + return state.BracketMatch{}, "" +} + +func sideOf(m state.BracketMatch, side string) (string, string) { + if side == "A" { + return m.SideA, m.SideAID + } + return m.SideB, m.SideBID +} + +// scoreKO completes a knockout match for winner (a name on one of its sides). +func (f *rqFix) scoreKO(matchID, winner string) error { + f.t.Helper() + m := findBracketMatchInBracket(f.bracket(), matchID) + require.NotNil(f.t, m, matchID) + r := &state.MatchResult{SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, Status: state.MatchStatusCompleted} + switch winner { + case m.SideA: + r.Winner, r.WinnerID, r.IpponsA = m.SideA, m.SideAID, []string{"M"} + case m.SideB: + r.Winner, r.WinnerID, r.IpponsB = m.SideB, m.SideBID, []string{"M"} + default: + f.t.Fatalf("%s is not in %s (%s vs %s)", winner, matchID, m.SideA, m.SideB) + } + return f.write(matchID, r) +} + +// A 1st/2nd swap leaves the same two competitors qualified, so a comparison of +// the qualifying SET before and after the write saw no change at all, and both +// knockout slots went on showing the old places. Measured against the +// bracket, both places move and the played match is named; confirmed, it is +// reopened, both slots are repainted, and the untouched final no longer shows +// the old winner as if it were playable. +func TestRequalify_FirstSecondSwap_WarnsThenReopensAndRepaints(t *testing.T) { + f := newRQFixture(t, "rq-swap", 2, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + + m1, s1 := f.slot("Pool A-1st") + name, _ := sideOf(m1, s1) + require.Equal(t, "A1", name) + require.NoError(t, f.scoreKO(m1.ID, "A1")) + + err := f.write("Pool A-0", f.poolResult("Pool A-0", "A2")) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + assert.Equal(t, "Pool A-0", played.MatchID) + require.Len(t, played.Blocking, 1, "only the match A1 already fought is named; the scheduled one is simply repainted") + assert.Equal(t, m1.ID, played.Blocking[0].ID) + require.Len(t, played.QualifierChange, 2) + assert.Equal(t, QualifierChange{Pool: "Pool A", Rank: 1, Place: "1st", From: QualifierIdentity{Name: "A1", ID: rqID("A1")}, To: QualifierIdentity{Name: "A2", ID: rqID("A2")}}, played.QualifierChange[0]) + assert.Equal(t, QualifierChange{Pool: "Pool A", Rank: 2, Place: "2nd", From: QualifierIdentity{Name: "A2", ID: rqID("A2")}, To: QualifierIdentity{Name: "A1", ID: rqID("A1")}}, played.QualifierChange[1]) + assert.Equal(t, "A1", loadPoolMatchByID(t, f.store, f.compID, "Pool A-0").Winner, "refused until confirmed") + + var reopened []ReopenedMatch + require.NoError(t, f.write("Pool A-0", f.poolResult("Pool A-0", "A2"), ForceOptions{Force: true, Reopened: &reopened})) + require.Len(t, reopened, 1) + assert.Equal(t, m1.ID, reopened[0].ID) + assert.Equal(t, m1.MatchNumber, reopened[0].Number) + + b := f.bracket() + got := findBracketMatchInBracket(b, m1.ID) + assert.Equal(t, state.MatchStatusScheduled, got.Status) + assert.Empty(t, got.Winner) + assert.Empty(t, got.IpponsA) + n, id := sideOf(*got, s1) + assert.Equal(t, "A2", n) + assert.Equal(t, rqID("A2"), id) + m2, s2 := f.slot("Pool A-2nd") + n, id = sideOf(m2, s2) + assert.Equal(t, "A1", n) + assert.Equal(t, rqID("A1"), id) + + final := b.Rounds[1][0] + assert.Equal(t, state.MatchStatusScheduled, final.Status) + for _, side := range []string{final.SideA, final.SideB} { + assert.NotEqual(t, "A1", side, "the untouched final must not keep the old qualifier as if playable") + } +} + +// When only the 2nd-place match was played, the competitor named as displaced +// is the one sitting in THAT match (the old 2nd), not the old 1st: the +// refusal and the queued-replay copy say " already played ". +func TestRequalify_FirstSecondSwap_DisplacedIsWhoPlayed(t *testing.T) { + f := newRQFixture(t, "rq-swap2", 2, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + m2, _ := f.slot("Pool A-2nd") + require.NoError(t, f.scoreKO(m2.ID, "A2")) + + err := f.write("Pool A-0", f.poolResult("Pool A-0", "A2")) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Len(t, played.Blocking, 1) + assert.Equal(t, m2.ID, played.BlockingMatchID) + assert.Equal(t, "A2", played.Displaced) + assert.Contains(t, played.Error(), "was already fought") +} + +// Under the extra-qualifier modes the knockout seats "-2nd" slots while +// PoolWinners stays 1, so a guard counting only the top PoolWinners never saw +// a 2nd-place change. The bracket-baseline check sees every label the draw +// seated. +func TestRequalify_ExtraQualifierSecond_Warns(t *testing.T) { + f := newRQFixture(t, "rq-extra", 1, [][]string{{"A1", "A2", "A3"}, {"B1", "B2", "B3"}}, func(c *state.Competition) { + c.ExtraQualifiers = state.ExtraQualifiersFillBracket + }) + // Seat the "-2nd" labels by hand: this draw builder only seats winners. + require.NoError(t, f.store.SaveBracket(f.compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "m-r1-0", MatchNumber: 1, SideA: "Pool A-1st", SideB: "Pool B-2nd", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-2nd"}, + {ID: "m-r1-1", MatchNumber: 2, SideA: "Pool B-1st", SideB: "Pool A-2nd", PlaceholderA: "Pool B-1st", PlaceholderB: "Pool A-2nd"}, + }, + {{ID: "m-r2-0", MatchNumber: 3, SideA: winnerOfPlaceholder(2, 0), SideB: winnerOfPlaceholder(2, 1), PlaceholderA: winnerOfPlaceholder(2, 0), PlaceholderB: winnerOfPlaceholder(2, 1)}}, + }})) + // A1 > A2 > A3; B1 > B2 > B3. + for _, w := range [][2]string{{"Pool A-0", "A1"}, {"Pool A-1", "A1"}, {"Pool A-2", "A2"}, {"Pool B-0", "B1"}, {"Pool B-1", "B1"}, {"Pool B-2", "B2"}} { + f.scorePool(w[0], w[1]) + } + f.resolve() + require.Equal(t, "A2", findBracketMatchInBracket(f.bracket(), "m-r1-1").SideB) + require.NoError(t, f.scoreKO("m-r1-1", "B1")) + + // A3 now beats A2: A3 is 2nd. + err := f.write("Pool A-2", f.poolResult("Pool A-2", "A3")) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Len(t, played.QualifierChange, 1) + assert.Equal(t, 2, played.QualifierChange[0].Rank) + assert.Equal(t, "A2", played.QualifierChange[0].From.Name) + assert.Equal(t, "A3", played.QualifierChange[0].To.Name) + assert.Equal(t, "m-r1-1", played.BlockingMatchID) +} + +// A match decided by a withdrawal and then reopened by a pool correction no +// longer carries that withdrawal, so the competitor it barred is restored and +// reported for the broadcast. +func TestRequalify_ForcedReopenRestoresWithdrawalEligibility(t *testing.T) { + f := newRQFixture(t, "rq-kiken", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + ko := f.bracket().Rounds[0][0] + // B1 withdraws from the knockout match against A1. decisionBy names the + // withdrawing side: aka is SideA, shiro is SideB. + decisionBy := "shiro" + if ko.SideA == "B1" { + decisionBy = "aka" + } + _, st, err := f.eng.RecordDecision(f.compID, ko.ID, "kiken-voluntary", decisionBy, "", nil, false) + require.NoError(t, err) + require.NotNil(t, st) + require.Equal(t, rqID("B1"), st.PlayerID) + + var reopened []ReopenedMatch + require.NoError(t, f.write("Pool A-0", f.poolResult("Pool A-0", "A2"), ForceOptions{Force: true, Reopened: &reopened})) + require.Len(t, reopened, 1) + require.NotNil(t, reopened[0].Restored, "the withdrawal the reopen removed bars nobody") + assert.Equal(t, rqID("B1"), reopened[0].Restored.PlayerID) + assert.True(t, reopened[0].Restored.Eligible) + statuses, err := f.store.LoadCompetitorStatus(f.compID) + require.NoError(t, err) + assert.True(t, statuses[rqID("B1")].Eligible) + assert.Empty(t, findBracketMatchInBracket(f.bracket(), ko.ID).Decision) +} + +// A qualifier seated through a bye sits in two slots: the bye match's winner +// and the next round's side. A correction made once the competition reached +// knockout status repaints both. +func TestRequalify_ByeLeafRepaintsNextRound(t *testing.T) { + f := newRQFixture(t, "rq-bye", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}, {"C1", "C2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.scorePool("Pool C-0", "C1") + f.resolve() + setCompStatus(t, f.store, f.compID, state.CompStatusKnockout) + + var bye state.BracketMatch + for _, m := range f.bracket().Rounds[0] { + if m.SideA == "" || m.SideB == "" { + bye = m + } + } + require.NotEmpty(t, bye.ID, "three qualifiers in a four-slot draw leave one bye") + label := bye.PlaceholderWinner + require.NotEmpty(t, label) + pool := label[:len("Pool X")] + winner, loser := string(pool[5])+"1", string(pool[5])+"2" + require.Equal(t, winner, bye.Winner) + + require.NoError(t, f.write(pool+"-0", f.poolResult(pool+"-0", loser))) + + b := f.bracket() + gotBye := findBracketMatchInBracket(b, bye.ID) + assert.Equal(t, loser, gotBye.Winner) + assert.Equal(t, rqID(loser), gotBye.WinnerID) + final := b.Rounds[1][0] + seated := final.SideA + seatedID := final.SideAID + if final.PlaceholderB == label { + seated, seatedID = final.SideB, final.SideBID + } + assert.Equal(t, loser, seated) + assert.Equal(t, rqID(loser), seatedID) +} + +// A correction that leaves a qualifying place tied does not pick a finisher by +// sort order: the slot returns to its draw label until the tie-break is +// fought, which is then injected and seated even though the competition had +// already reached knockout status. +func TestRequalify_TieRevertsToLabelThenTieBreakSeats(t *testing.T) { + f := newRQFixture(t, "rq-tie", 1, [][]string{{"A1", "A2", "A3"}, {"B1", "B2"}}) + // Pool A-0: A1 v A2, A-1: A1 v A3, A-2: A2 v A3. + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool A-1", "A1") + f.scorePool("Pool A-2", "A2") + f.scorePool("Pool B-0", "B1") + f.resolve() + setCompStatus(t, f.store, f.compID, state.CompStatusKnockout) + m, side := f.slot("Pool A-1st") + name, _ := sideOf(m, side) + require.Equal(t, "A1", name) + + // A3 beats A1: a three-way tie on one win each. + require.NoError(t, f.write("Pool A-1", f.poolResult("Pool A-1", "A3"))) + m, _ = f.slot("Pool A-1st") + name, id := sideOf(m, side) + assert.Equal(t, "Pool A-1st", name, "a tied place is not a finisher") + assert.Empty(t, id) + + _, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + var tbs []state.MatchResult + matches, err := f.store.LoadPoolMatches(f.compID) + require.NoError(t, err) + for _, pm := range matches { + if IsTiebreakerMatchID(pm.ID) { + tbs = append(tbs, pm) + } + } + require.NotEmpty(t, tbs, "the tie-break is injected in knockout status") + for _, tb := range tbs { + winner := tb.SideA + if tb.SideB == "A3" || (tb.SideA != "A3" && tb.SideB == "A1") { + winner = tb.SideB + } + f.scorePool(tb.ID, winner) + } + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + m, _ = f.slot("Pool A-1st") + name, id = sideOf(m, side) + assert.Equal(t, "A3", name, "the tie-break winner is seated") + assert.Equal(t, rqID("A3"), id) +} + +// Reopening a withdrawal-decided pool match is allowed even though its +// finisher has played the knockout: the reopen moves no slot. The write that +// finishes it decides: a different result names the played match, the same +// result is silent. +func TestRequalify_ReopenThenFinish(t *testing.T) { + f := newRQFixture(t, "rq-reopen", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + // A2 (shiro, SideB) withdraws from Pool A-0, so A1 qualifies. + _, _, err := f.eng.RecordDecision(f.compID, "Pool A-0", "kiken-voluntary", "shiro", "", nil, false) + require.NoError(t, err) + f.scorePool("Pool B-0", "B1") + f.resolve() + ko := f.bracket().Rounds[0][0] + require.NoError(t, f.scoreKO(ko.ID, "B1")) + + _, err = f.eng.ReopenMatch(f.compID, "Pool A-0", "withdrawal recorded by mistake") + require.NoError(t, err, "a pool reopen moves no knockout slot") + + err = f.write("Pool A-0", f.poolResult("Pool A-0", "A2")) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played, "finishing with a different result must name the played match") + assert.Equal(t, ko.ID, played.BlockingMatchID) + assert.Equal(t, state.MatchStatusRunning, loadPoolMatchByID(t, f.store, f.compID, "Pool A-0").Status, "the refused finish leaves the reopened match open") + + require.NoError(t, f.write("Pool A-0", f.poolResult("Pool A-0", "A1")), "the same result moves nobody") + assert.Equal(t, state.MatchStatusCompleted, findBracketMatchInBracket(f.bracket(), ko.ID).Status, "the played match is untouched") +} + +// A queued write replayed after a newer result was stored is superseded before +// anything looks at the knockout, forced or not: nothing is reopened. +func TestRequalify_StaleReplayIsSuperseded(t *testing.T) { + f := newRQFixture(t, "rq-stale", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + r := f.poolResult("Pool A-0", "A1") + r.ModifiedAt = 5000 + require.NoError(t, f.write("Pool A-0", r)) + f.scorePool("Pool B-0", "B1") + f.resolve() + ko := f.bracket().Rounds[0][0] + require.NoError(t, f.scoreKO(ko.ID, "A1")) + + stale := f.poolResult("Pool A-0", "A2") + stale.ModifiedAt = 1000 + err := f.write("Pool A-0", stale, ForceOptions{Force: true}) + require.ErrorIs(t, err, ErrMatchSuperseded) + got := findBracketMatchInBracket(f.bracket(), ko.ID) + assert.Equal(t, state.MatchStatusCompleted, got.Status) + assert.Equal(t, "A1", got.Winner) +} + +// The bare-store resolver never repaints a slot in a match that was already +// fought: standings can move without a correcting write (a pool-rank override, +// or a file edited by hand here), and a played match must not have its +// competitor swapped under its own verdict. +func TestResolveQualifiedPools_SkipsPlayedMatch(t *testing.T) { + f := newRQFixture(t, "rq-skip", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + ko := f.bracket().Rounds[0][0] + require.NoError(t, f.scoreKO(ko.ID, "A1")) + + matches, err := f.store.LoadPoolMatches(f.compID) + require.NoError(t, err) + for i := range matches { + if matches[i].ID == "Pool A-0" { + matches[i].Winner, matches[i].WinnerID = "A2", rqID("A2") + matches[i].IpponsA, matches[i].IpponsB = nil, []string{"M"} + } + } + require.NoError(t, f.store.SavePoolMatches(f.compID, matches)) + f.resolve() + + got := findBracketMatchInBracket(f.bracket(), ko.ID) + assert.Contains(t, []string{got.SideA, got.SideB}, "A1", "a played match keeps the competitor who fought it") + assert.NotContains(t, []string{got.SideA, got.SideB}, "A2") +} + +// The resolver's safety net must be all-or-nothing PER POOL. Skipping only the +// locked slot while repainting the pool's others seated one competitor twice: +// a 1st/2nd swap that reached the bare-store door (a rank override, a file +// edited by hand) kept A1 in the played "Pool A-1st" match and also painted +// A1 into the scheduled "Pool A-2nd" one. When any slot of a pool is locked +// and would get a different competitor, none of that pool's slots move. +func TestResolveQualifiedPools_LockedSlotFreezesItsWholePool(t *testing.T) { + f := newRQFixture(t, "rq-freeze", 2, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + m1, _ := f.slot("Pool A-1st") + m2, s2 := f.slot("Pool A-2nd") + require.NotEqual(t, m1.ID, m2.ID, "the two places of one pool are seated in different matches") + require.NoError(t, f.scoreKO(m1.ID, "A1")) + + matches, err := f.store.LoadPoolMatches(f.compID) + require.NoError(t, err) + for i := range matches { + if matches[i].ID == "Pool A-0" { + matches[i].Winner, matches[i].WinnerID = "A2", rqID("A2") + matches[i].IpponsA, matches[i].IpponsB = nil, []string{"M"} + } + } + require.NoError(t, f.store.SavePoolMatches(f.compID, matches)) + f.resolve() + + seated := 0 + for _, m := range f.bracket().Rounds[0] { + for _, id := range []string{m.SideAID, m.SideBID} { + if id == rqID("A1") { + seated++ + } + } + } + assert.Equal(t, 1, seated, "A1 is seated once, never in the played match AND the 2nd-place slot") + m2, _ = f.slot("Pool A-2nd") + name, id := sideOf(m2, s2) + assert.Equal(t, "A2", name, "the scheduled slot of a frozen pool is left as it was") + assert.Equal(t, rqID("A2"), id) +} + +// Once a mixed competition is in its knockout, almost every write is a +// knockout match or its autosave, and none of them can move who holds a pool +// place. The after-write auto-complete must not pay the pool pass for them +// (standings, both tie-break injectors, a bracket parse, under the +// per-competition lock). The standings cache is the observable: every pass +// computes standings, so an entry deleted before the call and still absent +// after it means none ran. The completed pool write at the end is the control +// that proves the observable is live. +func TestAutoCompleteAfterWrite_KnockoutWriteSkipsThePoolPass(t *testing.T) { + f := newRQFixture(t, "rq-cost", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + _, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + comp, err := f.store.LoadCompetition(f.compID) + require.NoError(t, err) + require.Equal(t, state.CompStatusKnockout, comp.Status, "the last pool seated flips the competition to knockout") + ko := f.bracket().Rounds[0][0] + require.NoError(t, f.scoreKO(ko.ID, "A1")) + + cached := func() bool { + _, ok := f.eng.standingsCache.Load(f.compID) + return ok + } + for _, w := range []state.MatchResult{ + {ID: ko.ID, Status: state.MatchStatusCompleted}, + {ID: ko.ID, Status: state.MatchStatusRunning}, + {ID: "Pool A-0", Status: state.MatchStatusRunning}, + {ID: "Pool A-0", Status: state.MatchStatusScheduled}, + } { + f.eng.standingsCache.Delete(f.compID) + outcome, err := f.eng.MaybeAutoCompletePoolsAfterWrite(f.compID, w) + require.NoError(t, err) + assert.Equal(t, AutoCompleteNoChange, outcome, "%s left %s", w.ID, w.Status) + assert.False(t, cached(), "%s left %s reads no pool standings", w.ID, w.Status) + } + + f.eng.standingsCache.Delete(f.compID) + _, err = f.eng.MaybeAutoCompletePoolsAfterWrite(f.compID, state.MatchResult{ID: "Pool A-0", Status: state.MatchStatusCompleted}) + require.NoError(t, err) + assert.True(t, cached(), "a completed pool write in knockout status still runs the pool pass") +} + +// K3 must refuse BEFORE anything is written. A forced pool correction that is +// also a withdrawal, whose loser a different match has already made +// ineligible, used to land the pool row, reopen and repaint the knockout match +// the old qualifier had fought (restoring any eligibility its verdict +// recorded), and only then meet AlreadyIneligibleError: the rollback restored +// the pool row alone, and the refusal committed with the knockout reopened. +func TestRequalify_ForcedWithdrawalOfAnIneligibleLoserStagesNothing(t *testing.T) { + f := newRQFixture(t, "rq-k3", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + ko := f.bracket().Rounds[0][0] + require.NoError(t, f.scoreKO(ko.ID, "A1")) + require.NoError(t, f.store.SetCompetitorStatus(f.compID, domain.CompetitorStatus{ + PlayerID: rqID("A1"), Eligible: false, MatchID: "elsewhere", Reason: "kiken-voluntary at elsewhere", + })) + bracketVersion := f.store.FileVersion(f.compID, "bracket.json") + + // A1 (aka, SideA) withdraws from Pool A-0, so A2 would take 1st place. + r := f.poolResult("Pool A-0", "A2") + r.Decision = string(domain.DecisionKikenVoluntary) + r.DecisionBy = "aka" + var reopened []ReopenedMatch + err := f.write("Pool A-0", r, ForceOptions{Force: true, Reopened: &reopened}) + var already *AlreadyIneligibleError + require.ErrorAs(t, err, &already) + assert.Equal(t, "elsewhere", already.MatchID) + + assert.Empty(t, reopened, "nothing was reopened") + got := findBracketMatchInBracket(f.bracket(), ko.ID) + require.NotNil(t, got) + assert.Equal(t, state.MatchStatusCompleted, got.Status, "the knockout match keeps its result") + assert.Equal(t, "A1", got.Winner) + assert.Contains(t, []string{got.SideA, got.SideB}, "A1") + assert.Equal(t, bracketVersion, f.store.FileVersion(f.compID, "bracket.json"), "the bracket was not written") + assert.Equal(t, "A1", loadPoolMatchByID(t, f.store, f.compID, "Pool A-0").Winner) +} + +// An engi (kata) competition with pools and a knockout answers a pool +// correction exactly as a kendo one does. Its pool writes go through the engi +// recorder, which returned before the requalification check, so a 1st/2nd swap +// after the old 1st had fought silently left the played match standing on the +// old qualifier. Engi has no supplementary bouts and all its standings carry +// Points 0, so the tie check must not run for it: every place would read tied. +func TestRequalify_Engi_FirstSecondSwap_WarnsThenReopens(t *testing.T) { + f := newRQFixture(t, "rq-engi", 2, [][]string{{"A1", "A2"}, {"B1", "B2"}}, func(c *state.Competition) { c.Engi = true }) + engi := func(matchID string, flagsA, flagsB int, fo ...ForceOptions) error { + t.Helper() + return f.write(matchID, &state.MatchResult{FlagsA: flagsA, FlagsB: flagsB, Status: state.MatchStatusCompleted}, fo...) + } + require.NoError(t, engi("Pool A-0", 3, 0)) // A1 (side A) wins + require.NoError(t, engi("Pool B-0", 3, 0)) + f.resolve() + m1, s1 := f.slot("Pool A-1st") + name, _ := sideOf(m1, s1) + require.Equal(t, "A1", name) + flagsA, flagsB := 3, 0 + if s1 == "B" { + flagsA, flagsB = 0, 3 + } + require.NoError(t, engi(m1.ID, flagsA, flagsB), "A1 fights and wins the 1st-place match") + + err := engi("Pool A-0", 0, 3) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played, "the correction names the match A1 already fought") + require.Len(t, played.Blocking, 1) + assert.Equal(t, m1.ID, played.Blocking[0].ID) + require.Len(t, played.QualifierChange, 2) + for _, c := range played.QualifierChange { + assert.False(t, c.Tied, "an engi place is never read as tied: %+v", c) + } + assert.Equal(t, "A2", played.QualifierChange[0].To.Name) + stored := loadPoolMatchByID(t, f.store, f.compID, "Pool A-0") + assert.Equal(t, "A1", stored.Winner, "refused until confirmed") + assert.Equal(t, 3, stored.FlagsA, "the refusal restores the flags too") + assert.Equal(t, 0, stored.FlagsB) + + var reopened []ReopenedMatch + require.NoError(t, engi("Pool A-0", 0, 3, ForceOptions{Force: true, Reopened: &reopened})) + require.Len(t, reopened, 1) + assert.Equal(t, m1.ID, reopened[0].ID) + got := findBracketMatchInBracket(f.bracket(), m1.ID) + require.NotNil(t, got) + assert.Equal(t, state.MatchStatusScheduled, got.Status) + assert.Empty(t, got.Winner) + name, id := sideOf(*got, s1) + assert.Equal(t, "A2", name) + assert.Equal(t, rqID("A2"), id) + seated := map[string]int{} + for _, m := range f.bracket().Rounds[0] { + seated[m.SideAID]++ + seated[m.SideBID]++ + } + assert.Equal(t, 1, seated[rqID("A1")], "A1 moves to the 2nd-place slot, seated once") + assert.Equal(t, 1, seated[rqID("A2")]) +} + +// A pool-rank override (the chusen door) moves a pool's order without a match +// write, so it answers for the knockout exactly as a pool correction does: +// named and refused while the old 1st has fought, the override taken back; a +// knockout match being fought refuses it outright; confirmed, the fought match +// is reopened and both places are repainted, with nobody seated twice. +func TestOverridePoolRank_AnswersForTheKnockout(t *testing.T) { + f := newRQFixture(t, "rq-rank", 2, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + m1, s1 := f.slot("Pool A-1st") + m2, s2 := f.slot("Pool A-2nd") + ranks := func() map[string]int { + o, err := f.store.LoadOverrides(f.compID) + require.NoError(t, err) + return o.PoolRanks["Pool A"] + } + + // Running: terminal, never confirmable, override taken back. + require.NoError(t, f.store.WithTransaction(f.compID, func(tx state.StoreTx) error { + return tx.UpdateBracket(f.compID, func(b *state.Bracket) error { + findBracketMatchInBracket(b, m1.ID).Status = state.MatchStatusRunning + return nil + }) + })) + _, err := f.eng.OverridePoolRanks(f.compID, "Pool A", []RankOverride{{PlayerID: rqID("A2"), Rank: 1}}, ForceOptions{Force: true}) + var running *DownstreamKnockoutRunningError + require.ErrorAs(t, err, &running) + assert.Empty(t, running.MatchID, "no match is being corrected") + assert.Empty(t, ranks(), "the refused override is taken back") + require.NoError(t, f.scoreKO(m1.ID, "A1")) + + // Played: named, with the places that move, and taken back. + changed, err := f.eng.OverridePoolRanks(f.compID, "Pool A", []RankOverride{{PlayerID: rqID("A2"), Rank: 1}}) + assert.False(t, changed) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + assert.Empty(t, played.MatchID) + require.Len(t, played.Blocking, 1) + assert.Equal(t, m1.ID, played.Blocking[0].ID) + require.Len(t, played.QualifierChange, 2) + assert.Equal(t, QualifierIdentity{Name: "A2", ID: rqID("A2")}, played.QualifierChange[0].To) + assert.Contains(t, played.Error(), "changing the ranking of Pool A") + assert.Empty(t, ranks(), "the refused override is taken back") + got := findBracketMatchInBracket(f.bracket(), m1.ID) + assert.Equal(t, state.MatchStatusCompleted, got.Status, "nothing reopened before the operator confirms") + + // Confirmed: reopened, repainted, nobody twice. + var reopened []ReopenedMatch + changed, err = f.eng.OverridePoolRanks(f.compID, "Pool A", []RankOverride{{PlayerID: rqID("A2"), Rank: 1}}, ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, 1, ranks()[helper.CompetitorKey(rqID("A2"), "", "")]) + require.Len(t, reopened, 1) + assert.Equal(t, m1.ID, reopened[0].ID) + b := f.bracket() + got = findBracketMatchInBracket(b, m1.ID) + assert.Equal(t, state.MatchStatusScheduled, got.Status) + assert.Equal(t, "reopened: the ranking of Pool A was changed", got.CorrectionReason) + name, _ := sideOf(*got, s1) + assert.Equal(t, "A2", name) + name, _ = sideOf(*findBracketMatchInBracket(b, m2.ID), s2) + assert.Equal(t, "A1", name) + seated := map[string]int{} + for _, m := range b.Rounds[0] { + seated[m.SideAID]++ + seated[m.SideBID]++ + } + assert.Equal(t, 1, seated[rqID("A1")]) + assert.Equal(t, 1, seated[rqID("A2")]) +} + +// Re-entering a match a pool correction reopened, with the SAME winner the +// next round already holds, moves nobody there, so it must not warn. A +// different winner still does. +func TestRequalify_ReenterReopenedSameWinnerDoesNotWarn(t *testing.T) { + f := newRQFixture(t, "rq-reenter", 2, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + f.scorePool("Pool A-0", "A1") + f.scorePool("Pool B-0", "B1") + f.resolve() + mA1, _ := f.slot("Pool A-1st") // A1 v B2 + mA2, _ := f.slot("Pool A-2nd") // B1 v A2 + require.NoError(t, f.scoreKO(mA1.ID, "B2")) + require.NoError(t, f.scoreKO(mA2.ID, "B1")) + final := f.bracket().Rounds[1][0] + require.NoError(t, f.scoreKO(final.ID, "B2")) + + var reopened []ReopenedMatch + require.NoError(t, f.write("Pool A-0", f.poolResult("Pool A-0", "A2"), ForceOptions{Force: true, Reopened: &reopened})) + require.Len(t, reopened, 2) + require.Equal(t, state.MatchStatusCompleted, findBracketMatchInBracket(f.bracket(), final.ID).Status, "one hop: the played final is left for its own turn") + + // A2 v B2 again, B2 wins again: the final already holds B2. + require.NoError(t, f.scoreKO(mA1.ID, "B2"), "the same winner moves nobody in the final") + // B1 v A1 again, A1 wins: the final holds B1, so that one must warn. + err := f.scoreKO(mA2.ID, "A1") + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + assert.Equal(t, final.ID, played.BlockingMatchID) + assert.Empty(t, played.QualifierChange, "a knockout correction moves no pool place") +} + +// Sending a running pool match back to the queue stamps it, as the bracket +// branch always did, so an older queued write replayed afterwards cannot +// resurrect a result the operator discarded. +func TestRevertMatchToQueue_PoolStampsRevertFence(t *testing.T) { + f := newRQFixture(t, "rq-revert", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + now := time.Now().UnixMilli() + running := f.poolResult("Pool A-0", "A1") + running.Status, running.Winner, running.WinnerID = state.MatchStatusRunning, "", "" + running.ModifiedAt = now - 10_000 + require.NoError(t, f.write("Pool A-0", running)) + + require.NoError(t, f.eng.RevertMatchToQueue(f.compID, "Pool A-0")) + reverted := loadPoolMatchByID(t, f.store, f.compID, "Pool A-0") + assert.Equal(t, state.MatchStatusScheduled, reverted.Status) + assert.GreaterOrEqual(t, reverted.ModifiedAt, now, "the requeue is stamped with the server's now") + + stale := f.poolResult("Pool A-0", "A1") + stale.ModifiedAt = now - 5_000 // written before the requeue, replayed after it + require.ErrorIs(t, f.write("Pool A-0", stale), ErrMatchSuperseded) + assert.Equal(t, state.MatchStatusScheduled, loadPoolMatchByID(t, f.store, f.compID, "Pool A-0").Status) +} diff --git a/internal/engine/pool_write_merge_test.go b/internal/engine/pool_write_merge_test.go index 0cf43d996..be67af2cb 100644 --- a/internal/engine/pool_write_merge_test.go +++ b/internal/engine/pool_write_merge_test.go @@ -17,7 +17,7 @@ import ( // rather than through this helper — the whole point of bc-lww1 being that a // dropped write must not be indistinguishable from an applied one. func poolMismatch(stored, result *state.MatchResult, policy matchWritePolicy) bool { - mismatch, _, _ := applyPoolWrite(stored, result, policy) + mismatch, _, _, _ := applyPoolWrite(stored, result, policy) return mismatch } @@ -411,7 +411,7 @@ func TestTimestampGuardAppliesToBothBranches(t *testing.T) { t.Run("a strictly older write is dropped, in the pool too", func(t *testing.T) { p := poolStored() - mismatch, superseded, _ := applyPoolWrite(p, incoming(older), matchWriteForward) + mismatch, superseded, _, _ := applyPoolWrite(p, incoming(older), matchWriteForward) require.False(t, mismatch, "the payload names the right pairing; it is merely late") // bc-lww1: the drop has to be REPORTED, not just performed. A `false, // false` here is exactly the bug — indistinguishable from a clean write, @@ -430,7 +430,7 @@ func TestTimestampGuardAppliesToBothBranches(t *testing.T) { t.Run("a newer write applies on both", func(t *testing.T) { p := poolStored() - mismatch, superseded, _ := applyPoolWrite(p, incoming(newer), matchWriteForward) + mismatch, superseded, _, _ := applyPoolWrite(p, incoming(newer), matchWriteForward) require.False(t, mismatch) require.False(t, superseded, "a write that landed must never report itself superseded") assert.Equal(t, "Osaka", p.Winner) @@ -444,7 +444,7 @@ func TestTimestampGuardAppliesToBothBranches(t *testing.T) { t.Run("an unstamped write still applies, so legacy clients are unaffected", func(t *testing.T) { p := poolStored() - mismatch, superseded, _ := applyPoolWrite(p, incoming(0), matchWriteForward) + mismatch, superseded, _, _ := applyPoolWrite(p, incoming(0), matchWriteForward) require.False(t, mismatch) require.False(t, superseded) assert.Equal(t, "Osaka", p.Winner, "0 means unstamped, which never loses") @@ -455,7 +455,7 @@ func TestTimestampGuardAppliesToBothBranches(t *testing.T) { t.Run("an unstamped STORED value accepts anything, so legacy files are unaffected", func(t *testing.T) { p := poolStored() p.ModifiedAt = 0 // a row written before the column existed - mismatch, superseded, _ := applyPoolWrite(p, incoming(older), matchWriteForward) + mismatch, superseded, _, _ := applyPoolWrite(p, incoming(older), matchWriteForward) require.False(t, mismatch) require.False(t, superseded, "a legacy file must not start reporting supersedes") assert.Equal(t, "Osaka", p.Winner) @@ -472,7 +472,7 @@ func TestTimestampGuardAppliesToBothBranches(t *testing.T) { p := poolStored() corr := incoming(older) corr.CorrectionReason = "scoreboard misread" - mismatch, superseded, _ := applyPoolWrite(p, corr, matchWriteForward) + mismatch, superseded, _, _ := applyPoolWrite(p, corr, matchWriteForward) require.False(t, mismatch) require.True(t, superseded, "a correction older than the stored result is a replay, not an override") @@ -486,7 +486,7 @@ func TestTimestampGuardAppliesToBothBranches(t *testing.T) { p := poolStored() corr := incoming(newer) corr.CorrectionReason = "scoreboard misread" - mismatch, superseded, _ := applyPoolWrite(p, corr, matchWriteForward) + mismatch, superseded, _, _ := applyPoolWrite(p, corr, matchWriteForward) require.False(t, mismatch) require.False(t, superseded, "a fresh correction is not a replay") assert.Equal(t, "Osaka", p.Winner, @@ -509,7 +509,7 @@ func TestTimestampGuardAppliesToBothBranches(t *testing.T) { snap := incoming(older) snap.Winner = "Kyoto" snap.Status = state.MatchStatusScheduled - mismatch, superseded, _ := applyPoolWrite(p, snap, matchWriteRestore) + mismatch, superseded, _, _ := applyPoolWrite(p, snap, matchWriteRestore) require.False(t, mismatch) require.False(t, superseded, "a restore is exempt, so it can never report a supersede") assert.Equal(t, state.MatchStatusScheduled, p.Status, "the rollback landed") diff --git a/internal/engine/prior_lookup_error_test.go b/internal/engine/prior_lookup_error_test.go new file mode 100644 index 000000000..d585560e7 --- /dev/null +++ b/internal/engine/prior_lookup_error_test.go @@ -0,0 +1,81 @@ +package engine + +import ( + "errors" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// failFirstPoolLoad is a StoreTx whose FIRST LoadPoolMatches fails and whose +// later ones succeed, so the engi seam's prior lookup cannot read the stored +// match while the write that follows it could. +type failFirstPoolLoad struct { + state.StoreTx + failed bool +} + +func (f *failFirstPoolLoad) LoadPoolMatches(compID string) ([]state.MatchResult, error) { + if !f.failed { + f.failed = true + return nil, errors.New("pool-matches.csv could not be read") + } + return f.StoreTx.LoadPoolMatches(compID) +} + +// A pool write in a mixed engi competition reads the stored match first, so a +// refusal from the knockout check can put the pool row back. A prior it cannot +// read is an error, never a nil prior: with no prior there is nothing to roll +// back to, and the write went ahead unguarded while the error was discarded. +func TestEngiMixedPoolWrite_UnreadablePriorIsAnError(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "engi-prior-unreadable" + setupEngiComp(t, store, compID, state.CompFormatMixed) + require.NoError(t, store.SavePools(compID, []helper.Pool{ + {PoolName: "Pool A", Players: []helper.Player{{Name: "Alice", Dojo: "Dojo Alice"}, {Name: "Bob", Dojo: "Dojo Bob"}}}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusRunning, Court: "A", + }})) + + err := store.WithTransaction(compID, func(tx state.StoreTx) error { + _, werr := eng.RecordMatchResultWithIneligibilityTx(&failFirstPoolLoad{StoreTx: tx}, compID, "Pool A-0", + &state.MatchResult{FlagsA: 3, FlagsB: 0, Status: state.MatchStatusCompleted}) + return werr + }) + require.Error(t, err, "the prior lookup's error must reach the caller") + + ms, lerr := store.LoadPoolMatches(compID) + require.NoError(t, lerr) + require.Len(t, ms, 1) + assert.Equal(t, state.MatchStatusRunning, ms[0].Status, "nothing is written when the prior cannot be read") + assert.Zero(t, ms[0].FlagsA+ms[0].FlagsB) +} + +// The same rule on the kendo path of the same entry point, where the prior +// also feeds K3's rollback and the kachinuki merge. +func TestMatchWrite_UnreadablePriorIsAnError(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "kendo-prior-unreadable" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "p", Kind: "individual", Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusRunning, Court: "A", + }})) + + err := store.WithTransaction(compID, func(tx state.StoreTx) error { + _, werr := eng.RecordMatchResultWithIneligibilityTx(&failFirstPoolLoad{StoreTx: tx}, compID, "Pool A-0", + &state.MatchResult{SideA: "Alice", SideB: "Bob", Winner: "Alice", IpponsA: []string{"M", "K"}, Status: state.MatchStatusCompleted}) + return werr + }) + require.Error(t, err, "the prior lookup's error must reach the caller") + + ms, lerr := store.LoadPoolMatches(compID) + require.NoError(t, lerr) + require.Len(t, ms, 1) + assert.Equal(t, state.MatchStatusRunning, ms[0].Status, "nothing is written when the prior cannot be read") +} diff --git a/internal/engine/reopen_bye_unwind_test.go b/internal/engine/reopen_bye_unwind_test.go new file mode 100644 index 000000000..5dc206829 --- /dev/null +++ b/internal/engine/reopen_bye_unwind_test.go @@ -0,0 +1,250 @@ +package engine + +import ( + "fmt" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A five-competitor knockout is the smallest real draw with a bye PAST the +// first round: the draw pairs P00-P01 in m-r1-0, whose winner meets nobody in +// the semifinal m-r2-0 (the other half of that pairing is an empty "" vs "" +// slot), so the bye passes them straight to the final m-r3-0. A withdrawal +// recorded by mistake on m-r1-0 therefore has a winner who went through a bye, +// and the reopen that removes it has to unwind the bye rather than be refused +// by it (a correction keeps the withdrawal, so no other door removes it). +func byeDrawFixture(t *testing.T, compID string) (*Engine, *state.Store) { + t.Helper() + eng, store, _ := setupTestEngine(t) + createTestCompetition(t, store, compID, "knockout", 3) + players := make([]domain.Player, 5) + for i := range players { + players[i] = domain.Player{Name: fmt.Sprintf("P%02d", i), Dojo: fmt.Sprintf("D%02d", i)} + } + require.NoError(t, store.SaveParticipants(compID, players)) + require.NoError(t, eng.StartCompetition(compID)) + + // Pin the draw shape this test reasons about, so a change to the draw + // fails here rather than as a confusing assertion below. + b := loadBracket(t, store, compID) + require.Equal(t, [2]string{"P00", "P01"}, [2]string{b.Rounds[0][0].SideA, b.Rounds[0][0].SideB}) + bye := b.Rounds[1][0] + require.Equal(t, winnerOfPlaceholder(3, 0), bye.SideA) + require.Empty(t, bye.SideB) + require.Equal(t, state.MatchStatusCompleted, bye.Status, "generation leaves a latent bye completed with no winner") + require.Empty(t, bye.Winner) + require.Equal(t, winnerOfPlaceholder(2, 0), b.Rounds[2][0].SideA) + return eng, store +} + +func loadBracket(t *testing.T, store *state.Store, compID string) *state.Bracket { + t.Helper() + b, err := store.LoadBracket(compID) + require.NoError(t, err) + return b +} + +// scoreBracketMatch completes matchID for winner, one men, through the +// engine's score door. +func scoreBracketMatch(t *testing.T, eng *Engine, store *state.Store, compID, matchID, winner string) { + t.Helper() + m := findBracketMatchInBracket(loadBracket(t, store, compID), matchID) + require.NotNil(t, m, matchID) + r := &state.MatchResult{SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, Status: state.MatchStatusCompleted} + switch winner { + case m.SideA: + r.Winner, r.WinnerID, r.IpponsA = m.SideA, m.SideAID, []string{"M"} + case m.SideB: + r.Winner, r.WinnerID, r.IpponsB = m.SideB, m.SideBID, []string{"M"} + default: + t.Fatalf("%s is not in %s (%s vs %s)", winner, matchID, m.SideA, m.SideB) + } + _, err := eng.RecordMatchResultWithIneligibility(compID, matchID, r) + require.NoError(t, err) +} + +// recordMistakenKiken records P00 (aka) withdrawing from m-r1-0, so P01 wins +// and the bye passes P01 into the final. +func recordMistakenKiken(t *testing.T, eng *Engine, store *state.Store, compID string) { + t.Helper() + _, st, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "aka", "", nil, false) + require.NoError(t, err) + require.NotNil(t, st) + b := loadBracket(t, store, compID) + require.Equal(t, "P01", b.Rounds[1][0].Winner, "the bye resolved off the kiken's winner") + require.Equal(t, "P01", b.Rounds[2][0].SideA) +} + +// assertByeUnwound checks the semifinal bye is back to the shape generation +// gave it and the final's slot to the bye's placeholder. +func assertByeUnwound(t *testing.T, b *state.Bracket) { + t.Helper() + bye := b.Rounds[1][0] + assert.Equal(t, winnerOfPlaceholder(3, 0), bye.SideA) + assert.Empty(t, bye.SideAID) + assert.Empty(t, bye.Winner) + assert.Empty(t, bye.WinnerID) + assert.Equal(t, state.MatchStatusCompleted, bye.Status) + assert.Equal(t, winnerOfPlaceholder(2, 0), b.Rounds[2][0].SideA) + assert.Empty(t, b.Rounds[2][0].SideAID) +} + +func TestReopenUnwindsAByeTheWinnerWentThrough(t *testing.T) { + compID := "bye-unwind" + eng, store := byeDrawFixture(t, compID) + recordMistakenKiken(t, eng, store, compID) + + restored, err := eng.ReopenMatch(compID, "m-r1-0", "the withdrawal was recorded on the wrong match") + require.NoError(t, err, "the bye was never fought, so it cannot refuse the reopen") + require.NotNil(t, restored, "the withdrawal the reopen removed bars nobody") + + b := loadBracket(t, store, compID) + assert.Equal(t, state.MatchStatusRunning, b.Rounds[0][0].Status) + assert.Empty(t, b.Rounds[0][0].Decision) + assertByeUnwound(t, b) + + // Fought again, P00 wins this time: the bye passes the NEW winner on. + scoreBracketMatch(t, eng, store, compID, "m-r1-0", "P00") + b = loadBracket(t, store, compID) + assert.Equal(t, "P00", b.Rounds[1][0].SideA) + assert.Equal(t, "P00", b.Rounds[1][0].Winner) + assert.Equal(t, b.Rounds[0][0].SideAID, b.Rounds[1][0].WinnerID) + assert.Equal(t, state.MatchStatusCompleted, b.Rounds[1][0].Status) + assert.Equal(t, "P00", b.Rounds[2][0].SideA) + assert.Equal(t, b.Rounds[0][0].SideAID, b.Rounds[2][0].SideAID) +} + +// playToFinal plays everything up to the final: P03 beats P04, P02 beats +// P03 in the other semifinal, so the final is P01 (through the bye) vs P02. +func playToFinal(t *testing.T, eng *Engine, store *state.Store, compID string) { + t.Helper() + scoreBracketMatch(t, eng, store, compID, "m-r1-3", "P03") + scoreBracketMatch(t, eng, store, compID, "m-r2-1", "P02") + final := loadBracket(t, store, compID).Rounds[2][0] + require.Equal(t, [2]string{"P01", "P02"}, [2]string{final.SideA, final.SideB}) +} + +// Past the bye, the final answers exactly as the next round after any +// reopened match does: played, the operator is told and may proceed. +func TestReopenThroughAByeWarnsForAPlayedFinalThenReopensIt(t *testing.T) { + compID := "bye-unwind-played" + eng, store := byeDrawFixture(t, compID) + recordMistakenKiken(t, eng, store, compID) + playToFinal(t, eng, store, compID) + scoreBracketMatch(t, eng, store, compID, "m-r3-0", "P01") + + _, err := eng.ReopenMatch(compID, "m-r1-0", "the withdrawal was recorded on the wrong match") + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Len(t, played.Blocking, 1) + assert.Equal(t, "m-r3-0", played.BlockingMatchID) + assert.Equal(t, "P01", played.Displaced, "the competitor the bye passed on is the one displaced") + b := loadBracket(t, store, compID) + assert.Equal(t, "P01", b.Rounds[1][0].Winner, "a refused reopen changes nothing") + assert.Equal(t, "P01", b.Rounds[2][0].Winner) + + var reopened []ReopenedMatch + _, err = eng.ReopenMatch(compID, "m-r1-0", "the withdrawal was recorded on the wrong match", + ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.Len(t, reopened, 1) + assert.Equal(t, "m-r3-0", reopened[0].ID) + b = loadBracket(t, store, compID) + final := b.Rounds[2][0] + assert.Equal(t, state.MatchStatusScheduled, final.Status) + assert.Empty(t, final.Winner) + assertByeUnwound(t, b) + assert.Equal(t, "P02", final.SideB, "the other semifinal's winner keeps their place") +} + +// A final being fought past the bye refuses the reopen, as the next round +// after any reopened match does, and nothing is unwound. +func TestReopenThroughAByeIsRefusedWhileTheFinalIsFought(t *testing.T) { + compID := "bye-unwind-running" + eng, store := byeDrawFixture(t, compID) + recordMistakenKiken(t, eng, store, compID) + playToFinal(t, eng, store, compID) + b := loadBracket(t, store, compID) + b.Rounds[2][0].Status = state.MatchStatusRunning + require.NoError(t, store.SaveBracket(compID, b)) + + _, err := eng.ReopenMatch(compID, "m-r1-0", "the withdrawal was recorded on the wrong match") + var running *DownstreamKnockoutRunningError + require.ErrorAs(t, err, &running) + require.Len(t, running.Running, 1) + assert.Equal(t, "m-r3-0", running.Running[0].ID) + b = loadBracket(t, store, compID) + assert.Equal(t, "P01", b.Rounds[1][0].Winner) + assert.Equal(t, "P01", b.Rounds[2][0].SideA) + assert.Equal(t, "kiken-voluntary", b.Rounds[0][0].Decision) +} + +// playedFinalThroughByeFixture plays the whole draw with P00 winning m-r1-0 +// outright, through the bye, and then the final against P02. +func playedFinalThroughByeFixture(t *testing.T, compID string) (*Engine, *state.Store) { + t.Helper() + eng, store := byeDrawFixture(t, compID) + scoreBracketMatch(t, eng, store, compID, "m-r1-0", "P00") + scoreBracketMatch(t, eng, store, compID, "m-r1-3", "P03") + scoreBracketMatch(t, eng, store, compID, "m-r2-1", "P02") + scoreBracketMatch(t, eng, store, compID, "m-r3-0", "P00") + return eng, store +} + +// A CORRECTION that changes who won m-r1-0 re-resolves the bye with the new +// winner and so reaches the final, already fought. Looking only one hop down +// it saw the bye (no result of its own) and repainted the played final in +// silence: P01 was seated in it while its recorded winner stayed P00. Past +// the bye it is named like any played next round, and confirming reopens it. +func TestCorrectionThroughAByeWarnsForAPlayedFinalThenReopensIt(t *testing.T) { + compID := "bye-correction" + eng, store := playedFinalThroughByeFixture(t, compID) + correction := func() *state.MatchResult { + m := findBracketMatchInBracket(loadBracket(t, store, compID), "m-r1-0") + return &state.MatchResult{SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, + Status: state.MatchStatusCompleted, Winner: m.SideB, WinnerID: m.SideBID, IpponsB: []string{"M"}, + CorrectionReason: "the wrong winner was entered"} + } + + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", correction()) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Len(t, played.Blocking, 1) + assert.Equal(t, "m-r3-0", played.BlockingMatchID) + assert.Equal(t, "P00", played.Displaced) + b := loadBracket(t, store, compID) + assert.Equal(t, "P00", b.Rounds[0][0].Winner, "refused until confirmed") + assert.Equal(t, "P00", b.Rounds[2][0].SideA) + assert.Equal(t, "P00", b.Rounds[2][0].Winner) + + var reopened []ReopenedMatch + _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", correction(), ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.Len(t, reopened, 1) + assert.Equal(t, "m-r3-0", reopened[0].ID) + b = loadBracket(t, store, compID) + assert.Equal(t, "P01", b.Rounds[1][0].Winner, "the bye passes the corrected winner on") + final := b.Rounds[2][0] + assert.Equal(t, "P01", final.SideA) + assert.Equal(t, state.MatchStatusScheduled, final.Status) + assert.Empty(t, final.Winner) + assert.Empty(t, final.IpponsA) +} + +// The manual winner override is the same correction through another door. +func TestOverrideThroughAByeWarnsForAPlayedFinal(t *testing.T) { + compID := "bye-override" + eng, store := playedFinalThroughByeFixture(t, compID) + + applied, err := eng.OverrideBracketWinner(compID, "m-r1-0", "P01", 0) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + assert.False(t, applied) + assert.Equal(t, "m-r3-0", played.BlockingMatchID) + assert.Equal(t, "P00", played.Displaced) + assert.Equal(t, "P00", loadBracket(t, store, compID).Rounds[2][0].SideA, "refused until confirmed") +} diff --git a/internal/engine/reopen_fusensho_status_test.go b/internal/engine/reopen_fusensho_status_test.go new file mode 100644 index 000000000..5c1f6c860 --- /dev/null +++ b/internal/engine/reopen_fusensho_status_test.go @@ -0,0 +1,121 @@ +package engine + +// bc-cse item 9: reopening a MATCH-LEVEL fusensho (the default win recorded +// for a barred competitor's remaining match, via POST /decision -- see +// domain.IsWithdrawalDecisionStr's doc) must not blindly return the match to +// RUNNING when the competitor it named as loser is STILL barred by an +// earlier, unrelated withdrawal: RUNNING would let every later write on +// this match be refused by StartMatchTx's eligibility gate, with no way for +// the operator to get out. It must go to SCHEDULED instead, which re-shows +// the barred-match notice, exactly as it did before the fusensho was +// recorded. Only once the earlier bar is itself cleared does the SAME +// reopen go to RUNNING. + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fusenshoBarredFixture builds a pool competition where Alice is barred by +// a kiken-voluntary on "Pool A-0" (Alice vs Bob), then a match-level +// fusensho closes "Pool A-1" (Alice vs Carol) crediting Carol -- the shape +// an operator reaches by recording a default win for each of a barred +// competitor's remaining matches. Real participant ids throughout: BarredSides +// keys statuses by PlayerID, so a match row with no stamped SideAID/SideBID +// and no roster to resolve a name against would record no bar at all. +func fusenshoBarredFixture(t *testing.T, compID string) (*Engine, *state.Store) { + t.Helper() + eng, store, _ := setupTestEngine(t) + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Status: state.CompStatusPools, + })) + aliceID, bobID, carolID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, + })) + // Alice (aka/SideA) withdraws from Pool A-0; Bob credited. + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + require.NoError(t, err) + // Alice (aka/SideA) cannot fight Pool A-1 either; Carol credited by + // match-level fusensho. Not a withdrawal decision, so this write bars + // nobody itself (domain.IsWithdrawalDecisionStr excludes fusensho) -- + // Alice's only bar is still the one Pool A-0 recorded. + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "aka", "already ineligible", nil, false) + require.NoError(t, err) + return eng, store +} + +func TestReopenMatch_FusenshoMatch_StillBarred_GoesToScheduled(t *testing.T) { + eng, store := fusenshoBarredFixture(t, "reopen-fusensho-barred") + + restored, err := eng.ReopenMatch("reopen-fusensho-barred", "Pool A-1", "operator error") + require.NoError(t, err) + assert.Nil(t, restored, "fusensho recorded no CompetitorStatus, so reopening it restores nobody's eligibility") + + matches, err := store.LoadPoolMatches("reopen-fusensho-barred") + require.NoError(t, err) + var m1 *state.MatchResult + for i := range matches { + if matches[i].ID == "Pool A-1" { + m1 = &matches[i] + } + } + require.NotNil(t, m1) + assert.Equal(t, state.MatchStatusScheduled, m1.Status, + "the loser is still barred by the unrelated Pool A-0 withdrawal; RUNNING would strand the operator behind StartMatchTx") + assert.Empty(t, m1.Winner) + assert.Empty(t, m1.Decision) + assert.Empty(t, m1.IpponsA) + assert.Empty(t, m1.IpponsB) + + statuses, err := store.LoadCompetitorStatus("reopen-fusensho-barred") + require.NoError(t, err) + for _, st := range statuses { + if st.PlayerID != "" { + assert.False(t, st.Eligible, "Alice's Pool A-0 bar must be untouched by reopening the unrelated fusensho match") + } + } +} + +func TestReopenMatch_FusenshoMatch_NoLongerBarred_GoesToRunning(t *testing.T) { + eng, store := fusenshoBarredFixture(t, "reopen-fusensho-clear") + + // Clear Alice's ONLY bar by reopening the withdrawal that recorded it; + // restoreIfWithdrawalRemoved restores her eligibility in the same + // transaction (operator ruling 2026-09-24). + _, err := eng.ReopenMatch("reopen-fusensho-clear", "Pool A-0", "withdrawal recorded by mistake") + require.NoError(t, err) + + statuses, err := store.LoadCompetitorStatus("reopen-fusensho-clear") + require.NoError(t, err) + for _, st := range statuses { + require.True(t, st.Eligible, "Alice's only bar must already be cleared before this test's real assertion") + } + + restored, err := eng.ReopenMatch("reopen-fusensho-clear", "Pool A-1", "operator error") + require.NoError(t, err) + assert.Nil(t, restored, "fusensho still restores nobody's eligibility, whichever status it goes to") + + matches, err := store.LoadPoolMatches("reopen-fusensho-clear") + require.NoError(t, err) + var m1 *state.MatchResult + for i := range matches { + if matches[i].ID == "Pool A-1" { + m1 = &matches[i] + } + } + require.NotNil(t, m1) + assert.Equal(t, state.MatchStatusRunning, m1.Status, + "Alice is no longer barred by anything, so this reopen behaves like any other") +} diff --git a/internal/engine/reopen_scheduled_court_test.go b/internal/engine/reopen_scheduled_court_test.go new file mode 100644 index 000000000..2382fd9c5 --- /dev/null +++ b/internal/engine/reopen_scheduled_court_test.go @@ -0,0 +1,150 @@ +package engine + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A reopen that goes back to the QUEUE takes no court, so a bout running on +// that court must not refuse it. The one such reopen is a match-level +// fusensho whose barred competitor is still barred (reopenTargetStatus). It +// used to be refused court_busy whenever anything else was being fought on its +// court, and the remedy the operator was then offered (requeue the court's +// occupant) wiped the live bout to free a court the reopen never needed. + +// busyCourtFusenshoFixture builds a pool competition on court A where Alice +// withdrew injured from "Pool A-0" and a match-level fusensho then closed +// "Pool A-1" (Alice vs Carol) for Carol. reinstate clears Alice before the +// test reopens. The court is then made busy: by "Pool A-2" of the same +// competition (crossComp false), or by a match of a second competition sharing +// the court (crossComp true). +func busyCourtFusenshoFixture(t *testing.T, compID string, reinstate, crossComp bool) (*Engine, *state.Store) { + t.Helper() + eng, store, _ := setupTestEngine(t) + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Status: state.CompStatusPools, Courts: []string{"A"}, + })) + aliceID, bobID, carolID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + daveID, erinID := helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + {ID: daveID, Name: "Dave", Dojo: "D"}, + {ID: erinID, Name: "Erin", Dojo: "E"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Court: "A", Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Court: "A", Status: state.MatchStatusScheduled}, + {ID: "Pool A-2", SideA: "Dave", SideB: "Erin", SideAID: daveID, SideBID: erinID, Court: "A", Status: state.MatchStatusScheduled}, + })) + _, st, err := eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "injured", nil, false) + require.NoError(t, err) + require.Equal(t, aliceID, st.PlayerID) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "aka", "already ineligible", nil, false) + require.NoError(t, err) + if reinstate { + _, err = eng.ReinstateCompetitor(compID, aliceID) + require.NoError(t, err) + } + + if !crossComp { + setPoolMatchStatus(t, store, compID, "Pool A-2", state.MatchStatusRunning) + return eng, store + } + other := compID + "-other" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: other, Name: other, Status: state.CompStatusPools, Courts: []string{"A"}, + })) + require.NoError(t, store.SavePoolMatches(other, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Frank", SideB: "Gina", Court: "A", Status: state.MatchStatusRunning}, + })) + return eng, store +} + +func setPoolMatchStatus(t *testing.T, store *state.Store, compID, matchID string, status state.MatchStatus) { + t.Helper() + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range matches { + if matches[i].ID == matchID { + matches[i].Status = status + } + } + require.NoError(t, store.SavePoolMatches(compID, matches)) +} + +func TestReopenToScheduledIgnoresABusyCourt(t *testing.T) { + for _, tc := range []struct { + name string + crossComp bool + }{ + {"same competition", false}, + {"another competition on the same court", true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Run("still barred: reopens to the queue", func(t *testing.T) { + compID := "reopen-busy-barred" + eng, store := busyCourtFusenshoFixture(t, compID, false, tc.crossComp) + + _, err := eng.ReopenMatch(compID, "Pool A-1", "operator error") + require.NoError(t, err, "a reopen to the queue takes no court, so a busy court cannot refuse it") + m := loadPoolMatchByID(t, store, compID, "Pool A-1") + assert.Equal(t, state.MatchStatusScheduled, m.Status) + assert.Empty(t, m.Decision) + }) + + t.Run("reinstated: reopens onto the court, so a busy court refuses it", func(t *testing.T) { + compID := "reopen-busy-clear" + eng, store := busyCourtFusenshoFixture(t, compID, true, tc.crossComp) + + _, err := eng.ReopenMatch(compID, "Pool A-1", "operator error") + var busy *CourtBusyError + require.ErrorAs(t, err, &busy) + assert.Equal(t, "A", busy.Court) + m := loadPoolMatchByID(t, store, compID, "Pool A-1") + assert.Equal(t, state.MatchStatusCompleted, m.Status, "a refused reopen changes nothing") + assert.Equal(t, "fusensho", m.Decision) + }) + }) + } +} + +// The requeue door exists to free a court for a reopen that needs one. For a +// reopen to the queue it would wipe the court occupant's live score for +// nothing, so it is refused before the requeue, naming the plain reopen. +func TestRequeueBlockerAndReopenRefusesAReopenThatNeedsNoCourt(t *testing.T) { + compID := "requeue-no-court" + eng, store := busyCourtFusenshoFixture(t, compID, false, false) + + _, err := eng.RequeueBlockerAndReopen(compID, "Pool A-1", compID, "Pool A-2", "operator error") + var verr *ValidationError + require.ErrorAs(t, err, &verr) + assert.Contains(t, verr.Error(), "Reopen it directly") + assert.Equal(t, state.MatchStatusRunning, loadPoolMatchByID(t, store, compID, "Pool A-2").Status, + "the court's occupant keeps its bout") + assert.Equal(t, state.MatchStatusCompleted, loadPoolMatchByID(t, store, compID, "Pool A-1").Status) +} + +// The pre-read that the reopen lands scheduled is HELD inside the tx: the +// cross-competition gate was skipped on its word, so a reinstatement landing +// between the pre-read and the tx must not turn the reopen into a running +// match on a court another competition may be using. +func TestReopenHeldToScheduledWhenThePreReadSaidSo(t *testing.T) { + compID := "reopen-held" + // Reinstated, so the tx on its own would reopen onto the court, and the + // court is free, so nothing but the held pre-read keeps it scheduled. + eng, store := busyCourtFusenshoFixture(t, compID, true, false) + setPoolMatchStatus(t, store, compID, "Pool A-2", state.MatchStatusScheduled) + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + + _, err = eng.reopenUnderCourtLock(compID, comp, "Pool A-1", "operator error", ForceOptions{}, true) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusScheduled, loadPoolMatchByID(t, store, compID, "Pool A-1").Status) +} diff --git a/internal/engine/reopen_withdrawal_test.go b/internal/engine/reopen_withdrawal_test.go new file mode 100644 index 000000000..9445c466c --- /dev/null +++ b/internal/engine/reopen_withdrawal_test.go @@ -0,0 +1,471 @@ +package engine + +// Operator ruling 2026-09-24: "Everything should be able to be fixed, in case +// of a wrong entry. The operator just needs to be aware of the consequences, +// if it affects downstream matches." A withdrawal (kiken, fusenpai) recorded +// by mistake is removed by REOPENING the match, whatever its format: a +// withdrawal means the opponent received the default score, so removing one +// means the match was never decided. It goes back to running with what was +// fought kept, the verdict cleared and the withdrawn side eligible again, and +// the operator finishes it normally. These pin that on the team (pool and +// knockout) and individual shapes, the downstream warn-and-proceed, and the +// gate that still refuses every other non-kachinuki match. The kachinuki +// reopen's own withdrawal tests are in kachinuki_reopen_withdrawal_test.go. + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A fixed-order team pool match: running, bout kept, verdict and maru gone, +// eligible again, and the reason is the audit trail (nothing outstanding). +func TestReopenWithdrawal_TeamPoolMatch(t *testing.T) { + for _, decision := range []string{"kiken-voluntary", "kiken-injury", "fusenpai"} { + t.Run(decision, func(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, decision) + + status, err := eng.ReopenMatch(compID, "Pool A-0", " Withdrawal recorded by mistake ") + require.NoError(t, err) + require.NotNil(t, status, "the restore is returned so the handler can broadcast it") + assert.Equal(t, wrTeamAID, status.PlayerID) + assert.True(t, status.Eligible) + assert.True(t, wrEligible(t, store, compID, wrTeamAID), "a removed withdrawal bars nobody") + + m := wrPoolMatch(t, store, compID) + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, "", m.Decision) + assert.Equal(t, "", m.DecisionBy) + assert.Equal(t, "", m.DecisionReason) + assert.Equal(t, "", m.Winner) + assert.Equal(t, "", m.WinnerID) + assert.Empty(t, m.IpponsB, "the default-win maru goes with the verdict") + // bc-tmfn follow-up: the kiken padded bouts 2 and 3 (TeamSize 3); + // reopening clears the verdict but does not strip those rows -- + // the same empty-position shape a fresh team match's Start write + // already stores. + require.Len(t, m.SubResults, 3, "the bout fought before the withdrawal is kept, plus the padded rows 2/3") + assert.Equal(t, []string{"M"}, m.SubResults[0].IpponsA) + assert.Equal(t, "Withdrawal recorded by mistake", m.CorrectionReason) + assert.False(t, m.ReopenPending, "a reason was given, so nothing is owed") + }) + } +} + +// A reason-less reopen owes its reason on the next completion, as the +// kachinuki reopen always has. +func TestReopenWithdrawal_NoReasonLeavesItPending(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, "fusenpai") + _, err := eng.ReopenMatch(compID, "Pool A-0", "") + require.NoError(t, err) + assert.True(t, wrPoolMatch(t, store, compID).ReopenPending) +} + +// seedIndividualWithdrawal: an individual league where Ryu (aka) had struck a +// men and Tora (shiro) a kote when Ryu withdrew, in overtime when encho is set. +func seedIndividualWithdrawal(t *testing.T, decision string, encho *state.EnchoMetadata) (*Engine, *state.Store, string) { + t.Helper() + eng, store, _ := setupTestEngine(t) + const compID = "rw-ind" + createTestCompetition(t, store, compID, "league", 3) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + IpponsA: []string{"M"}, IpponsB: []string{"K"}, HansokuB: 1, + Status: state.MatchStatusRunning, + }})) + _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "", encho, false) + require.NoError(t, err) + require.False(t, wrEligible(t, store, compID, wrTeamAID), "precondition: the withdrawal barred Ryu") + return eng, store, compID +} + +// An individual match is a single bout: its match-level scoreline IS the +// fight, so the letters the withdrawing side struck (kept by the withdrawal, +// FIK Art. 32) stay, the winner's maru goes, and overtime stays. +func TestReopenWithdrawal_IndividualMatchKeepsWhatWasFought(t *testing.T) { + encho := &state.EnchoMetadata{PeriodCount: 1} + eng, store, compID := seedIndividualWithdrawal(t, "kiken-injury", encho) + before := wrPoolMatch(t, store, compID) + require.Equal(t, []string{"M"}, before.IpponsA) + require.Equal(t, domain.DefaultWinIppons(true), before.IpponsB) + + status, err := eng.ReopenMatch(compID, "Pool A-0", "Withdrawal recorded by mistake") + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, wrTeamAID, status.PlayerID) + assert.True(t, wrEligible(t, store, compID, wrTeamAID)) + + m := wrPoolMatch(t, store, compID) + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, "", m.Decision) + assert.Equal(t, "", m.Winner) + assert.Equal(t, []string{"M"}, m.IpponsA, "the withdrawing side's struck letter is kept") + assert.Empty(t, m.IpponsB, "the winner's maru was the verdict; its letters before the withdrawal were replaced when it was recorded") + require.NotNil(t, m.Encho, "the bout was in overtime and still is") + assert.Equal(t, 1, m.Encho.PeriodCount) +} + +// The knockout shape, with the downstream warn-and-proceed: a final that has +// its own result is the operator's call. Refused (naming it) without force, +// reopened for re-entry with force; either way the retraction lands only +// once the operator has been asked. +func TestReopenWithdrawal_KnockoutDownstreamIsWarnAndProceed(t *testing.T) { + eng, store, compID, _, matchID := seedBracketWithdrawal(t, false) + before, err := store.LoadBracket(compID) + require.NoError(t, err) + require.Equal(t, state.MatchStatusCompleted, before.Rounds[1][0].Status, "precondition: Tora won the final") + + _, err = eng.ReopenMatch(compID, matchID, "Withdrawal recorded by mistake") + require.ErrorIs(t, err, ErrDownstreamKnockoutPlayed) + var dkp *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &dkp) + assert.Equal(t, "m-r2-0", dkp.BlockingMatchID) + assert.Equal(t, wrTeamB, dkp.Displaced) + unchanged, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, before, unchanged, "a refused reopen leaves the bracket untouched") + assert.False(t, wrEligible(t, store, compID, wrTeamAID), "and the withdrawal in force") + + var reopened []ReopenedMatch + status, err := eng.ReopenMatch(compID, matchID, "Withdrawal recorded by mistake", ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.NotNil(t, status) + assert.True(t, wrEligible(t, store, compID, wrTeamAID)) + require.Len(t, reopened, 1) + assert.Equal(t, "m-r2-0", reopened[0].ID) + + after, err := store.LoadBracket(compID) + require.NoError(t, err) + r1 := after.Rounds[0][0] + assert.Equal(t, state.MatchStatusRunning, r1.Status) + assert.Equal(t, "", r1.Decision) + // bc-tmfn follow-up: the kiken padded bouts 2 and 3 (TeamSize 3); reopening + // clears the verdict but does not strip those rows. + require.Len(t, r1.SubResults, 3, "the bout fought before the withdrawal is kept, plus the padded rows 2/3") + final := after.Rounds[1][0] + assert.Equal(t, state.MatchStatusScheduled, final.Status, "the final is reopened for re-entry") + assert.Empty(t, final.Winner) + assert.Empty(t, final.SubResults, "its bouts belonged to a pairing no longer in it") + assert.Equal(t, "Winner of r2-m0", final.SideA, "the retracted slot waits for this match again") + assert.Equal(t, wrTeamC, final.SideB) +} + +// A downstream match in progress is not something a confirmation can settle: +// someone is fighting it. Refused outright, force or not. +func TestReopenWithdrawal_RunningDownstreamIsRefused(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "rw-ko-running" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "rw", Kind: "individual", Status: state.CompStatusKnockout})) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning}}, + {{ID: "m-r2-0", SideB: wrTeamC, SideBID: wrTeamCID}}, + }})) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + require.NoError(t, err) + require.NoError(t, store.UpdateBracket(compID, func(b *state.Bracket) error { + b.Rounds[1][0].Status = state.MatchStatusRunning + b.Rounds[1][0].IpponsA = []string{"M"} + return nil + })) + + for _, force := range []bool{false, true} { + _, err = eng.ReopenMatch(compID, "m-r1-0", "Withdrawal recorded by mistake", ForceOptions{Force: force}) + // bc-cse: m-r2-0 is RUNNING, so this is DownstreamKnockoutRunningError + // now, not the bare ErrReopenDownstreamFought sentinel. + require.ErrorIs(t, err, ErrDownstreamKnockoutRunning, "force=%v", force) + } + b, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusCompleted, b.Rounds[0][0].Status) + assert.Equal(t, state.MatchStatusRunning, b.Rounds[1][0].Status) +} + +// The gate is unchanged for every other match: a completed non-kachinuki +// match decided by neither a withdrawal nor a default win is corrected, not +// reopened. fusensho is deliberately NOT in this list since bc-cse: see +// TestReopenWithdrawal_FusenshoAcceptedRestoresNobody for its own (accepted) +// case. +func TestReopenWithdrawal_OtherMatchesAreStillRefused(t *testing.T) { + for _, decision := range []string{"", "fought", "hikiwake", "daihyosen"} { + t.Run("decision "+decision, func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "rw-refused" + createTestCompetition(t, store, compID, "league", 3) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusCompleted, Winner: wrTeamA, WinnerID: wrTeamAID, + IpponsA: []string{"M"}, Decision: decision, + }})) + _, err := eng.ReopenMatch(compID, "Pool A-0", "reason") + var verr *ValidationError + require.ErrorAs(t, err, &verr) + assert.Equal(t, state.MatchStatusCompleted, wrPoolMatch(t, store, compID).Status) + }) + } + t.Run("a withdrawal-decided match that is not completed", func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "rw-running" + createTestCompetition(t, store, compID, "league", 3) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: wrTeamA, SideB: wrTeamB, Status: state.MatchStatusRunning, + }})) + _, err := eng.ReopenMatch(compID, "Pool A-0", "reason") + require.ErrorIs(t, err, ErrReopenNotCompleted) + }) +} + +// bc-cse: a match-level fusensho (default win) is now reopenable, exactly +// like a withdrawal -- but fusensho never recorded a CompetitorStatus for +// anyone (domain.IsWithdrawalDecisionStr excludes it, recordIneligibilityFromDecision +// only fires for a withdrawal), so reopening one restores nobody's +// eligibility: there was nothing to restore. +func TestReopenWithdrawal_FusenshoAcceptedRestoresNobody(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, "fusensho") + + status, err := eng.ReopenMatch(compID, "Pool A-0", "Default win recorded by mistake") + require.NoError(t, err, "fusensho must be reopenable (bc-cse)") + assert.Nil(t, status, "fusensho barred nobody, so nothing is restored") + + m := wrPoolMatch(t, store, compID) + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, "", m.Decision) + assert.Equal(t, "", m.DecisionBy) + assert.Equal(t, "", m.Winner) + assert.Empty(t, m.IpponsB, "the default-win maru goes with the verdict") + assert.Equal(t, "Default win recorded by mistake", m.CorrectionReason) +} + +// The warn-and-proceed is the reopen's one rule, so a kachinuki reopen whose +// final has its own result is asked about too, and proceeds on force. +func TestReopenKachinuki_ClosedDownstreamIsWarnAndProceed(t *testing.T) { + eng, store, _ := setupKachinukiComp(t, "rw-kachi-ko", 3) + require.NoError(t, store.SaveBracket("rw-kachi-ko", &state.Bracket{Rounds: [][]state.BracketMatch{ + { + { + ID: "SF0", SideA: "RedTeam", SideB: "WhiteTeam", Status: state.MatchStatusCompleted, + Winner: "RedTeam", Decision: "kachinuki-exhaustion", + SubResults: []state.SubMatchResult{{Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M"}, Winner: "R-1", Decision: "fought"}}, + }, + {ID: "SF1", SideA: "Kuma", SideB: "Washi", Status: state.MatchStatusCompleted, Winner: "Kuma", IpponsA: []string{"M"}}, + }, + { + { + ID: "F0", SideA: "RedTeam", SideB: "Kuma", Status: state.MatchStatusCompleted, Winner: "Kuma", + Decision: "kachinuki-exhaustion", + SubResults: []state.SubMatchResult{{Position: 1, SideA: "R-1", SideB: "K-1", IpponsB: []string{"M"}, Winner: "K-1", Decision: "fought"}}, + }, + }, + }})) + _, err := eng.ReopenMatch("rw-kachi-ko", "SF0", "") + require.ErrorIs(t, err, ErrDownstreamKnockoutPlayed) + + var reopened []ReopenedMatch + _, err = eng.ReopenMatch("rw-kachi-ko", "SF0", "", ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.Len(t, reopened, 1) + b, err := store.LoadBracket("rw-kachi-ko") + require.NoError(t, err) + assert.Equal(t, state.MatchStatusRunning, b.Rounds[0][0].Status) + assert.Equal(t, state.MatchStatusScheduled, b.Rounds[1][0].Status) + assert.Equal(t, "Winner of r2-m0", b.Rounds[1][0].SideA) +} + +// The confirmed reopen takes the reopened downstream match's own winner back +// out of a round beyond it that nobody has touched, as a forced correction +// does: the final no longer names RedTeam once the semifinal RedTeam won is +// reopened. +func TestReopenKachinuki_ForcedReopenRetractsFromAnUntouchedRound(t *testing.T) { + const compID = "rw-kachi-3r" + eng, store, _ := setupKachinukiComp(t, compID, 3) + bout := func(a, b string) []state.SubMatchResult { + return []state.SubMatchResult{{Position: 1, SideA: a, SideB: b, IpponsA: []string{"M"}, Winner: a, Decision: "fought"}} + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "QF0", SideA: "RedTeam", SideB: "WhiteTeam", Status: state.MatchStatusCompleted, Winner: "RedTeam", + Decision: "kachinuki-exhaustion", SubResults: bout("R-1", "W-1")}}, + {{ID: "SF0", SideA: "RedTeam", SideB: "Kuma", Status: state.MatchStatusCompleted, Winner: "RedTeam", + Decision: "kachinuki-exhaustion", SubResults: bout("R-1", "K-1")}}, + {{ID: "F0", SideA: "RedTeam", SideB: "Washi", Status: state.MatchStatusScheduled}}, + }})) + + var reopened []ReopenedMatch + _, err := eng.ReopenMatch(compID, "QF0", "", ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.Equal(t, []string{"SF0"}, reopenedIDs(reopened)) + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusRunning, b.Rounds[0][0].Status) + assert.Equal(t, state.MatchStatusScheduled, b.Rounds[1][0].Status) + final := b.Rounds[2][0] + assert.Equal(t, winnerOfPlaceholder(len(b.Rounds)-1, 0), final.SideA, + "the untouched final waits for the reopened semifinal instead of naming RedTeam") + assert.Equal(t, "Washi", final.SideB) +} + +// A team competition's -DH- rep bout is a single bout: the match carries no +// bout rows and its match-level scoreline IS the bout. Reopening a withdrawal +// that decided it keeps who fought it (RepPlayerA/B) with the letters struck, +// the same as the fight itself: a reopen that cleared them lost the fighters' +// names while keeping their points. +func TestReopenWithdrawal_TeamRepBoutKeepsWhoFought(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "rw-rep" + createTestCompetition(t, store, compID, "league", 3, func(c *state.Competition) { + c.Kind = "team" + c.TeamSize = 3 + }) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-DH-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + RepPlayerA: "r2", RepPlayerB: "t2", IpponsA: []string{"M"}, + Status: state.MatchStatusRunning, + }})) + _, _, err := eng.RecordDecision(compID, "Pool A-DH-0", "kiken-voluntary", "aka", "knee", nil, false) + require.NoError(t, err) + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Equal(t, "kiken-voluntary", ms[0].Decision) + require.Equal(t, "r2", ms[0].RepPlayerA, "precondition: the decision kept who fought") + + _, err = eng.ReopenMatch(compID, "Pool A-DH-0", "Withdrawal recorded by mistake") + require.NoError(t, err) + ms, err = store.LoadPoolMatches(compID) + require.NoError(t, err) + m := ms[0] + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, []string{"M"}, m.IpponsA, "the struck letter is kept") + assert.Equal(t, "r2", m.RepPlayerA, "who fought for Ryu is kept with the letter they struck") + assert.Equal(t, "t2", m.RepPlayerB, "who fought for Tora is kept") +} + +// seedKnockoutFinalWithdrawal is an individual knockout: round 1 Ryu v Tora, +// the final Tora (or whoever round 1 sends through) v Kuma. Round 1 is +// settled by first(eng), then Kuma withdraws from the final through the real +// decision path, which bars Kuma. +func seedKnockoutFinalWithdrawal(t *testing.T, first func(eng *Engine, compID string)) (*Engine, *state.Store, string) { + t.Helper() + eng, store, _ := setupTestEngine(t) + const compID = "rw-final" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "rw", Kind: "individual", Status: state.CompStatusKnockout})) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning, MatchNumber: 1}}, + {{ID: "m-r2-0", SideB: wrTeamC, SideBID: wrTeamCID, MatchNumber: 2}}, + }})) + first(eng, compID) + b, err := store.LoadBracket(compID) + require.NoError(t, err) + require.Equal(t, wrTeamB, b.Rounds[1][0].SideA, "precondition: Tora went through to the final") + _, _, err = eng.RecordDecision(compID, "m-r2-0", "kiken-voluntary", "shiro", "knee", nil, false) + require.NoError(t, err) + require.False(t, wrEligible(t, store, compID, wrTeamCID), "precondition: the final's withdrawal barred Kuma") + return eng, store, compID +} + +// A downstream match a confirmed reopen or correction reopens loses its +// verdict, and when that verdict was a withdrawal the competitor it barred +// is eligible again (restoreForceReopened), exactly as on the match the +// operator acted on. Before, the reopened final kept Kuma barred, so it could +// never be started again. Pinned on every door that reopens downstream: the +// reopen itself, a forced score correction and a forced winner override. +func TestForceReopenedDownstreamWithdrawalRestoresEligibility(t *testing.T) { + fought := func(eng *Engine, compID string) { + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", &state.MatchResult{ + ID: "m-r1-0", SideA: wrTeamA, SideB: wrTeamB, Winner: wrTeamB, + IpponsB: []string{"M", "K"}, Status: state.MatchStatusCompleted, + }) + require.NoError(t, err) + } + cases := []struct { + name string + first func(eng *Engine, compID string) + reopen func(t *testing.T, eng *Engine, compID string) []ReopenedMatch + }{ + { + name: "clear withdrawal and reopen", + first: func(eng *Engine, compID string) { + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "aka", "", nil, false) + require.NoError(t, err) + }, + reopen: func(t *testing.T, eng *Engine, compID string) []ReopenedMatch { + var reopened []ReopenedMatch + _, err := eng.ReopenMatch(compID, "m-r1-0", "Withdrawal recorded by mistake", ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + return reopened + }, + }, + { + name: "forced score correction", + first: fought, + reopen: func(t *testing.T, eng *Engine, compID string) []ReopenedMatch { + var reopened []ReopenedMatch + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", &state.MatchResult{ + ID: "m-r1-0", SideA: wrTeamA, SideB: wrTeamB, Winner: wrTeamA, + IpponsA: []string{"M", "K"}, IpponsB: []string{}, Status: state.MatchStatusCompleted, + CorrectionReason: "Scoring error: wrong side", + }, ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + return reopened + }, + }, + { + name: "forced winner override", + first: fought, + reopen: func(t *testing.T, eng *Engine, compID string) []ReopenedMatch { + var reopened []ReopenedMatch + applied, err := eng.OverrideBracketWinner(compID, "m-r1-0", wrTeamA, 0, ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.True(t, applied) + return reopened + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + eng, store, compID := seedKnockoutFinalWithdrawal(t, tc.first) + reopened := tc.reopen(t, eng, compID) + + require.Len(t, reopened, 1) + assert.Equal(t, "m-r2-0", reopened[0].ID) + assert.True(t, wrEligible(t, store, compID, wrTeamCID), "the final's withdrawal is gone, so Kuma can compete again") + require.NotNil(t, reopened[0].Restored, "the restore is reported for the handler to broadcast") + assert.Equal(t, wrTeamCID, reopened[0].Restored.PlayerID) + b, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusScheduled, b.Rounds[1][0].Status) + assert.Empty(t, b.Rounds[1][0].Decision) + }) + } +} + +// A downstream match that no withdrawal decided has nothing to restore. +func TestForceReopenedDownstreamWithoutWithdrawalRestoresNothing(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "rw-final-fought" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "rw", Kind: "individual", Status: state.CompStatusKnockout})) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning, MatchNumber: 1}}, + {{ID: "m-r2-0", SideB: wrTeamC, SideBID: wrTeamCID, MatchNumber: 2}}, + }})) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "aka", "", nil, false) + require.NoError(t, err) + _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r2-0", &state.MatchResult{ + ID: "m-r2-0", SideA: wrTeamB, SideB: wrTeamC, Winner: wrTeamB, + IpponsA: []string{"M", "K"}, Status: state.MatchStatusCompleted, + }) + require.NoError(t, err) + var reopened []ReopenedMatch + _, err = eng.ReopenMatch(compID, "m-r1-0", "Withdrawal recorded by mistake", ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.Len(t, reopened, 1) + assert.Nil(t, reopened[0].Restored) +} diff --git a/internal/engine/restore_touches_no_eligibility_test.go b/internal/engine/restore_touches_no_eligibility_test.go new file mode 100644 index 000000000..2924994b3 --- /dev/null +++ b/internal/engine/restore_touches_no_eligibility_test.go @@ -0,0 +1,62 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A correction the server refuses is rolled back by replaying the match's +// prior result (rollbackMatchResultTx, matchWriteRestore). That replay used to +// re-derive eligibility from the snapshot, so a snapshot holding a withdrawal +// re-barred its loser: a kiken-injury competitor the doctor had cleared, whose +// match the operator then tried to correct, was made ineligible again by a +// correction that never landed. The first attempt of the confirm flow is +// always refused (downstream_knockout_played) and the score door commits the +// transaction on that refusal, so the re-bar reached disk every time. +func TestRefusedCorrectionRollbackKeepsAReinstatedCompetitorEligible(t *testing.T) { + f := newRQFixture(t, "rq-restore-elig", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) + + // A2 withdraws injured from Pool A-0 (A1 is aka, A2 shiro), so A1 wins + // the pool; B1 wins Pool B; A1 then beats B1 in the knockout. + _, st, err := f.eng.RecordDecision(f.compID, "Pool A-0", "kiken-injury", "shiro", "", nil, false) + require.NoError(t, err) + require.NotNil(t, st) + require.Equal(t, rqID("A2"), st.PlayerID) + f.scorePool("Pool B-0", "B1") + f.resolve() + ko, _ := f.slot("Pool A-1st") + require.NoError(t, f.scoreKO(ko.ID, "A1")) + + // The doctor clears A2. + reinstated, err := f.eng.ReinstateCompetitor(f.compID, rqID("A2")) + require.NoError(t, err) + require.True(t, reinstated.Eligible) + + statusPath := filepath.Join(f.store.GetFolder(), "competitions", f.compID, "competitor-status.yaml") + before, err := os.ReadFile(statusPath) + require.NoError(t, err) + + // The operator corrects Pool A-0 to a fought win for A2 (a decided + // result, so it replaces the withdrawal rather than keeping it). A2 then + // tops Pool A, moving a qualifier out of a knockout match already fought: + // refused until confirmed, and the pool row is rolled back. + correction := f.poolResult("Pool A-0", "A2") + correction.Decision = "fought" + correction.CorrectionReason = "the withdrawal was recorded on the wrong match" + err = f.write("Pool A-0", correction) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + + assert.Equal(t, "kiken-injury", loadPoolMatchByID(t, f.store, f.compID, "Pool A-0").Decision, + "the refused correction is rolled back") + statuses, err := f.store.LoadCompetitorStatus(f.compID) + require.NoError(t, err) + assert.True(t, statuses[rqID("A2")].Eligible, "the rollback must not re-bar the reinstated competitor") + after, err := os.ReadFile(statusPath) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "a refused write leaves competitor-status.yaml untouched") +} diff --git a/internal/engine/retract_through_bye_test.go b/internal/engine/retract_through_bye_test.go new file mode 100644 index 000000000..69c5799ae --- /dev/null +++ b/internal/engine/retract_through_bye_test.go @@ -0,0 +1,257 @@ +package engine + +import ( + "fmt" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// retract_through_bye_test.go pins retractIntoUntouched against a winner that +// was passed through a hidden bye. Knockout rounds count back from the final, +// so a pair whose neighbouring slot pair is empty feeds a pass-through match +// that generation leaves completed and propagateBracketWinner resolves off the +// winner; that winner is seated in the first real match past it. A correction +// that reopens the pair (a pool correction's requalification, or a forced +// knockout correction reopening the next round) must take the winner back out +// of that match too, unwinding the bye, whenever nobody has touched it since. + +// sixQualifierBlock is a mixed competition on one court with six pools of two +// and one qualifier each: six qualifiers in an eight-slot block, laid out +// [A, B, C, D, E, F, "", ""]. Pool E-1st v Pool F-1st (m-r1-2) sits beside the +// empty pair, so its winner passes through the hidden bye m-r2-1 into the +// block final m-r3-0. Every pool is played with X1 beating X2, the knockout is +// seated, and E1 beats F1, so the bye carries E1 into the final. +func sixQualifierBlock(t *testing.T, compID string) *rqFix { + t.Helper() + pools := [][]string{{"A1", "A2"}, {"B1", "B2"}, {"C1", "C2"}, {"D1", "D2"}, {"E1", "E2"}, {"F1", "F2"}} + f := newRQFixture(t, compID, 1, pools) + + // Pin the draw shape this test reasons about, so a change to the draw + // fails here rather than as a confusing assertion below. + b := f.bracket() + require.Len(t, b.Rounds, 3) + pair := b.Rounds[0][2] + require.Equal(t, "m-r1-2", pair.ID) + require.Equal(t, [2]string{"Pool E-1st", "Pool F-1st"}, [2]string{pair.PlaceholderA, pair.PlaceholderB}) + empty := b.Rounds[0][3] + require.Equal(t, [2]string{"", ""}, [2]string{empty.SideA, empty.SideB}, "the pair beside E v F is empty") + bye := b.Rounds[1][1] + require.Equal(t, "m-r2-1", bye.ID) + require.Equal(t, winnerOfPlaceholder(3, 2), bye.SideA) + require.Empty(t, bye.SideB) + require.Equal(t, state.MatchStatusCompleted, bye.Status, "generation leaves the hidden bye completed with no winner") + require.Empty(t, bye.Winner) + final := b.Rounds[2][0] + require.Equal(t, "m-r3-0", final.ID) + require.Equal(t, winnerOfPlaceholder(2, 1), final.SideB) + + for _, pn := range pools { + pool := "Pool " + pn[0][:1] + f.scorePool(pool+"-0", pn[0]) + } + f.resolve() + + require.NoError(t, f.scoreKO("m-r1-2", "E1")) + b = f.bracket() + require.Equal(t, "E1", b.Rounds[1][1].Winner, "the bye resolved off E v F's winner") + require.Equal(t, "E1", b.Rounds[2][0].SideB, "and passed them into the block final") + require.Equal(t, rqID("E1"), b.Rounds[2][0].SideBID) + return f +} + +// playSemifinalOne plays the other side of the block, so the final names both +// sides: A1 v E1. +func playSemifinalOne(t *testing.T, f *rqFix) { + t.Helper() + require.NoError(t, f.scoreKO("m-r1-0", "A1")) + require.NoError(t, f.scoreKO("m-r1-1", "C1")) + require.NoError(t, f.scoreKO("m-r2-0", "A1")) + final := f.bracket().Rounds[2][0] + require.Equal(t, [2]string{"A1", "E1"}, [2]string{final.SideA, final.SideB}) + require.True(t, bracketMatchPlayable(&final)) +} + +// A pool correction that moves Pool E's qualifier reopens E v F, and the block +// final past the bye, which nobody has touched, must stop naming E1. Judging +// the bye instead of the final read the bye as played (it is completed, with +// a winner), kept it, and left E1 seated in a scheduled final with both sides +// named: a final that could be started with a competitor who no longer +// qualifies. +func TestRequalify_RetractsThroughAByeFromAnUntouchedFinal(t *testing.T) { + f := sixQualifierBlock(t, "rtb-requalify") + playSemifinalOne(t, f) + + err := f.write("Pool E-0", f.poolResult("Pool E-0", "E2")) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Equal(t, []string{"m-r1-2"}, reopenedIDs(played.Blocking), "only the match E1 fought is named; the bye and the untouched final are not") + assert.Equal(t, "E1", played.Displaced) + + var reopened []ReopenedMatch + require.NoError(t, f.write("Pool E-0", f.poolResult("Pool E-0", "E2"), ForceOptions{Force: true, Reopened: &reopened})) + require.Equal(t, []string{"m-r1-2"}, reopenedIDs(reopened)) + + b := f.bracket() + pair := b.Rounds[0][2] + assert.Equal(t, state.MatchStatusScheduled, pair.Status) + assert.Equal(t, "E2", pair.SideA, "the new qualifier is seated in the reopened match") + assert.Equal(t, rqID("E2"), pair.SideAID) + + bye := b.Rounds[1][1] + assert.Empty(t, bye.Winner, "the bye no longer passes E1 on") + assert.Empty(t, bye.WinnerID) + assert.Equal(t, winnerOfPlaceholder(3, 2), bye.SideA, "the bye's fed side is back to its placeholder") + assert.Empty(t, bye.SideAID) + assert.Equal(t, state.MatchStatusCompleted, bye.Status, "a hidden bye stays completed, as generation left it") + + final := b.Rounds[2][0] + assert.Equal(t, winnerOfPlaceholder(2, 1), final.SideB, "the untouched final waits for E v F again instead of naming E1") + assert.Empty(t, final.SideBID, "a placeholder carries no id") + assert.Equal(t, "A1", final.SideA, "the other side is not this correction's") + assert.Equal(t, state.MatchStatusScheduled, final.Status) + assert.False(t, bracketMatchPlayable(&final)) + + // Starting it (a running-status write through the score door) is refused: + // a feeder has not finished. + start := &state.MatchResult{SideA: final.SideA, SideB: final.SideB, SideAID: final.SideAID, SideBID: final.SideBID, Status: state.MatchStatusRunning} + err = f.write("m-r3-0", start) + require.Error(t, err, "a final with an unresolved side cannot be started") + assert.Contains(t, err.Error(), "not ready to score") + + // Fought again, the bye passes the NEW qualifier on exactly as it passed + // the first one, which is what shows the unwind restored generation's shape. + require.NoError(t, f.scoreKO("m-r1-2", "E2")) + b = f.bracket() + assert.Equal(t, "E2", b.Rounds[1][1].Winner) + assert.Equal(t, rqID("E2"), b.Rounds[1][1].WinnerID) + assert.Equal(t, "E2", b.Rounds[2][0].SideB) + assert.Equal(t, rqID("E2"), b.Rounds[2][0].SideBID) +} + +// A final that was already FOUGHT past the bye is one hop further than the +// pool correction reaches: the bye and the final are left exactly as they +// are, and the operator is asked about the final in its own turn, when E v F +// is fought again with a different winner. +func TestRequalify_LeavesAByeBeforeAPlayedFinal(t *testing.T) { + f := sixQualifierBlock(t, "rtb-requalify-played") + playSemifinalOne(t, f) + require.NoError(t, f.scoreKO("m-r3-0", "E1")) + + var reopened []ReopenedMatch + require.NoError(t, f.write("Pool E-0", f.poolResult("Pool E-0", "E2"), ForceOptions{Force: true, Reopened: &reopened})) + require.Equal(t, []string{"m-r1-2"}, reopenedIDs(reopened), "the played final is not this correction's hop") + + b := f.bracket() + bye := b.Rounds[1][1] + assert.Equal(t, "E1", bye.SideA, "the bye before a played final is not unwound") + assert.Equal(t, "E1", bye.Winner) + assert.Equal(t, rqID("E1"), bye.WinnerID) + final := b.Rounds[2][0] + assert.Equal(t, "E1", final.SideB) + assert.Equal(t, rqID("E1"), final.SideBID) + assert.Equal(t, "E1", final.Winner) + assert.Equal(t, state.MatchStatusCompleted, final.Status) + + // Its own warning, through the bye it kept: E2 winning E v F would + // displace E1 from the played final. + err := f.scoreKO("m-r1-2", "E2") + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + assert.Equal(t, "m-r3-0", played.BlockingMatchID) + assert.Equal(t, "E1", played.Displaced) +} + +// nineDrawFixture is a nine-competitor knockout on one court. P00 v P01 +// (m-r1-0) and P02 v P03 (m-r1-1) meet in m-r2-0, whose winner meets nobody in +// m-r3-0 (the other half of that pairing is an empty "" vs "" block), so the +// bye passes them straight to the final m-r4-0. The first round is played and +// P00 wins m-r2-0, so the bye carries P00 into the final, which nobody has +// touched (its other half is unplayed). +func nineDrawFixture(t *testing.T, compID string) (*Engine, *state.Store) { + t.Helper() + eng, store, _ := setupTestEngine(t) + createTestCompetition(t, store, compID, "knockout", 3) + players := make([]domain.Player, 9) + for i := range players { + players[i] = domain.Player{Name: fmt.Sprintf("P%02d", i), Dojo: fmt.Sprintf("D%02d", i)} + } + require.NoError(t, store.SaveParticipants(compID, players)) + require.NoError(t, eng.StartCompetition(compID)) + + // Pin the draw shape this test reasons about. + b := loadBracket(t, store, compID) + require.Len(t, b.Rounds, 4) + require.Equal(t, [2]string{"P00", "P01"}, [2]string{b.Rounds[0][0].SideA, b.Rounds[0][0].SideB}) + require.Equal(t, [2]string{"P02", "P03"}, [2]string{b.Rounds[0][1].SideA, b.Rounds[0][1].SideB}) + require.Equal(t, [2]string{winnerOfPlaceholder(4, 0), winnerOfPlaceholder(4, 1)}, [2]string{b.Rounds[1][0].SideA, b.Rounds[1][0].SideB}) + bye := b.Rounds[2][0] + require.Equal(t, "m-r3-0", bye.ID) + require.Equal(t, winnerOfPlaceholder(3, 0), bye.SideA) + require.Empty(t, bye.SideB) + require.Equal(t, state.MatchStatusCompleted, bye.Status, "generation leaves the bye completed with no winner") + require.Empty(t, bye.Winner) + require.Equal(t, winnerOfPlaceholder(2, 0), b.Rounds[3][0].SideA) + + scoreBracketMatch(t, eng, store, compID, "m-r1-0", "P00") + scoreBracketMatch(t, eng, store, compID, "m-r1-1", "P02") + scoreBracketMatch(t, eng, store, compID, "m-r2-0", "P00") + b = loadBracket(t, store, compID) + require.Equal(t, "P00", b.Rounds[2][0].Winner, "the bye resolved off m-r2-0's winner") + require.Equal(t, "P00", b.Rounds[3][0].SideA, "and passed them into the final") + require.Equal(t, state.MatchStatusScheduled, b.Rounds[3][0].Status) + return eng, store +} + +// A forced knockout correction reopens the next round it displaced someone +// from (m-r2-0) and takes back what THAT match had sent on: past the bye, the +// untouched final stops naming P00, whose place in it rested on a win the +// correction just cleared. +func TestForcedCorrection_RetractsThroughAByeFromAnUntouchedFinal(t *testing.T) { + compID := "rtb-correction" + eng, store := nineDrawFixture(t, compID) + correction := func() *state.MatchResult { + m := findBracketMatchInBracket(loadBracket(t, store, compID), "m-r1-0") + return &state.MatchResult{SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, + Status: state.MatchStatusCompleted, Winner: m.SideB, WinnerID: m.SideBID, IpponsB: []string{"M"}, + CorrectionReason: "the wrong winner was entered"} + } + + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", correction()) + var played *DownstreamKnockoutPlayedError + require.ErrorAs(t, err, &played) + require.Equal(t, "m-r2-0", played.BlockingMatchID) + + var reopened []ReopenedMatch + _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", correction(), ForceOptions{Force: true, Reopened: &reopened}) + require.NoError(t, err) + require.Equal(t, []string{"m-r2-0"}, reopenedIDs(reopened)) + + b := loadBracket(t, store, compID) + semi := b.Rounds[1][0] + assert.Equal(t, state.MatchStatusScheduled, semi.Status) + assert.Equal(t, [2]string{"P01", "P02"}, [2]string{semi.SideA, semi.SideB}) + assert.Empty(t, semi.Winner) + + bye := b.Rounds[2][0] + assert.Empty(t, bye.Winner, "the bye no longer passes P00 on") + assert.Empty(t, bye.WinnerID) + assert.Equal(t, winnerOfPlaceholder(3, 0), bye.SideA, "the bye's fed side is back to its placeholder") + assert.Empty(t, bye.SideAID) + assert.Equal(t, state.MatchStatusCompleted, bye.Status) + + final := b.Rounds[3][0] + assert.Equal(t, winnerOfPlaceholder(2, 0), final.SideA, "the untouched final waits for m-r2-0 again instead of naming P00") + assert.Empty(t, final.SideAID, "a placeholder carries no id") + assert.Equal(t, state.MatchStatusScheduled, final.Status) + + // Fought again, the bye passes the new winner on. + scoreBracketMatch(t, eng, store, compID, "m-r2-0", "P01") + b = loadBracket(t, store, compID) + assert.Equal(t, "P01", b.Rounds[2][0].Winner) + assert.Equal(t, "P01", b.Rounds[3][0].SideA) + assert.Equal(t, b.Rounds[1][0].SideAID, b.Rounds[3][0].SideAID) +} diff --git a/internal/engine/scheduler_slots.go b/internal/engine/scheduler_slots.go index c6d0e8b8d..4fbf942a6 100644 --- a/internal/engine/scheduler_slots.go +++ b/internal/engine/scheduler_slots.go @@ -2,6 +2,7 @@ package engine import ( "math" + "sort" "time" "github.com/gitrgoliveira/bracket-creator/internal/state" @@ -10,7 +11,7 @@ import ( // scheduleClockLayout is the only time format used here. Wire format // is HH:MM (24h), with a zero-valued date so we operate purely on // minutes-of-day arithmetic. T150 / T151. -const scheduleClockLayout = "15:04" +const scheduleClockLayout = state.ScheduledAtLayout // defaultLunchStartClock is the fallback start-of-lunch when a // tournament defines LunchBlock duration but does not (yet) carry a @@ -259,14 +260,15 @@ func assignPoolMatchSlots(matches []state.MatchResult, comp *state.Competition, // assignBracketMatchSlots is the bracket analogue of // assignPoolMatchSlots. Bracket matches carry the same Court field -// as pool matches; matches are walked round-by-round, court-by- -// court, and each court's cursor advances by perMatchElapsedMinutes +// as pool matches; matches are walked in match-number order (see the +// body), and each court's cursor advances by perMatchElapsedMinutes // after every assignment. T150, T151. // -// Auto-resolved bye matches (Status == Completed at generation time) -// still receive a ScheduledAt for UI consistency, the operator- -// facing schedule lists them even though no play happens. The court -// cursor is NOT advanced for byes (they consume no court time). +// Unnumbered rows (the draw's byes, hidden pass-through matches and empty +// pairs, all Completed at generation time) take their court's opening time, +// ahead of Match 1: nothing is played on them and no surface lists them as +// a match to play. The court cursor is NOT advanced for them (they consume +// no court time). // // Returns the maximum per-court end-cursor (the clock time when the // last match on the busiest court finishes). Callers that only want @@ -291,26 +293,43 @@ func assignBracketMatchSlots(rounds [][]state.BracketMatch, comp *state.Competit perMatchMin := perMatchElapsedMinutes(comp, tournament, true /*isKnockout*/) + // Order of play follows the match numbers (state.Bracket.NumberMatches: + // deepest round first, then left to right), so a court plays Match 1 + // before Match 2 and every round before the next. The storage order is + // not the order of play: a pair beside an empty pair sits in the first + // storage row but is fought a round later. Unnumbered matches (the draw's + // byes and empty pairs, and hand-built rounds that carry no numbers) keep + // storage order and go first; a bye takes no court time. + var ordered, numbered []*state.BracketMatch for rIdx := range rounds { round := rounds[rIdx] for mIdx := range round { - m := &round[mIdx] - court := m.Court - cursor, ok := courtCursor[court] - if !ok { - cursor = dayStart.Add(time.Duration(openingMin) * time.Minute) + if m := &round[mIdx]; m.MatchNumber > 0 { + numbered = append(numbered, m) + } else { + ordered = append(ordered, m) } - cursor = skipCeremonyBlocks(cursor, lunchStart, lunchMin) - m.ScheduledAt = cursor.Format(scheduleClockLayout) + } + } + sort.SliceStable(numbered, func(i, j int) bool { return numbered[i].MatchNumber < numbered[j].MatchNumber }) + ordered = append(ordered, numbered...) - // Don't advance the court cursor for auto-resolved byes. - // They occupy no real time on the court, the next round - // would otherwise inherit a phantom delay. - if m.Status != state.MatchStatusCompleted { - cursor = cursor.Add(time.Duration(perMatchMin) * time.Minute) - } - courtCursor[court] = cursor + for _, m := range ordered { + court := m.Court + cursor, ok := courtCursor[court] + if !ok { + cursor = dayStart.Add(time.Duration(openingMin) * time.Minute) } + cursor = skipCeremonyBlocks(cursor, lunchStart, lunchMin) + m.ScheduledAt = cursor.Format(scheduleClockLayout) + + // Don't advance the court cursor for auto-resolved byes. + // They occupy no real time on the court, the next round + // would otherwise inherit a phantom delay. + if m.Status != state.MatchStatusCompleted { + cursor = cursor.Add(time.Duration(perMatchMin) * time.Minute) + } + courtCursor[court] = cursor } // Find the maximum end-cursor across all courts. Seed with dayStart diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 762fead22..40fb8a5b9 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -555,9 +555,34 @@ const ( // sheet, the bracket and the Excel tree. Number is 0 for a match that never got // one (a bye placeholder, or a bracket saved before numbering existed), and a // caller showing it must fall back rather than print "Match 0". +// +// DisplayRound is the match's effective round counted from the final +// (state.BracketMatch.DisplayRound: 1 = Final, 2 = Semifinals, ...), so +// MatchLabel can say which round of the KNOCKOUT the match is in. Without it +// "Match 1" collided with the pool match the operator was correcting +// ("Pool A · Match 1"), which is numbered from 1 as well. 0 when the bracket +// predates the field. +// +// PriorDecision and Restored are set only on a match forceReopenDownstreamChain +// reopened. PriorDecision is the decision the reopen cleared, and Restored is +// the eligibility that gave back when that decision was a withdrawal +// (restoreForceReopened): a withdrawal the reopen removed bars nobody, exactly +// as on the match the operator reopened or corrected. The handler broadcasts +// Restored as competitor_status_updated. type ReopenedMatch struct { - ID string - Number int + ID string + Number int + DisplayRound int + PriorDecision string + Restored *domain.CompetitorStatus +} + +// bracketMatchRef is the ONE way a bracket match becomes a ReopenedMatch, so +// every door that names a knockout match to the operator (the played and +// running refusals, and the matches a confirmed correction reopened) carries +// the same identity MatchLabel reads. +func bracketMatchRef(m *state.BracketMatch) ReopenedMatch { + return ReopenedMatch{ID: m.ID, Number: m.MatchNumber, DisplayRound: m.DisplayRound} } type ForceOptions struct { @@ -568,7 +593,11 @@ type ForceOptions struct { // the write is refused unless Force is true. A true Force also reopens // (reopenBracketMatch) the ONE downstream match that carries // its own result, since the correction just repainted their SideA/SideB - // out from under their recorded verdicts. + // out from under their recorded verdicts. A POOL write in a mixed + // competition that moves a qualifier out of a knockout match already + // fought is refused the same way, and Force reopens those matches with + // the new qualifier seated (requalifyAfterPoolWrite). It never gets past + // a knockout match being fought now (DownstreamKnockoutRunningError). Force bool // Reopened, when non-nil, is populated with the IDs of every downstream // bracket match reopened by a forced correction (empty when Force is @@ -615,13 +644,13 @@ func firstForceOptions(opts []ForceOptions) ForceOptions { // match). // force (bc-kcdg) reaches ONLY the bracket branch's downstream-knockout- // correction guard; the pool branch has no equivalent concept and ignores it. -func (e *Engine) writeToPoolOrBracket(h state.StoreTx, compId, matchId string, result *state.MatchResult, policy matchWritePolicy, force bool) (mismatch bool, reopened []ReopenedMatch, err error) { +func (e *Engine) writeToPoolOrBracket(h state.StoreTx, compId, matchId string, result *state.MatchResult, policy matchWritePolicy, force bool) (mismatch bool, reopened []ReopenedMatch, inherited bool, err error) { var superseded bool perr := e.withPoolMatch(h, compId, matchId, func(r *state.MatchResult) error { // The POOL branch of the path POST /score and the bulk-score endpoint // actually take — the site the hand-copied merge once missed. var werr error - mismatch, superseded, werr = applyPoolWrite(r, result, policy) + mismatch, superseded, inherited, werr = applyPoolWrite(r, result, policy) if werr != nil { // A genuine validation failure (e.g. backfillMatchIdentity's // winnerId-names-neither-side check): propagate it AS the error, @@ -649,16 +678,16 @@ func (e *Engine) writeToPoolOrBracket(h state.StoreTx, compId, matchId string, r // path here rather than being reported as a store failure. if perr == nil || errors.Is(perr, errPoolWriteDropped) { if superseded { - return false, nil, ErrMatchSuperseded + return false, nil, false, ErrMatchSuperseded } - return mismatch, nil, nil + return mismatch, nil, inherited && !mismatch, nil } if !errors.Is(perr, errMatchNotFound) { - return false, nil, perr + return false, nil, false, perr } // The SAME policy the pool branch would have used. - reopened, err = e.recordBracketMatchResult(h, compId, matchId, result, policy, force) - return false, reopened, err + reopened, inherited, err = e.recordBracketMatchResult(h, compId, matchId, result, policy, force) + return false, reopened, inherited, err } // applyPoolWrite folds the stored match into an incoming result and then @@ -691,7 +720,7 @@ func (e *Engine) writeToPoolOrBracket(h state.StoreTx, compId, matchId string, r // because it is a different verdict again -- not "wrong pairing" (mismatch) // and not "stale" (superseded), but "this winner doesn't correspond to // either competitor in this match" -- and the caller maps it to 400, not 409. -func applyPoolWrite(stored, result *state.MatchResult, policy matchWritePolicy) (mismatch, superseded bool, err error) { +func applyPoolWrite(stored, result *state.MatchResult, policy matchWritePolicy) (mismatch, superseded, inherited bool, err error) { // reconcileSides BACKFILLS omitted sides as a side effect and only reports // the mismatch, so it must run under both policies; hoisted out of the // condition below because folding it into a short-circuit would let a later @@ -701,7 +730,7 @@ func applyPoolWrite(stored, result *state.MatchResult, policy matchWritePolicy) // restore policy replays sides captured from this same match, so a mismatch // there is not a client error. if sidesDisagree && policy == matchWriteForward { - return true, false, nil + return true, false, false, nil } // Timestamp last-write-wins, the SAME guard, the same primitive and now the // same call shape the bracket branch uses: a reconnecting offline court's @@ -709,12 +738,16 @@ func applyPoolWrite(stored, result *state.MatchResult, policy matchWritePolicy) // exemption lives inside applyMatchWrite, which is load-bearing here — unlike // the bracket's, this branch's rollback snapshot carries a real stamp. if !applyMatchWrite(result, stored.ModifiedAt, policy) { - return false, true, nil + return false, true, false, nil } + // A bout-row correction over a recorded withdrawal keeps the ruling + // (bc-tmfn). Before the identity backfill, so the winner id it resolves + // and validates is the inherited one. + inherited = preserveWithdrawalRuling(rulingOfMatch(stored), result, policy) // Preserve generation-time participant ids + resolve winner id across the // overwrite: score requests carry side NAMES only. See backfillMatchIdentity. if berr := backfillMatchIdentity(result, stored, policy); berr != nil { - return false, false, berr + return false, false, false, berr } // Keep the stored stamp when this write is unstamped, so an un-stamped // client cannot reset the field to 0 and reopen the match to stale writes. @@ -768,7 +801,7 @@ func applyPoolWrite(stored, result *state.MatchResult, policy matchWritePolicy) result.SubResultsUnreadable = false } *stored = *result - return false, false, nil + return false, false, inherited, nil } // applyHansokuIppons auto-awards ippons from accumulated hansoku counts per @@ -938,6 +971,16 @@ func isWinForSide(subWinner, matchSide, subSide string) bool { // sub-bouts, or countScoringIppons feed the tie-break cannot silently diverge // between formats. func accrueTeamSubResults(sA, sB *state.PlayerStanding, m state.MatchResult) { + // The SAME crediting rule the wire summary uses (state.TeamResultFrom -> + // DefaultWinCreditSide), computed once for the whole match: a completed + // default-win ruling (any kiken, fusenpai, or fusensho) credits every + // numbered bout with no result of its own to the side the ruling + // credits, so a kiken declared before a team's bouts were scored still + // contributes IV/PW to standings instead of silently contributing + // nothing. domain.MatchSideNone for anything else, which makes + // SubBoutEffectiveResult below a no-op and reproduces the prior + // behaviour exactly. + credit := state.DefaultWinCreditSide(m.Status, m.Decision, m.DecisionBy, m.Attribution()) for _, sub := range m.SubResults { // bc-pnum: the SAME owner the wire summary uses (state.TeamResultFrom // -> SubBoutWinnerSide), so the IV a spectator reads and the IV this @@ -946,7 +989,8 @@ func accrueTeamSubResults(sA, sB *state.PlayerStanding, m state.MatchResult) { // for exactly one shape: a bout between two fighters sharing a display // name, which the name comparison here credited to side A by case // order while the summary credited the member id -- or neither. - switch state.SubBoutWinnerSide(sub, m.SideA, m.SideB) { + outcome := state.SubBoutEffectiveResult(sub, credit, m.SideA, m.SideB) + switch state.SubBoutWinnerSide(outcome, m.SideA, m.SideB) { case domain.MatchSideA: sA.IndividualWins++ sB.IndividualLosses++ @@ -954,7 +998,17 @@ func accrueTeamSubResults(sA, sB *state.PlayerStanding, m state.MatchResult) { sB.IndividualWins++ sA.IndividualLosses++ default: - if sub.Winner == "" { + // A draw is a bout that HAS a result and names no winner: a Tie + // (hikiwake) or a scored tie. A row with no result was never + // fought (an unfought bout kept on a match a withdrawal decided, + // and not credited above either -- an individual competition, or + // a match some other decision closed), and counting it as IT for + // both teams credited a draw nobody fought. + // state.SubMatchResult.HasResult is the one played-bout + // predicate, the same one the team finish gate asks; asked of + // outcome so a CREDITED bout (Winner now set, HasResult() true) + // never also falls into this branch. + if outcome.Winner == "" && outcome.HasResult() { sA.IndividualDraws++ sB.IndividualDraws++ } @@ -965,10 +1019,10 @@ func accrueTeamSubResults(sA, sB *state.PlayerStanding, m state.MatchResult) { // with the wire teamResult PW (state.TeamResultFrom uses the // same rule) so the displayed points and the tie-break points // cannot drift. - sA.PointsWon += countScoringIppons(sub.IpponsA) - sA.PointsLost += countScoringIppons(sub.IpponsB) - sB.PointsWon += countScoringIppons(sub.IpponsB) - sB.PointsLost += countScoringIppons(sub.IpponsA) + sA.PointsWon += countScoringIppons(outcome.IpponsA) + sA.PointsLost += countScoringIppons(outcome.IpponsB) + sB.PointsWon += countScoringIppons(outcome.IpponsB) + sB.PointsLost += countScoringIppons(outcome.IpponsA) } } @@ -1197,6 +1251,176 @@ func preserveLoserScore(result, prior *state.MatchResult, decisionBy string) { } } +// withdrawalRuling is the stored half of preserveWithdrawalRuling: the fields +// of a match-level withdrawal (kiken, fusenpai) that RecordDecisionTx writes +// and a bout-row correction must not undo. It is a value projection, like +// storedSides, so the pool and bracket owners share ONE merge body even +// though one stores a MatchResult and the other a BracketMatch. +// +// The unit, field by field, is exactly what recordDecisionTx sets: +// - Decision, DecisionBy, DecisionReason: the ruling itself, which side +// withdrew and the operator's note on why. +// - Winner, WinnerID: the side the ruling names as winner. A bout +// correction derives a winner from the bouts, which says nothing about a +// withdrawal and may name the other team. +// - IpponsA, IpponsB: the winner's default-win maru (domain.DefaultWinIppons) +// plus the points the withdrawing side had struck (preserveLoserScore). +// Only the maru is ruling; keptWithdrawalScoreline decides what a +// correction keeps of each side. +// - Encho: whether the withdrawal came in overtime, which is what decided +// one maru rather than two. +// +// Not in it: HansokuA/B (the decision path never sets them, so they are not +// part of the ruling), SubResults, CorrectionReason and ModifiedAt (the +// correction's own), and Status (the condition requires both to be +// completed). +type withdrawalRuling struct { + Status state.MatchStatus + Decision string + DecisionBy string + DecisionReason string + Winner string + WinnerID string + IpponsA []string + IpponsB []string + Encho *state.EnchoMetadata +} + +func rulingOfMatch(m *state.MatchResult) withdrawalRuling { + return withdrawalRuling{ + Status: m.Status, Decision: m.Decision, DecisionBy: m.DecisionBy, DecisionReason: m.DecisionReason, + Winner: m.Winner, WinnerID: m.WinnerID, IpponsA: m.IpponsA, IpponsB: m.IpponsB, Encho: m.Encho, + } +} + +func rulingOfBracketMatch(bm *state.BracketMatch) withdrawalRuling { + return withdrawalRuling{ + Status: bm.Status, Decision: bm.Decision, DecisionBy: bm.DecisionBy, DecisionReason: bm.DecisionReason, + Winner: bm.Winner, WinnerID: bm.WinnerID, IpponsA: bm.IpponsA, IpponsB: bm.IpponsB, Encho: bm.Encho, + } +} + +// KeepsWithdrawalRuling reports whether a write keeps the match-level +// withdrawal already recorded on the match (operator ruling 2026-09-24, bc-tmfn: +// "Save correction should just save what the operator enters"). It is scoped +// by the WRITE's shape, and every clause is load-bearing: +// - stored completed with a default-win decision (domain. +// IsDefaultWinDecisionStr: any kiken, fusenpai, or fusensho): there is a +// ruling to keep. Widened from IsWithdrawalDecisionStr (bc-tmfn follow- +// up) to also keep a stored FUSENSHO ruling on a correction: fusensho is +// a per-bout default win within a team encounter, not a withdrawal of +// the match it is recorded on, so it stays out of IsWithdrawalDecisionStr +// and its eligibility side effect (recordIneligibilityFromDecision is +// keyed on IsWithdrawalDecisionStr specifically, unaffected by this +// widening) -- but the RULING itself must survive a score correction +// exactly as a kiken/fusenpai's does, or a correction silently replaced +// a stored fusensho with an empty decision (the whole-struct overwrite +// in applyPoolWrite/applyBracketResultIn has nothing else to fall back +// on once this reports false); +// - incoming completed: a start, an autosave or a requeue never inherits; +// - incoming decision "" or "hikiwake", and nothing else: that is a score +// sheet's correction, and no score sheet can state a decision (the team +// sheet's rows say nothing about the encounter, the individual sheet's +// "" or its draw toggle say nothing about a withdrawal), so the write +// corrects the scores and leaves the ruling. Every other decision IS the +// operator ruling on the match: a withdrawal re-decides it or names the +// other side, and fusensho, daihyosen, kachinuki-exhaustion or "fought" +// replace it. An ALLOWLIST, not a denylist: a decision added later is a +// replacement until someone decides it keeps the ruling, never the +// reverse (a denylist missed fusensho and daihyosen, which POST +// /decision sends with the prior bout rows attached by preserveLoserScore: +// the kiken was kept and the default arm of recordDecisionTx still +// restored the loser, leaving a stored kiken with an eligible loser). +// +// Removing a withdrawal recorded by mistake is not a score write at all: it +// is a reopen (ReopenMatch), which puts the match back to running with its +// fought bouts kept. +// +// Exported because the team finish gate (mobileapp.refuseUnfinishedTeamFinish) +// must exempt exactly the writes this keeps the ruling for, and a second copy +// of the condition there would drift from this one. +func KeepsWithdrawalRuling(storedStatus state.MatchStatus, storedDecision string, incoming *state.MatchResult) bool { + return storedStatus == state.MatchStatusCompleted && + domain.IsDefaultWinDecisionStr(storedDecision) && + incoming.Status == state.MatchStatusCompleted && + (incoming.Decision == "" || incoming.Decision == string(domain.DecisionHikiwake)) +} + +// preserveWithdrawalRuling is the mirror of preserveLoserScore: that one +// carries the stored bout rows onto a decision write, this one carries the +// stored RULING (withdrawalRuling) onto a score sheet's correction, so a +// correction to the bouts (or, on a single-bout match, the struck letters) +// fought before a withdrawal saves them and leaves the ruling and its +// consequences exactly as recorded. Forward writes only: a +// matchWriteRestore replays a trusted snapshot and inherits nothing. +// +// It reports whether it inherited, and the caller must then skip the +// eligibility side effect (recordIneligibilityFromDecision): the result now +// carries the withdrawal again, and re-recording it would overwrite the +// competitor's status (RecordedAt, and a kiken-injury reinstatement) for a +// ruling nobody changed. Both forward-merge owners call it BEFORE anything +// reads result.Winner for this write: applyPoolWrite before identity +// backfill, applyBracketResultIn before the downstream-correction guard. +func preserveWithdrawalRuling(stored withdrawalRuling, result *state.MatchResult, policy matchWritePolicy) bool { + if policy != matchWriteForward || !KeepsWithdrawalRuling(stored.Status, stored.Decision, result) { + return false + } + result.Decision = stored.Decision + result.DecisionBy = stored.DecisionBy + result.DecisionReason = stored.DecisionReason + result.Winner = stored.Winner + result.WinnerID = stored.WinnerID + // WinnerSide is a transient per-write hint the client never sends; clear + // it so no handler-set hint can outvote the inherited WinnerID. + result.WinnerSide = "" + result.IpponsA, result.IpponsB = keptWithdrawalScoreline(stored, result) + result.Encho = nil + if stored.Encho != nil { + encho := *stored.Encho + result.Encho = &encho + } + return true +} + +// keptWithdrawalScoreline is the match-level scoreline a correction that keeps +// a withdrawal stores. The ruling's scoreline is two different things, one per +// side (recordDecisionTx): the WINNER's side is the default-win maru, which is +// the ruling itself and is kept; the WITHDRAWING side's is the letters it had +// struck (preserveLoserScore, FIK Art. 32), which are scores, so a correction +// saves what the operator entered for them (operator ruling 2026-09-24: "Save +// correction should just save what the operator enters"), passed through +// struckIppons so no maru or hantei mark the sheet echoed back can be filed +// as a strike. +// +// Two shapes keep the stored scoreline whole instead: +// - a write with bout rows (a team encounter): its match level is not a +// score sheet at all, the team editor sends [] there, and the ruling's +// match-level scoreline is only the verdict's maru; +// - a ruling with no DecisionBy (written before the side was recorded): +// nothing says which side withdrew, so there is no loser side to take. +// +// A withdrawing side the write OMITS (nil, not []) is a writer with nothing +// to say, and keeps the stored letters: the same silence rule the wire uses +// for ippons elsewhere. An explicit [] is the operator clearing them. +func keptWithdrawalScoreline(stored withdrawalRuling, result *state.MatchResult) (ipponsA, ipponsB []string) { + ipponsA = append([]string(nil), stored.IpponsA...) + ipponsB = append([]string(nil), stored.IpponsB...) + if len(result.SubResults) > 0 { + return ipponsA, ipponsB + } + switch stored.DecisionBy { + case "shiro": // Shiro (SideB) withdrew. + if result.IpponsB != nil { + ipponsB = struckIppons(result.IpponsB) + } + case "aka": // Aka (SideA) withdrew. + if result.IpponsA != nil { + ipponsA = struckIppons(result.IpponsA) + } + } + return ipponsA, ipponsB +} + // struckIppons returns the real struck ippon letters from a slice, dropping // empty entries, the "•" UI placeholder, the domain.DefaultWinIppon maru // (an awarded default win, not a struck point) and the domain.HanteiMark @@ -1269,22 +1493,62 @@ func (e *Engine) RecordMatchResult(compId string, matchId string, result *state. // ErrMatchSideMismatch return below is unreachable on the rollback path — the // snapshot replays sides captured from this same match. func (e *Engine) writeMatchResult(h state.StoreTx, compId string, matchId string, result *state.MatchResult, policy matchWritePolicy) error { + // The stored decision before the write, for the eligibility-follows-the- + // ruling restore below (forward writes only: a restore replays a trusted + // snapshot and its eligibility is the rolled-back write's caller's). + var priorDecision string + if policy == matchWriteForward { + // An unknown match is left to writeToPoolOrBracket, which answers + // it; a store that cannot be read is this write's error. + prior, perr := e.lookupExistingResult(h, compId, matchId) + var notFound *NotFoundError + switch { + case perr == nil: + priorDecision = prior.Decision + case !errors.As(perr, ¬Found): + return perr + } + } // force=false: RecordMatchResult (this function's only forward-policy // caller) offers no operator override, so a write that would trip the // downstream-knockout-correction guard is always refused here; the K3 // rollback's matchWriteRestore call is exempt from the guard regardless. - sideMismatch, _, err := e.writeToPoolOrBracket(h, compId, matchId, result, policy, false) + sideMismatch, _, inherited, err := e.writeToPoolOrBracket(h, compId, matchId, result, policy, false) if err != nil { return err } if sideMismatch { return ErrMatchSideMismatch } + // A RESTORE touches no eligibility. It replays the snapshot of a write + // that was refused (rollbackMatchResultTx: K3, or a pool requalification + // refusal), and both refusals fire before the refused write recorded + // any eligibility (recordIneligibilityFromDecision refuses K3 before its + // SetCompetitorStatus), so there is nothing of the refused write's to + // undo. Re-deriving eligibility from the snapshot is what this return + // stops: a snapshot holding a withdrawal re-barred its loser, so a + // kiken-injury competitor reinstated since was made ineligible again + // by a correction that never landed. + if policy == matchWriteRestore { + return nil + } + // A write that kept a recorded withdrawal (preserveWithdrawalRuling) + // changed no ruling, so it has no eligibility consequence to record. + if inherited { + return nil + } // Side-effect writes are non-fatal: the match score is already staged, // so propagating would cause a 500 retry that double-records the score. - if _, err := e.recordIneligibilityFromDecision(h, compId, matchId, result); err != nil { + loser, err := e.recordIneligibilityFromDecision(h, compId, matchId, result) + if err != nil { log.Printf("engine: recordIneligibilityFromDecision compId=%s matchId=%s: %v", compId, matchId, err) } + // The eligibility record follows the ruling, as in + // RecordMatchResultWithIneligibilityTx: a write that replaced a recorded + // withdrawal, with a result that is not one or with a withdrawal by the + // other side, restores whom the first one barred and keeps whom this one + // barred (loser). + e.restoreIfWithdrawalRemoved(h, compId, matchId, priorDecision, result.Decision, loser) return nil } @@ -1299,9 +1563,9 @@ func (e *Engine) writeMatchResult(h state.StoreTx, compId string, matchId string // enters by. Two things changed for this entry when its hand-copied body was // deleted, both deliberate: the whole write now commits under a single // per-comp lock acquire (previously each store call locked separately), and -// the mp-e2k1 displaced-finisher guard now applies here too — it had been -// added to the Tx twin only, which is exactly the drift this collapse exists -// to end. Side-effect write failures are still non-fatal: (nil, nil) + log. +// the pool requalification check now applies here too — its predecessor had +// been added to the Tx twin only, which is exactly the drift this collapse +// exists to end. Side-effect write failures are still non-fatal: (nil, nil) + log. // // NEVER call this from inside a transaction: it takes the per-competition lock // itself and that lock is not reentrant, so it deadlocks rather than erroring. @@ -1442,8 +1706,8 @@ func (e *Engine) sampleStandingsTokens(compId string) standingsTokens { // poolStandingsLoader is the read surface computeStandingsFrom needs. Both // *state.Store and state.StoreTx satisfy it (identical signatures), so the // single scoring core below can run either against the cached/single-flight -// store path (CalculatePoolStandings) or inside a write transaction (the -// mp-e2k1 pool-rescore guard in scoring_tx.go), with NO duplicated formula. +// store path (CalculatePoolStandings) or inside a write transaction (the pool +// requalification check in pool_requalify.go), with NO duplicated formula. type poolStandingsLoader interface { LoadCompetition(compID string) (*state.Competition, error) LoadPools(compID string) ([]helper.Pool, error) @@ -1556,7 +1820,18 @@ func (e *Engine) computeStandingsFrom(loader poolStandingsLoader, compId string) sB.Draws++ } - if isTeam && len(m.SubResults) > 0 { + if isTeam { + // A team match's tie-break columns (IV/IL/IT/PW/PL) come from + // the sub-bouts, via accrueTeamSubResults -- unconditionally, + // not gated on len(m.SubResults) > 0. Routing is by isTeam (a + // competition-level property) alone, so a padding-less default + // win match was never misrouted into the individual branch + // below; the gap padding closes is CREDIT, not routing: a + // completed default-win match (kiken before any bout was + // scored) with no SubResults rows still reaches this branch, + // but accrueTeamSubResults' loop over an empty slice is then a + // no-op, silently contributing zero IV/PW for a decided match. + // state.PadDefaultWinBoutPositions gives it rows to credit. accrueTeamSubResults(sA, sB, m) } else { // Individual scoring: ippons at match level. countScoringIppons @@ -1615,11 +1890,19 @@ func (e *Engine) computeStandingsFrom(loader poolStandingsLoader, compId string) applyTiebreakSort(sorted, matches, IsPoolDaihyosenMatchID) } + // `overrides` itself is loaded ONCE above this loop, not per pool. + var poolOverrides map[string]int + if overrides != nil { + poolOverrides = overrides.PoolRanks[p.PoolName] + } + // Detect ties before applying manual rank overrides. detectPoolTies walks // adjacent elements, so it must run while the slice is still Points-sorted. - // Overrides only change the display order; the underlying scoring tie is real - // regardless of how the operator chose to resolve it. - markTiedStandings(comp, sorted, poolResults[p.PoolName], playerStandings) + // A tie already settled is not flagged: a chusen recorded for every + // member of the group (poolOverrides), or supplementary bouts that + // ordered it completely, decided the order, so the group is no longer a + // tie to break (operator ruling 2026-09-24; see tieSettled). + markTiedStandings(comp, sorted, poolResults[p.PoolName], playerStandings, poolOverrides) // Apply manual rank overrides. Overrides are keyed by competitor // participant id ONLY (bc-cse, bc-pnum), not bare name: @@ -1627,11 +1910,6 @@ func (e *Engine) computeStandingsFrom(loader poolStandingsLoader, compId string) // pre-bc-cse overrides.json entry keyed by bare name alone is // unresolvable until the operator re-records it through the current // chusen/override-rank flow. - // `overrides` itself is loaded ONCE above this loop, not per pool. - var poolOverrides map[string]int - if overrides != nil { - poolOverrides = overrides.PoolRanks[p.PoolName] - } if len(poolOverrides) > 0 { // TWO defects fixed together (bc-idfx): // @@ -1802,7 +2080,13 @@ func applyJointThirdRanks(comp *state.Competition, sorted []state.PlayerStanding // that function's doc comment. Unused by the pools branch (nil is fine, e.g. // direct callers such as the unit tests in tied_standings_test.go, which // build a synthetic byKey from `sorted` itself via rosterIndexFrom). -func markTiedStandings(comp *state.Competition, sorted []state.PlayerStanding, matches []state.MatchResult, rosterIndex map[string]*state.PlayerStanding) { +// +// poolOverrides is the pool's recorded rank overrides (a chusen), nil when +// none. Both branches mark only the groups tieSettled leaves open: Tied means +// a tie still to be broken, which is what the amber row tells the operator, +// so a group a chusen or its supplementary bouts already ordered is not one +// even though its Points stay equal. +func markTiedStandings(comp *state.Competition, sorted []state.PlayerStanding, matches []state.MatchResult, rosterIndex map[string]*state.PlayerStanding, poolOverrides map[string]int) { if len(sorted) == 0 { return } @@ -1816,17 +2100,28 @@ func markTiedStandings(comp *state.Competition, sorted []state.PlayerStanding, m } isLeague := comp != nil && comp.Format == state.CompFormatLeague + isTeam := comp != nil && comp.TeamSize > 0 + + // The supplementary rows are read here, from the full pool list, before + // the branches see only the regular matches. + var open [][]int + for _, positions := range detectPoolTies(sorted) { + if !tieSettled(standingsAt(sorted, positions), matches, isTeam, poolOverrides) { + open = append(open, positions) + } + } if isLeague { - markTiedStandingsLeague(comp, sorted, regularMatches, rosterIndex) + markTiedStandingsLeague(comp, sorted, regularMatches, rosterIndex, open) } else { - markTiedStandingsPools(sorted, regularMatches) + markTiedStandingsPools(sorted, regularMatches, open) } } // markTiedStandingsPools marks tied rows in a pools (non-league) competition. // Rows are only marked once ALL regular matches in the pool are complete. -func markTiedStandingsPools(sorted []state.PlayerStanding, regularMatches []state.MatchResult) { +// open is the tied groups (positions into sorted) still to be broken. +func markTiedStandingsPools(sorted []state.PlayerStanding, regularMatches []state.MatchResult, open [][]int) { // Gate: there must be at least one regular match, and all must be completed. // With no matches at all the pool hasn't started, everyone is tied at 0 // points, which must NOT surface as amber. @@ -1839,8 +2134,8 @@ func markTiedStandingsPools(sorted []state.PlayerStanding, regularMatches []stat } } - // Pool is complete; mark every tied group. - for _, positions := range detectPoolTies(sorted) { + // Pool is complete; mark every tied group still open. + for _, positions := range open { for _, idx := range positions { sorted[idx].Tied = true } @@ -1879,7 +2174,7 @@ func markTiedStandingsPools(sorted []state.PlayerStanding, regularMatches []stat // (SideAID/SideBID blank) resolves to nothing here (lookupStandingsPlayer // is id-only), exactly as it contributes nothing to computeStandingsFrom's // own tally, so the two never disagree about who a match side means. -func markTiedStandingsLeague(comp *state.Competition, sorted []state.PlayerStanding, regularMatches []state.MatchResult, rosterIndex map[string]*state.PlayerStanding) { +func markTiedStandingsLeague(comp *state.Competition, sorted []state.PlayerStanding, regularMatches []state.MatchResult, rosterIndex map[string]*state.PlayerStanding, open [][]int) { topN := min(effectiveTopN(comp), len(sorted)) // statusFor is keyed by helper.CompetitorKey (id-preferred, name+dojo @@ -1931,9 +2226,10 @@ func markTiedStandingsLeague(comp *state.Competition, sorted []state.PlayerStand return } - // Trigger has fired: mark tied groups that intersect the top-N band and - // are not covered by the two-joint-3rd-places exemption. - for _, positions := range detectPoolTies(sorted) { + // Trigger has fired: mark the open tied groups (see markTiedStandings) + // that intersect the top-N band and are not covered by the + // two-joint-3rd-places exemption. + for _, positions := range open { minPos := positions[0] + 1 // 1-based g := TiedGroup{ Teams: standingsAt(sorted, positions), @@ -1961,14 +2257,20 @@ func markTiedStandingsLeague(comp *state.Competition, sorted []state.PlayerStand // step amplified the risk because it mutates ADJACENT bracket cells // (the next-round match), so a concurrent save with a stale view // could clobber another operator's propagation too. -func (e *Engine) recordBracketMatchResult(h state.StoreTx, compId string, matchId string, result *state.MatchResult, policy matchWritePolicy, force bool) ([]ReopenedMatch, error) { - var reopened []ReopenedMatch +func (e *Engine) recordBracketMatchResult(h state.StoreTx, compId string, matchId string, result *state.MatchResult, policy matchWritePolicy, force bool) ([]ReopenedMatch, bool, error) { + var ( + reopened []ReopenedMatch + inherited bool + ) err := h.UpdateBracket(compId, func(bracket *state.Bracket) error { var ierr error - reopened, ierr = e.applyBracketResultIn(bracket, compId, matchId, result, policy, force) + reopened, inherited, ierr = e.applyBracketResultIn(bracket, compId, matchId, result, policy, force) return ierr }) - return reopened, err + if err == nil { + e.restoreForceReopened(h, compId, reopened) + } + return reopened, inherited && err == nil, err } // applyMatchWrite reports whether a match write should apply under the @@ -2087,9 +2389,12 @@ func applyMatchWrite(result *state.MatchResult, storedModifiedAt int64, policy m // AMENDMENT 2 choke point. It now has a single caller, applyBracketMatchResult, // which is itself the one per-match bracket write that both the round path and // the bronze fallback share, so there is nothing left here to drift. -func validateBracketCompletion(matchID string, status state.MatchStatus, winner string) error { +func validateBracketCompletion(bm *state.BracketMatch, status state.MatchStatus, winner string) error { if status == state.MatchStatusCompleted && winner == "" { - return validationErrorf("bracket match %s: cannot mark completed with no winner; resolve the tie first (daihyosen, or encho on the final kachinuki bout)", matchID) + // MatchLabel(bracketMatchRef(bm)) is PURE (bm already carries + // Number/DisplayRound, no store read), so it is safe to call here + // even though this runs inside a live transaction (bc-cse item 14). + return validationErrorf("%s: cannot mark completed with no winner; resolve the tie first (daihyosen, or encho on the final kachinuki bout)", MatchLabel(bracketMatchRef(bm))) } return nil } @@ -2116,7 +2421,9 @@ func applyBracketMatchResult(bm *state.BracketMatch, result *state.MatchResult, // (feeder pools/matches finished). This replaces the old bracket-wide Preview // gate so the knockout fills in incrementally as pools qualify. if !bracketMatchPlayable(bm) { - return false, validationErrorf("knockout match %s is not ready to score: a feeder pool or match has not finished", bm.ID) + // PURE (bm already carries Number/DisplayRound): safe inside a live + // transaction (bc-cse item 14). + return false, validationErrorf("%s is not ready to score: a feeder pool or match has not finished", SentenceCase(MatchLabel(bracketMatchRef(bm)))) } // Match identity is fixed at seeding; a score must not rewrite the pairing. // Backfill omitted sides so deriveDaihyosenWinner can map a representative @@ -2193,7 +2500,7 @@ func applyBracketMatchResult(bm *state.BracketMatch, result *state.MatchResult, // Winner first and lean on updateBracketLocked skipping the save when the // mutate callback errors — true (state/bracket.go), but a non-local property // nothing at the write site stated. Ordering it here makes the discard local. - if err := validateBracketCompletion(bm.ID, status, result.Winner); err != nil { + if err := validateBracketCompletion(bm, status, result.Winner); err != nil { return false, err } bm.Winner = result.Winner @@ -2291,9 +2598,9 @@ func applyBracketMatchResult(bm *state.BracketMatch, result *state.MatchResult, // propagation have landed, requeues the ONE downstream match the correction // would otherwise have silently repainted; its id is returned so the caller // can broadcast match_updated for it. -func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID string, result *state.MatchResult, policy matchWritePolicy, force bool) ([]ReopenedMatch, error) { +func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID string, result *state.MatchResult, policy matchWritePolicy, force bool) ([]ReopenedMatch, bool, error) { if bracket == nil { - return nil, notFoundErrorf("bracket not found for competition %s", compID) + return nil, false, notFoundErrorf("bracket not found for competition %s", compID) } for rIdx := range bracket.Rounds { for mIdx := range bracket.Rounds[rIdx] { @@ -2301,6 +2608,13 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st if bm.ID != matchID { continue } + // A bout-row correction over a recorded withdrawal keeps the + // ruling (bc-tmfn). It must run BEFORE the downstream guard below, + // which compares result.Winner with the stored winner: the bouts' + // derived winner may name the other team, and the guard would then + // refuse a correction that moves nobody, or, forced, requeue a + // match it never changed. + inherited := preserveWithdrawalRuling(rulingOfBracketMatch(bm), result, policy) if !force { // bc-cse finding 2: a stale replayed write must be reported as // ErrMatchSuperseded -- the ordinary 200 {"applied":false} @@ -2319,7 +2633,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st stale := policy == matchWriteForward && !domain.ApplyByTimestamp(result.ModifiedAt, bm.ModifiedAt) if !stale { if err := guardDownstreamKnockoutCorrection(bracket, rIdx, mIdx, bm, result, policy); err != nil { - return nil, err + return nil, false, err } } } @@ -2329,10 +2643,10 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // cleared the next round for a write that stored the same winner -- // including one confirmed for an unrelated reason, since the // decision path's own T103 force used to arrive as this flag. - priorWinner, priorWinnerID := bm.Winner, bm.WinnerID + priorWinner, priorWinnerID := propagatedWinnerOf(bracket, rIdx, mIdx, bm) applied, err := applyBracketMatchResult(bm, result, policy) if err != nil { - return nil, err + return nil, false, err } // A nil error with applied=false is the timestamp guard's drop and // nothing else (the other two false returns carry an error). Report @@ -2341,7 +2655,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // right — nothing changed — and is the same reason OverrideBracketWinner // returns errLWWDropped from its own mutate callback. if !applied { - return nil, ErrMatchSuperseded + return nil, false, ErrMatchSuperseded } // Propagate only a genuinely completed result. A "running" update is // for live-status display, so the next round's SideA/SideB must stay @@ -2354,7 +2668,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, bm.ID) } } - return reopened, nil + return reopened, inherited, nil } } // The bronze (3rd-place) knockout lives in Bracket.ThirdPlaceMatch, NOT in @@ -2362,19 +2676,20 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // bronze: it has no downstream match, so the downstream-correction guard // does not apply here either. if bracket.ThirdPlaceMatch != nil && bracket.ThirdPlaceMatch.ID == matchID { + inherited := preserveWithdrawalRuling(rulingOfBracketMatch(bracket.ThirdPlaceMatch), result, policy) applied, err := applyBracketMatchResult(bracket.ThirdPlaceMatch, result, policy) if err != nil { - return nil, err + return nil, false, err } // Same report as the round branch. Bronze used to DISCARD `applied` // outright, so a superseded bronze write was doubly invisible: no signal // to the operator and a pointless bracket re-save. if !applied { - return nil, ErrMatchSuperseded + return nil, false, ErrMatchSuperseded } - return nil, nil + return nil, inherited, nil } - return nil, notFoundErrorf("bracket match %s not found", matchID) + return nil, false, notFoundErrorf("bracket match %s not found", matchID) } // bracketMatchCarriesOwnResult reports whether bm holds a result someone @@ -2388,10 +2703,10 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // correction). // // This is deliberately narrower than bracketMatchStartedOrScored -// (kachinuki.go), which the REOPEN path uses and which intentionally DOES -// treat a bye completion as blocking (retracting a winner whose bye already -// resolved downstream would orphan that resolution). The two predicates -// answer different questions for different rules; do not merge them. +// (kachinuki.go), which asks whether a match has been touched at all, a bye +// completion included; the retractions no longer apply it to a bye, which +// they unwind instead (propagatedDownstreamOf). The two predicates answer +// different questions for different rules; do not merge them. // // RECORDED CONTENT always blocks, whatever the status, and a RUNNING match // blocks even while empty: it is on court being fought right now, which is @@ -2451,36 +2766,6 @@ func bracketMatchCarriesOwnResult(bm *state.BracketMatch) bool { bm.IsOverridden } -// firstDownstreamWithOwnResult returns the match ONE HOP down that this -// result was propagated into and that is closed with a result of its own -- -// the next-round slot, or, for a semifinal, the bronze slot it also feeds. -// Nil when neither is closed. -// -// ONE HOP, not the whole chain (operator ruling 2026-09-19): "if a correction -// is applied then that match is completed and reopens the next one, if that -// one is also completed". The deeper rounds are not this write's business and -// are not silently unwound behind one confirmation. They are reached in their -// own turn: once the operator re-fights the reopened match and enters THAT -// result, the write propagates a round further, meets this same check against -// the round after it, and asks again. One decision per round, each one the -// operator's, instead of a single dialog quietly clearing three matches. -// -// Reuses downstreamTargets (kachinuki.go), the single owner of WHERE a winner -// propagates, so this cannot drift from propagateBracketWinner's slot rule. -func firstDownstreamWithOwnResult(bracket *state.Bracket, rIdx, mIdx int) []*state.BracketMatch { - var blocking []*state.BracketMatch - bronze, next := downstreamTargets(bracket, rIdx, mIdx) - // Bronze first: it is the slot the operator forgets, and naming it first - // keeps the order stable for the dialog and for the tests. - if bronze != nil && bracketMatchCarriesOwnResult(bronze) { - blocking = append(blocking, bronze) - } - if next != nil && bracketMatchCarriesOwnResult(next) { - blocking = append(blocking, next) - } - return blocking -} - // winnerActuallyChanged compares a bracket match's winner against what it held // before the write, by id when both sides carry one and by name otherwise // (bc-pnum: an id is authoritative when present, and the three id-less @@ -2497,8 +2782,8 @@ func winnerActuallyChanged(priorWinner, priorWinnerID string, bm *state.BracketM return priorWinner != bm.Winner } -// forceReopenDownstreamChain reopens exactly what firstDownstreamWithOwnResult -// named, and nothing else. It calls that same function rather than re-deriving +// forceReopenDownstreamChain reopens exactly what propagatedDownstream.played +// named, and nothing else. It asks that same method rather than re-deriving // the target, so the refusal and the confirmation can never disagree about // which matches are at stake. // @@ -2509,8 +2794,11 @@ func winnerActuallyChanged(priorWinner, priorWinnerID string, bm *state.BracketM // longer changes, so repeating the correction raises nothing and the second // sibling would sit contradicting itself forever (verified against the running // app: re-saving the same correction returned 200 with the final still showing -// the old winner). Rounds BEYOND the next are not unwound here; they are asked -// about in their own turn, when the re-fought result propagates into them. +// the old winner). A bye the winner was passed through is not a hop: nobody +// fought it, so the hop is the first match past it. Rounds BEYOND that are not +// reopened here. One nobody has touched only stops showing the winner the +// reopened match had sent on (retractIntoUntouched); one already played is +// asked about in its own turn, when the re-fought result propagates into it. // // reopenBracketMatch, not requeueBracketMatch: the match was already played // and stays where it is, reopened in place, so the queue is left alone @@ -2518,71 +2806,115 @@ func winnerActuallyChanged(priorWinner, priorWinnerID string, bm *state.BracketM // matches were already played"). It carries its own audit note rather than // owing one -- see downstreamReopenReason. func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correctedID string) []ReopenedMatch { + d := propagatedDownstreamOf(bracket, rIdx, mIdx) var reopened []ReopenedMatch - for _, m := range firstDownstreamWithOwnResult(bracket, rIdx, mIdx) { - reopenBracketMatch(m, downstreamReopenReason(correctedID)) - // A downstream reopen leaves the match SCHEDULED, where the kachinuki - // reopen leaves it RUNNING, and the difference is who is standing at - // the shiaijo. Kachinuki reopens the encounter the operator has this - // second, to fight on; this one reopens a match that was played - // earlier and must be fought AGAIN, with nobody on the court yet. - // - // Running is not merely inaccurate here, it is unusable: a running - // match holds its court, and a semifinal reopens BOTH the final and - // the 3rd-place match, which a draw runs on the same court. Two - // running matches there deadlocked each other -- scoring either was - // refused with court_busy naming the other, so neither could ever be - // completed and the operator had no way out of the state their own - // confirmation had created. Found by scoring a reopened bronze in the - // browser; both statuses look identical until you try to finish one. - // - // The status is not the queue: the match stays exactly where it sat - // (operator ruling 2026-09-19, "no changes in the queue necessary if - // the matches were already played"), it simply is not claimed as in - // progress until someone starts it. - m.Status = state.MatchStatusScheduled - // The BOUT LOG goes too, which is the other place this parts company - // with the kachinuki reopen. That one keeps SubResults on purpose: - // the same two teams are still fighting and every bout already fought - // is a fact about them. Here the correction REPAINTS this match's - // side, so the bouts describe a pairing that is no longer in it, and - // leaving them behind files one team's bouts under the name of the - // team that replaced them. The operator is told this is what happens - // ("its recorded result, including its bouts, is cleared" -- the - // court-operator guide, and the confirm dialog says the same), so the - // record has to match the promise. - // - // Engi flags go for the same reason: an engi bracket match carries - // its panel's flag counts, which belong to the pair that was in the - // slot. reopenBracketMatch leaves both alone because it serves - // kachinuki, where neither can be stale. - m.SubResults = nil - m.FlagsA = 0 - m.FlagsB = 0 - reopened = append(reopened, ReopenedMatch{ID: m.ID, Number: m.MatchNumber}) + for _, m := range d.played() { + reopened = append(reopened, reopenDisplacedBracketMatch(m, downstreamReopenReason(correctedID))) + // The reopened next-round match no longer has a winner, so what it + // had propagated comes back out of a round nobody has touched, the + // same retraction a pool correction's reopen applies + // (applyRequalification). The bronze match has no downstream. + if m == d.next { + retractIntoUntouched(bracket, d.feed.R+1, d.feed.M/2) + } } return reopened } +// reopenDisplacedBracketMatch reopens ONE bracket match whose side a +// correction elsewhere has just displaced, and reports it for the caller's +// broadcast and eligibility restore. It is the per-match body of both doors +// that do this: forceReopenDownstreamChain (a knockout correction repainting +// the next round) and applyRequalification (a pool correction moving who +// qualified into it), so the two cannot drift on what "reopened because its +// competitor was replaced" leaves behind. +func reopenDisplacedBracketMatch(m *state.BracketMatch, reason string) ReopenedMatch { + // Captured before reopenBracketMatch clears it, for the eligibility + // restore the caller runs once the bracket is saved + // (restoreForceReopened). It cannot run here: this runs inside an + // UpdateBracket callback, which on the bare store holds the + // non-reentrant per-competition lock a status write would take again. + priorDecision := m.Decision + // The Running placeholder here is immediately overridden to Scheduled + // below regardless of decision (this door never leaves a match + // RUNNING), so it needs none of reopenTargetStatus's fusensho-barred + // check -- that check exists only to pick RUNNING vs SCHEDULED, and this + // call site always ends up SCHEDULED either way. + reopenBracketMatch(m, reason, state.MatchStatusRunning) + // A downstream reopen leaves the match SCHEDULED, where the kachinuki + // reopen leaves it RUNNING, and the difference is who is standing at + // the shiaijo. Kachinuki reopens the encounter the operator has this + // second, to fight on; this one reopens a match that was played + // earlier and must be fought AGAIN, with nobody on the court yet. + // + // Running is not merely inaccurate here, it is unusable: a running + // match holds its court, and a semifinal reopens BOTH the final and + // the 3rd-place match, which a draw runs on the same court. Two + // running matches there deadlocked each other -- scoring either was + // refused with court_busy naming the other, so neither could ever be + // completed and the operator had no way out of the state their own + // confirmation had created. Found by scoring a reopened bronze in the + // browser; both statuses look identical until you try to finish one. + // + // The status is not the queue: the match stays exactly where it sat + // (operator ruling 2026-09-19, "no changes in the queue necessary if + // the matches were already played"), it simply is not claimed as in + // progress until someone starts it. + m.Status = state.MatchStatusScheduled + // The BOUT LOG goes too, which is the other place this parts company + // with the kachinuki reopen. That one keeps SubResults on purpose: + // the same two teams are still fighting and every bout already fought + // is a fact about them. Here the correction REPAINTS this match's + // side, so the bouts describe a pairing that is no longer in it, and + // leaving them behind files one team's bouts under the name of the + // team that replaced them. The operator is told this is what happens + // ("its recorded result, including its bouts, is cleared" -- the + // court-operator guide, and the confirm dialog says the same), so the + // record has to match the promise. + // + // Engi flags go for the same reason: an engi bracket match carries + // its panel's flag counts, which belong to the pair that was in the + // slot. reopenBracketMatch leaves both alone because it serves + // kachinuki, where neither can be stale. + m.SubResults = nil + m.FlagsA = 0 + m.FlagsB = 0 + ref := bracketMatchRef(m) + ref.PriorDecision = priorDecision + return ref +} + +// restoreForceReopened is the eligibility half of forceReopenDownstreamChain. +// A downstream match that a withdrawal decided and that a confirmed reopen or +// correction has just reopened no longer carries that withdrawal, so the +// competitor it barred is restored (restoreIfWithdrawalRemoved, the one +// statement of that rule) and the restored status is recorded on the +// ReopenedMatch for the handler to broadcast. Without it the reopened match +// kept its withdrawer ineligible, so it could never be started again. +// +// Every caller of forceReopenDownstreamChain runs this AFTER its bracket write +// has returned, never inside the UpdateBracket callback (see the capture in +// forceReopenDownstreamChain), with the same handle it wrote through. +func (e *Engine) restoreForceReopened(h state.StoreTx, compID string, reopened []ReopenedMatch) { + for i := range reopened { + reopened[i].Restored = e.restoreIfWithdrawalRemoved(h, compID, reopened[i].ID, reopened[i].PriorDecision, "", nil) + } +} + // downstreamReopenReason is the audit note a downstream match carries when a // correction upstream reopened it. It describes THIS match's own event -- it // was reopened, and why -- rather than repeating the operator's note about the // match they corrected, which would read as someone else's history on this // match's trail. // -// Supplying a reason at all is what keeps the reopen payable. reopenPending("") -// treats an empty reason as "still owes an audit justification", and only the -// TEAM editor has a prompt to collect one, so an individual match reopened with -// no reason could not be completed at all: the write came back 400 "this match -// was reopened; ending it again requires a reason". The system knows why this -// one was reopened, so it says so itself instead of billing the operator for an -// explanation of something it did to them. +// The system knows why this one was reopened, so it records that itself rather +// than leaving the match's trail without an explanation of something it did. func downstreamReopenReason(correctedID string) string { return fmt.Sprintf("reopened: the result of match %s was corrected", correctedID) } // requeueBracketMatch normalises a bracket match to a CLEAN SCHEDULED match: -// no verdict, no scoreline, no provenance, no audit debt. It is +// no verdict, no scoreline, no provenance, no reopen flag. It is // RevertMatchToQueue's bracket branch, extracted so the body has a name and a // doc rather than sitting inline in a closure. // @@ -2622,7 +2954,8 @@ func requeueBracketMatch(m *state.BracketMatch) { } // bracketWinnerChanged reports whether result's resolved winner differs from -// bm's CURRENTLY stored winner (i.e. before this write mutates bm). It +// the winner bm currently stands behind (priorName/priorID, read before this +// write mutates bm). It // backfills result.SideA/SideB and result.SideAID/SideBID from bm and // resolves result.WinnerID via resolveWinnerIDFromSides first -- the same // backfill+resolve applyBracketMatchResult performs later, called here early @@ -2645,13 +2978,15 @@ func requeueBracketMatch(m *state.BracketMatch) { // backfill side effect, run under the exact same stored pairing // applyBracketMatchResult itself is about to write against. // -// Comparison is BY ID when bm.WinnerID is already stamped (a resolved -// competitor, bc-brid); it falls back to bare-name comparison only for the +// The prior winner is the caller's propagatedWinnerOf, not bm's bare stored +// winner, so re-entering a reopened match is judged against who it still +// feeds downstream. Comparison is BY ID when that prior carries one (a +// resolved competitor, bc-brid); it falls back to bare-name comparison only for the // three legitimate id-less BracketMatch shapes documented on // BracketMatch.SideAID (a bye, an unresolved "Winner of ..." feeder, or an // unrepaired legacy row) -- see CLAUDE.md's bc-pnum id-only-when-present // ruling. -func bracketWinnerChanged(bm *state.BracketMatch, result *state.MatchResult, policy matchWritePolicy) (bool, error) { +func bracketWinnerChanged(bm *state.BracketMatch, priorName, priorID string, result *state.MatchResult, policy matchWritePolicy) (bool, error) { reconcileSides(result, storedSides{A: bm.SideA, B: bm.SideB, AID: bm.SideAID, BID: bm.SideBID}) if result.SideAID == "" { result.SideAID = bm.SideAID @@ -2672,10 +3007,41 @@ func bracketWinnerChanged(bm *state.BracketMatch, result *state.MatchResult, pol if err := resolveWinnerIDFromSides(result, policy); err != nil { return false, err } - if bm.WinnerID != "" { - return result.WinnerID != bm.WinnerID, nil + if priorID != "" { + return result.WinnerID != priorID, nil + } + return result.Winner != priorName, nil +} + +// propagatedWinnerOf is the winner a bracket match's result currently stands +// behind DOWNSTREAM: its own stored winner, or, when a reopen cleared that +// (reopenBracketMatch), the competitor still sitting in the next-round slot it +// fed. The three knockout-correction doors (score, override, engi) compare a +// new winner against this, never against the bare stored winner. +// +// A reopened match keeps its old winner in the next round whenever that round +// had already been played (the reopen reaches one hop and no further), so +// comparing against the cleared "" read every re-entry as a winner change: +// fighting the match again and entering the SAME winner still warned that the +// next round would be reopened, although nobody in it moves. A match that has +// never been completed feeds nothing yet (its slot holds a "Winner of ..." +// placeholder), so it still compares against "" exactly as before. +func propagatedWinnerOf(bracket *state.Bracket, rIdx, mIdx int, bm *state.BracketMatch) (name, id string) { + if bm.Winner != "" || bm.WinnerID != "" { + return bm.Winner, bm.WinnerID + } + _, next := downstreamTargets(bracket, rIdx, mIdx) + if next == nil { + return "", "" + } + slot, slotID := next.SideB, next.SideBID + if mIdx%2 == 0 { + slot, slotID = next.SideA, next.SideAID + } + if isUnresolvedBracketSide(slot) { + return "", "" } - return result.Winner != bm.Winner, nil + return slot, slotID } // newDownstreamKnockoutPlayedError builds the refusal for every blocking match @@ -2696,10 +3062,11 @@ func bracketWinnerChanged(bm *state.BracketMatch, result *state.MatchResult, pol func newDownstreamKnockoutPlayedError(bm *state.BracketMatch, blocking []*state.BracketMatch, mIdx int) *DownstreamKnockoutPlayedError { blocked := make([]ReopenedMatch, 0, len(blocking)) for _, b := range blocking { - blocked = append(blocked, ReopenedMatch{ID: b.ID, Number: b.MatchNumber}) + blocked = append(blocked, bracketMatchRef(b)) } return &DownstreamKnockoutPlayedError{ MatchID: bm.ID, + Label: MatchLabel(bracketMatchRef(bm)), BlockingMatchID: blocked[0].ID, Blocking: blocked, Displaced: displacedCompetitor(bm, blocking[0], mIdx), @@ -2768,18 +3135,20 @@ func guardDownstreamKnockoutCorrection(bracket *state.Bracket, rIdx, mIdx int, b if policy != matchWriteForward || effectiveBracketWriteStatus(result) != state.MatchStatusCompleted { return nil } - changed, err := bracketWinnerChanged(bm, result, policy) + priorName, priorID := propagatedWinnerOf(bracket, rIdx, mIdx, bm) + changed, err := bracketWinnerChanged(bm, priorName, priorID, result, policy) if err != nil { return err } if !changed { return nil } - blocking := firstDownstreamWithOwnResult(bracket, rIdx, mIdx) + d := propagatedDownstreamOf(bracket, rIdx, mIdx) + blocking := d.played() if len(blocking) == 0 { return nil } - return newDownstreamKnockoutPlayedError(bm, blocking, mIdx) + return newDownstreamKnockoutPlayedError(bm, blocking, d.displacedSlot(blocking, mIdx)) } func (e *Engine) propagateBracketWinner(bracket *state.Bracket, rIdx, mIdx int) { @@ -2956,20 +3325,22 @@ func deriveOverrideWinnerID(m *state.BracketMatch, winnerName string) string { // bracketWinnerChanged/resolveWinnerIDFromSides). func guardOverrideDownstreamKnockoutCorrection(bracket *state.Bracket, rIdx, mIdx int, m *state.BracketMatch, winnerName string) error { newWinnerID := deriveOverrideWinnerID(m, winnerName) + priorName, priorID := propagatedWinnerOf(bracket, rIdx, mIdx, m) var changed bool - if m.WinnerID != "" { - changed = newWinnerID != m.WinnerID + if priorID != "" { + changed = newWinnerID != priorID } else { - changed = winnerName != m.Winner + changed = winnerName != priorName } if !changed { return nil } - blocking := firstDownstreamWithOwnResult(bracket, rIdx, mIdx) + d := propagatedDownstreamOf(bracket, rIdx, mIdx) + blocking := d.played() if len(blocking) == 0 { return nil } - return newDownstreamKnockoutPlayedError(m, blocking, mIdx) + return newDownstreamKnockoutPlayedError(m, blocking, d.displacedSlot(blocking, mIdx)) } // OverrideBracketWinner atomically loads the bracket, locates the @@ -3008,7 +3379,10 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName m := &bracket.Rounds[rIdx][mIdx] if m.ID == matchId { if !bracketMatchPlayable(m) { - return validationErrorf("knockout match %s is not ready to override: a feeder pool or match has not finished", matchId) + // PURE (m already carries Number/DisplayRound): safe + // inside UpdateBracket's mutate callback (bc-cse item + // 14), which holds the per-comp lock. + return validationErrorf("%s is not ready to override: a feeder pool or match has not finished", SentenceCase(MatchLabel(bracketMatchRef(m)))) } // Timestamp last-write-wins (mp-y3nk): a reconnecting offline // feeder assertion older than a newer stored result is dropped. @@ -3025,16 +3399,14 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName // reason the score door captures its own: a forced override // that names the winner already recorded must not requeue // the next round. - priorWinner, priorWinnerID := m.Winner, m.WinnerID + priorWinner, priorWinnerID := propagatedWinnerOf(bracket, rIdx, mIdx, m) setBracketOverrideWinner(m, winnerName) m.IsOverridden = true m.Status = state.MatchStatusCompleted - // An override is itself the operator's audited, final decision, - // so it discharges any outstanding reopen debt: a reopened match - // closed out this way must not keep ReopenPending set (it bypasses - // applyCorrectionReasonUnderTx/dischargeReopenPendingUnderTx), or - // the flag lingers until some later score write is forced to - // invent a reason (mp-gmcg review). + // An override ends the match, so a reopened match closed out + // this way must not keep ReopenPending set: it bypasses + // applyCorrectionReasonUnderTx/dischargeReopenPendingUnderTx, + // which clear it on every other way of ending a match. m.ReopenPending = false if modifiedAt != 0 { m.ModifiedAt = modifiedAt @@ -3056,7 +3428,8 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName if bracket.ThirdPlaceMatch != nil && bracket.ThirdPlaceMatch.ID == matchId { bm := bracket.ThirdPlaceMatch if !bracketMatchPlayable(bm) { - return validationErrorf("knockout match %s is not ready to override: a feeder pool or match has not finished", matchId) + // PURE, same reasoning as the round branch above. + return validationErrorf("%s is not ready to override: a feeder pool or match has not finished", SentenceCase(MatchLabel(bracketMatchRef(bm)))) } // Same errLWWDropped mechanism as above. if !domain.ApplyByTimestamp(modifiedAt, bm.ModifiedAt) { @@ -3080,6 +3453,7 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName if err != nil { return false, err } + e.restoreForceReopened(e.store, compId, reopened) if fo.Reopened != nil { *fo.Reopened = reopened } @@ -3120,7 +3494,7 @@ func (e *Engine) UpdateMatchTime(compId string, matchId string, scheduledAt stri // using the same atomic withPoolMatch/withBracketMatch primitives so the // entire load+mutate+save runs under the per-competition lock. // -// COMPOSED UNDER THE COURT LOCK: RequeueBlockerAndReopenKachinuki calls this +// COMPOSED UNDER THE COURT LOCK: RequeueBlockerAndReopen calls this // from INSIDE store.WithCourtExclusivityLock so the blocker requeue and the // reopen share one lock section (mp-gmcg review A4/R3). This method must // therefore take ONLY the per-competition lock (via withPoolMatch/ @@ -3129,7 +3503,7 @@ func (e *Engine) UpdateMatchTime(compId string, matchId string, scheduledAt stri // would deadlock the whole tournament under that composition. It is a // court-FREEING operation and needs no court gate; if that ever changes, add a // lock-free `revertMatchToQueueUnderCourtLock` core and call THAT from the -// composition, mirroring reopenKachinukiUnderCourtLock. +// composition, mirroring reopenUnderCourtLock. func (e *Engine) RevertMatchToQueue(compId, matchId string) error { var alreadyCompleted bool @@ -3161,19 +3535,24 @@ func (e *Engine) RevertMatchToQueue(compId, matchId string) error { r.FlagsB = 0 r.ResultSource = "" r.CorrectionReason = "" - // ReopenPending is a match-level verdict field a kachinuki result can - // carry (reopenPoolMatch sets it), so requeue must clear it too: a - // reopened-then-requeued match that kept the flag would keep owing an - // audit reason for a result that no longer exists, and - // applyCorrectionReasonUnderTx would reject its first honest - // finalization demanding one (mp-gmcg review). reopenBracketMatch's doc - // names this exact mirror obligation on RevertMatchToQueue. + // ReopenPending is a match-level field a reopened result carries + // (reopenPoolMatch sets it), so requeue clears it too: the result the + // reopen discarded no longer matters to a match sent back to the + // queue. reopenBracketMatch's doc names this mirror obligation on + // RevertMatchToQueue. r.ReopenPending = false // Rep-bout nominations name who fought a pool/league daihyosen; they are // result data for that supplementary bout, so a requeued match must not // keep them (bracket matches have no rep fields). r.RepPlayerA = "" r.RepPlayerB = "" + // Revert fence, the same one the bracket branch sets + // (requeueBracketMatch, mp-y3nk): a write stamped before the requeue, + // such as an offline-queued score replayed afterwards, loses the + // timestamp comparison instead of resurrecting the result the operator + // just sent back to the queue. Left unstamped, the requeued row kept + // whatever stamp it had, which the stale write could beat. + r.ModifiedAt = time.Now().UnixMilli() return nil }) if err == nil { diff --git a/internal/engine/scoring_test.go b/internal/engine/scoring_test.go index f0f6d90d1..04ea9c42d 100644 --- a/internal/engine/scoring_test.go +++ b/internal/engine/scoring_test.go @@ -239,7 +239,7 @@ func TestTeamStandings_EmptySubSidesDrawNotFalseWin(t *testing.T) { SubResults: []state.SubMatchResult{ {Position: 1, Winner: "TeamA"}, {Position: 2, Winner: "TeamB"}, - {Position: 3, Winner: ""}, + {Position: 3, Winner: "", Decision: state.DecisionDraw}, }, }, } @@ -2633,7 +2633,9 @@ func TestAccrueTeamSubResults_SameNameBout(t *testing.T) { assert.Equal(t, 0, sA.IndividualWins) assert.Equal(t, 1, sA.IndividualLosses) - line := state.TeamResultFrom([]state.SubMatchResult{sub}, "Tora", "Kaze") + // domain.MatchSideNone: this bout already carries its own result, so no + // default-win ruling could ever apply here regardless of credit. + line := state.TeamResultFrom([]state.SubMatchResult{sub}, "Tora", "Kaze", domain.MatchSideNone) require.NotNil(t, line) assert.Equal(t, sB.IndividualWins, line.ShiroIV, "standings and summary must not disagree") assert.Equal(t, sA.IndividualWins, line.AkaIV) @@ -2649,7 +2651,9 @@ func TestAccrueTeamSubResults_SameNameBout(t *testing.T) { assert.Equal(t, 0, sB.IndividualWins) assert.Equal(t, 0, sA.IndividualDraws, "a bout with a winner is not a draw just because nobody could be credited") - line := state.TeamResultFrom([]state.SubMatchResult{sub}, "Tora", "Kaze") + // domain.MatchSideNone: this bout already carries its own result, so no + // default-win ruling could ever apply here regardless of credit. + line := state.TeamResultFrom([]state.SubMatchResult{sub}, "Tora", "Kaze", domain.MatchSideNone) require.NotNil(t, line) assert.Equal(t, 0, line.ShiroIV) assert.Equal(t, 0, line.AkaIV) @@ -2662,8 +2666,30 @@ func TestAccrueTeamSubResults_SameNameBout(t *testing.T) { assert.Equal(t, 1, sB.IndividualWins) assert.Equal(t, 1, sA.IndividualLosses) - dA, dB := accrue(state.SubMatchResult{Position: 1, SideA: "Sato", SideB: "Ito"}) + dA, dB := accrue(state.SubMatchResult{Position: 1, SideA: "Sato", SideB: "Ito", Decision: state.DecisionDraw}) assert.Equal(t, 1, dA.IndividualDraws) assert.Equal(t, 1, dB.IndividualDraws) + + // A scored tie (1-1 at time) is a draw without a decision. + tA, tB := accrue(state.SubMatchResult{Position: 1, SideA: "Sato", SideB: "Ito", IpponsA: []string{"M"}, IpponsB: []string{"K"}}) + assert.Equal(t, 1, tA.IndividualDraws) + assert.Equal(t, 1, tB.IndividualDraws) + }) + + // A row with no result was never fought, so it is not a draw either: an + // unfought bout kept on a match a withdrawal decided, or a placeholder row + // of unfilled slots. Counting it as IT for both teams inflated the team + // tie-break for bouts nobody fought. + t.Run("a bout with no result is not a draw", func(t *testing.T) { + for name, sub := range map[string]state.SubMatchResult{ + "empty row": {Position: 2}, + "named, unfought": {Position: 2, SideA: "Sato", SideB: "Ito"}, + "placeholder slots": {Position: 2, IpponsA: []string{"•"}, IpponsB: []string{""}}, + } { + uA, uB := accrue(sub) + assert.Zero(t, uA.IndividualDraws, name) + assert.Zero(t, uB.IndividualDraws, name) + assert.Zero(t, uA.IndividualWins+uA.IndividualLosses+uB.IndividualWins+uB.IndividualLosses, name) + } }) } diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go index 7c76d0235..30be8af9a 100644 --- a/internal/engine/scoring_tx.go +++ b/internal/engine/scoring_tx.go @@ -59,23 +59,6 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -// topNFinisher pairs a top-N finisher's IDENTITY key (the participant id, -// id-only per operator ruling bc-pnum) with their bare display name. The -// mp-e2k1 displaced-qualifier guard below needs both: membership in the -// pre/post top-N sets must be decided by identity (two competitors sharing a -// display name from different dojos are explicitly legal, -// CheckDuplicateEntriesByNameDojo, so a bare-name set would not notice a -// re-score swapping WHICH namesake holds a qualifying rank), while -// hasStartedKnockoutMatchTx's bracket lookup still matches by name only (a -// bracket match can carry a per-side id since bc-brid, but this mp-e2k1 -// guard was not converted -- a separate, lower-priority gap that bead -// recorded rather than closed), so the reported Finisher must still be a -// name. -type topNFinisher struct { - key string - name string -} - // RecordMatchResultWithIneligibilityTx is the tx-aware twin of // RecordMatchResultWithIneligibility. The K3/CHK047 partial-write // rollback path replays the prior result via the same tx so the @@ -94,10 +77,12 @@ type topNFinisher struct { // by the K3 AlreadyIneligibleError path below only restores the corrected // match itself (rollbackMatchResultTx replays `prior` through this same // match id) -- the downstream matches forceReopenDownstreamChain reopened -// stay reopened. Reaching this requires force=true on a decision write whose -// loser turns out to already be ineligible from a different match, a narrow -// intersection not covered by this bead's test list; recorded here rather -// than silently left undiscoverable. +// stay reopened. The same holds for the knockout matches a forced POOL +// correction reopens (requalifyAfterPoolWrite): the rollback restores the +// pool match, not the bracket. Reaching either requires force=true on a +// decision write whose loser turns out to already be ineligible from a +// different match, a narrow intersection not covered by this bead's test +// list; recorded here rather than silently left undiscoverable. func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, matchID string, result *state.MatchResult, opts ...ForceOptions) (*domain.CompetitorStatus, error) { fo := firstForceOptions(opts) result.ID = matchID @@ -129,6 +114,19 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, // stamping winners -- caught by TestWinnerIDInvariant_EveryWritePathStampsASideID). engiStartWrite := result.FlagsA == 0 && result.FlagsB == 0 && result.Status != state.MatchStatusCompleted if comp != nil && comp.Engi && !engiStartWrite { + // A pool write in a mixed engi competition answers for the knockout + // its pool feeds exactly as a kendo one does (below): prior is read + // first, so a refusal can put the pool row back. Only a pool write in + // a mixed competition needs it, so no other engi write pays the read. + // A prior it cannot read is the write's error, never a nil prior: a + // nil prior leaves the refusal below nothing to roll back to. + var engiPrior *state.MatchResult + if comp.Format == state.CompFormatMixed && IsPoolMatchID(matchID) { + var lerr error + if engiPrior, lerr = e.lookupExistingResult(tx, compID, matchID); lerr != nil { + return nil, lerr + } + } // fo carries bc-kcdg's downstream-correction confirmation through the // engi seam. Without it an engi knockout correction could neither be // refused nor confirmed: the guard lives past this early return. @@ -137,6 +135,9 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, return nil, recErr } backfillEngiResult(result, rec) + if err := e.requalifyMixedPoolWrite(tx, compID, comp, matchID, rec, engiPrior, fo); err != nil { + return nil, err + } return nil, nil } @@ -148,8 +149,14 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, // Capture the prior result so we can roll back the score on // AlreadyIneligibleError. lookupExistingResult reads directly from // the tx so it sees the state INSIDE the lock (the on-disk state - // hasn't moved under us, we hold the lock). - prior, _ := e.lookupExistingResult(tx, compID, matchID) + // hasn't moved under us, we hold the lock). A prior it cannot read is + // the write's error: every guard below (the kachinuki merge, K3, the + // rollback, the pool requalification refusal) reads a nil prior as + // "nothing stored", which would let the write through unguarded. + prior, err := e.lookupExistingResult(tx, compID, matchID) + if err != nil { + return nil, err + } // Kachinuki bout logs merge BY POSITION rather than replace wholesale // (ACID: a client whose local log is behind the server must never @@ -163,49 +170,81 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, return nil, merr } - // mp-e2k1: For mixed competitions, capture the pre-write standings for - // the match's pool so we can compare after the write and detect whether - // any qualifying finisher would be displaced from a started knockout match. - // We only need this for re-scores (prior != nil) in mixed comps. - // NOTE: the engi early-return above ensures this block only runs for - // non-engi competitions, so compIsEngi is always false here and is not - // tracked as a variable. - var ( - poolRescoredName string // pool this match belongs to (empty = not a pool match) - oldTopN []topNFinisher // qualifying finishers BEFORE the write, keyed by identity - poolWinners int // EffectivePoolWinners, captured so the post-write block needn't reload the comp - ) - if prior != nil { - // mp-e2k1: reuse the comp already loaded (and error-checked) at the - // engi-dispatch above rather than re-reading config.md from disk — the - // tx sees no pending config write, so a reload would just re-parse the - // same bytes. The load error is already returned there, so this path is - // still fail-closed; a nil comp skips the mixed guard as before. - if comp != nil && comp.Format == state.CompFormatMixed { - // Only actual pool matches ("Pool X-…") can change pool finishers. - // Gate on IsPoolMatchID so a knockout re-score ("m-rN-i"), whose ID - // would otherwise parse as a pool via poolNameFromMatchID's trailing - // "-" rule, skips the standings pre-read entirely. - if pn, ok := poolNameFromMatchID(matchID); ok && IsPoolMatchID(matchID) { - poolRescoredName = pn - poolWinners = comp.EffectivePoolWinners() - // Fail closed: if we can't establish the pre-write finishers we - // can't prove the re-score is safe, so abort before writing - // anything (nothing is staged yet, so returning aborts cleanly). - preStandings, sErr := e.computeStandingsFrom(tx, compID) - if sErr != nil { - return nil, fmt.Errorf("mp-e2k1: pre-write standings for %s pool %q: %w", compID, pn, sErr) - } - ps := preStandings[pn] - for i := 0; i < poolWinners && i < len(ps); i++ { - p := ps[i].Player - oldTopN = append(oldTopN, topNFinisher{key: p.ID, name: p.Name}) - } - } + // Default-win bout padding (bc-tmfn follow-up): a non-kachinuki team + // match a default-win ruling (any kiken, fusenpai, or fusensho: + // domain.IsDefaultWinDecisionStr) closes before every numbered bout has + // a result of its own -- most commonly a kiken declared before the + // match's first bout was ever scored -- gets an empty SubMatchResult row + // (Position only) for every missing position 1..TeamSize, via + // state.NeedsDefaultWinBoutPadding / state.PadDefaultWinBoutPositions, the + // SAME predicate and function the legacy-load repair uses + // (state.EnsureLegacyUpgraded). Without a row for every position, + // state.DefaultWinCreditSide's readers (TeamResultFrom, + // accrueTeamSubResults, the Excel export) have nothing to range over for + // the positions nobody fought and silently credit them zero. + // + // Gated on PRIOR's sides and unreadable flag, never result's: a client + // score payload commonly omits SideA/SideB and relies on reconcileSides + // (inside applyPoolWrite/applyBracketResultIn, called later via + // writeToPoolOrBracket) to backfill them from the stored row -- reading + // result.SideA/SideB here would skip padding on exactly that common case + // (a bare kiken payload). Likewise a stored SubResults cell that failed + // to parse must never be padded over: NeedsDefaultWinBoutPadding's + // subResultsUnreadable check keeps this branch from turning an empty + // result.SubResults into a non-empty padded one, which would defeat + // applyPoolWrite's own raw-bytes preservation (it only keeps + // SubResultsRaw when the incoming SubResults is still empty) and destroy + // the only copy of the corrupt cell. prior is guaranteed non-nil here: + // the lookupExistingResult error above already returned on a miss. + // + // The decision tested is result's own, OR -- covering a CORRECTION that + // KEEPS a stored withdrawal ruling -- prior's: such a write's own + // Decision is either empty (a score sheet states no decision) or an + // explicit "hikiwake" (KeepsWithdrawalRuling's own two accepted shapes), + // and applyPoolWrite/applyBracketResultIn only reinstate the stored + // decision AFTER this point (preserveWithdrawalRuling), so testing + // result.Decision alone would miss it here -- and nothing downstream + // pads again once this function returns. A correction can legitimately + // send fewer SubResults rows than TeamSize (e.g. a bout-level edit that + // only touches the rows it corrects), so this is the one place that + // still needs to catch it up. + // + // Excluded: a bye (either side empty -- a bye never carries a + // default-win decision in practice, but the padding shape assumes two + // real teams either way) and a pool daihyosen/tiebreaker row + // (IsPoolDaihyosenMatchID / IsTiebreakerMatchID), which is scored as a + // single individual representative bout, never the team's own numbered + // positions. Both checked inside NeedsDefaultWinBoutPadding. + if comp != nil && comp.TeamSize >= 2 && !comp.IsKachinuki() { + // KeepsWithdrawalRuling is checked UNCONDITIONALLY, not only when + // result.Decision == "": it is also true for an explicit incoming + // "hikiwake" over a stored default win (its own definition allows + // incoming.Decision to be "" OR "hikiwake"), and that write must + // still pad, since preserveWithdrawalRuling reinstates prior.Decision + // downstream regardless of which of the two the correction sent. + padDecision := result.Decision + if KeepsWithdrawalRuling(prior.Status, prior.Decision, result) { + padDecision = prior.Decision + } + if state.NeedsDefaultWinBoutPadding(result.Status, padDecision, prior.SideA, prior.SideB, matchID, + result.SubResults, prior.SubResultsUnreadable, comp.TeamSize) { + result.SubResults = state.PadDefaultWinBoutPositions(result.SubResults, comp.TeamSize) } } - sideMismatch, reopened, err := e.writeToPoolOrBracket(tx, compID, matchID, result, matchWriteForward, fo.Force) + // K3 ahead of the write: a withdrawal whose loser a DIFFERENT match has + // already made ineligible is refused before anything is written. The + // post-write check (recordIneligibilityFromDecision, below) still refuses + // it and rolls the match back, but that rollback restores the match row + // ONLY: what the write set off in the same transaction (a mixed + // competition's requalification reopening and repainting the knockout + // matches the old qualifier fought, and restoring the eligibility their + // verdicts recorded) stayed staged and committed with the refusal. + if err := e.refuseConcurrentWithdrawal(tx, compID, matchID, result, prior); err != nil { + return nil, err + } + + sideMismatch, reopened, inheritedRuling, err := e.writeToPoolOrBracket(tx, compID, matchID, result, matchWriteForward, fo.Force) if err != nil { return nil, err } @@ -219,74 +258,27 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, return nil, ErrMatchSideMismatch } - // mp-e2k1 guard: after the pool-match write, check whether any - // qualifying finisher changed. If a displaced finisher already appears - // in a started/completed bracket match, reject the re-score. - if err == nil && poolRescoredName != "" && len(oldTopN) > 0 { - // Fail closed on any verification-read failure past this point: the - // forward write is already staged, so we restore prior before returning - // the error, never silently commit a re-score we couldn't prove safe. - postStandings, sErr := e.computeStandingsFrom(tx, compID) - if sErr != nil { - e.rollbackMatchResultTx(tx, compID, matchID, prior) - return nil, fmt.Errorf("mp-e2k1: post-write standings for %s pool %q: %w", compID, poolRescoredName, sErr) - } - ps := postStandings[poolRescoredName] - // Build the new top-N set and find displaced finishers, keyed by - // IDENTITY (the participant id, id-only per operator ruling bc-pnum) - // rather than bare name. Two competitors sharing a display name from - // different dojos are explicitly legal (CheckDuplicateEntriesByNameDojo), - // so a re-score that swaps WHICH namesake holds a qualifying rank - // changes the identity at that rank without changing the bare name - // occupying it; a name-keyed newSet would see the same string still - // present and silently miss the swap (mp-e2k1's guard exists - // precisely to catch a qualifying finisher changing under a started - // knockout match). poolWinners was captured pre-write, the - // competition record can't change within this tx, so no reload is - // needed. - newSet := make(map[string]struct{}, poolWinners) - for i := 0; i < poolWinners && i < len(ps); i++ { - p := ps[i].Player - newSet[p.ID] = struct{}{} - } - // displaced carries bare NAMES (not keys): hasStartedKnockoutMatchTx - // below still matches bracket sides by name only (a bracket match CAN - // carry a per-side id since bc-brid, but this mp-e2k1 guard was not - // converted -- see topNFinisher's doc comment above, a separate, - // lower-priority gap that bead recorded rather than closed), so a - // namesake swap deliberately produces an over-broad name-based lookup - // that can match EITHER dojo's occupant of that bracket slot. That is - // intentional: the guard fails CLOSED on a namesake collision rather - // than silently letting an identity swap through. - var displaced []string - for _, f := range oldTopN { - if _, stillIn := newSet[f.key]; !stillIn { - displaced = append(displaced, f.name) - } - } - if len(displaced) > 0 { - blockingFinisher, knockoutMatchID, hErr := e.hasStartedKnockoutMatchTx(tx, compID, displaced) - if hErr != nil { - e.rollbackMatchResultTx(tx, compID, matchID, prior) - return nil, fmt.Errorf("mp-e2k1: checking started knockout matches for %s: %w", compID, hErr) - } - if blockingFinisher != "" { - // Reject: restore the prior result so the corrupting re-score - // never lands. Within a tx, writes are in-memory WAL intents - // coalesced last-write-wins, so this rollback supersedes the - // forward write before Commit applies the final state. Report the - // finisher actually sitting in the blocking match so Finisher and - // MatchID stay consistent (matters when poolWinners > 1). - e.rollbackMatchResultTx(tx, compID, matchID, prior) - return nil, &DownstreamKnockoutScoredError{ - Pool: poolRescoredName, - Finisher: blockingFinisher, - MatchID: knockoutMatchID, - } - } - } + // A pool write in a mixed competition answers for what it does to the + // knockout its pool feeds (pool_requalify.go): a place whose occupant + // moves is repainted, a knockout match the old qualifier already fought is + // named for the operator to confirm (force reopens it), and a match being + // fought now refuses the write. Any refusal restores prior first. Runs for + // every pool write, a first completion included: it only ever acts on a + // slot that is already occupied, and ignores a pool that is not complete. + if err := e.requalifyMixedPoolWrite(tx, compID, comp, matchID, result, prior, fo); err != nil { + return nil, err } + // A bout-row correction that kept a recorded withdrawal + // (preserveWithdrawalRuling) changed no ruling, so it has no eligibility + // consequence: re-recording the withdrawal would rewrite the competitor's + // status (its RecordedAt, and undo a kiken-injury reinstatement) and + // broadcast a change nobody made. Deliberately keyed on the helper's own + // report, never on comparing result with prior: an unchanged + // (decision, loser) test would also swallow a genuine re-decision. + if inheritedRuling { + return nil, nil + } status, err := e.recordIneligibilityFromDecision(tx, compID, matchID, result) if err != nil { var alreadyErr *AlreadyIneligibleError @@ -303,13 +295,94 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, log.Printf("engine: recordIneligibilityFromDecision compId=%s matchId=%s: %v", compID, matchID, err) return nil, nil } + // The eligibility record follows the ruling (operator ruling 2026-09-24: + // "Everything should be able to be fixed, in case of a wrong entry"). + // This write landed (a superseded, mismatched or rolled-back write + // returned above) and REPLACED a recorded withdrawal (a kept ruling + // returned above, via inheritedRuling): with a result that is not one + // (fusensho or daihyosen through recordDecisionTx, or any decision + // KeepsWithdrawalRuling does not keep the ruling for), or with a + // withdrawal by the OTHER side. The withdrawal it replaced never + // happened, so the competitor it barred is restored, keeping the one + // this write barred (status), on every door alike: /score, bulk-score + // and /decision. The restored status takes priority in the return, so + // the handler broadcasts competitor_status_updated for it: the new + // withdrawer's status is already carried by the match's own decision. + // result is the post-merge value, i.e. what is now stored. + if prior != nil { + if restored := e.restoreIfWithdrawalRemoved(tx, compID, matchID, prior.Decision, result.Decision, status); restored != nil { + status = restored + } + } return status, nil } +// refuseConcurrentWithdrawal is K3's pre-write half: for a withdrawal decision +// it names the loser the write would record and refuses with +// AlreadyIneligibleError when a different match has already made them +// ineligible (checkConcurrentIneligibility, the check recordDecisionTx makes +// before its own write). The loser is read off a scratch copy with the stored +// identity folded in by backfillMatchIdentity, the same fold the write +// applies, so it is the loser the post-write check would name. A payload that +// fold rejects, or whose losing side cannot be attributed, is left to the +// write and the post-write check, which answer it as before. +func (e *Engine) refuseConcurrentWithdrawal(tx state.StoreTx, compID, matchID string, result, prior *state.MatchResult) error { + if prior == nil || !domain.IsWithdrawalDecisionStr(result.Decision) { + return nil + } + probe := *result + if err := backfillMatchIdentity(&probe, prior, matchWriteForward); err != nil { + return nil + } + loserID, loserName, ok := losingSide(&probe) + if !ok { + return nil + } + return e.checkConcurrentIneligibility(tx, compID, matchID, loserID, loserName) +} + +// restoreIfWithdrawalRemoved is the one statement of "the eligibility record +// follows the ruling" for a write that replaced a recorded withdrawal (kiken, +// fusenpai). It restores every competitor-status entry this match recorded +// (restoreEligibilityRecordedByMatch) except the one the write itself has just +// recorded, loser: +// - the decision now stored is not a withdrawal: the withdrawal was +// removed, so nobody this match barred stays barred (loser is nil); +// - it is a withdrawal again: it may have MOVED to the other side, so the +// competitor the first entry barred is restored and loser, the one this +// write barred, is kept. When the write could not resolve a loser (nil) +// no entry is provably stale, so nothing is restored (PR #416 finding 1: +// re-recording a withdrawal whose side name drifted must not free the +// competitor who still withdrew). +// +// Returns the last restored status, or nil when nothing was restored. Every +// door that replaces a withdrawal calls it after its write landed: +// RecordMatchResultWithIneligibilityTx (PUT /score, bulk-score and, through +// it, POST /decision) and writeMatchResult. The reopen (reopenUnderCourtLock) +// and restoreForceReopened call it too, after their save, since clearing the +// decision removes a withdrawal just as a rescore does. +func (e *Engine) restoreIfWithdrawalRemoved(tx state.StoreTx, compID, matchID, priorDecision, storedDecision string, loser *domain.CompetitorStatus) *domain.CompetitorStatus { + if !domain.IsWithdrawalDecisionStr(priorDecision) { + return nil + } + keep := "" + if domain.IsWithdrawalDecisionStr(storedDecision) { + if loser == nil { + log.Printf("engine: restoreIfWithdrawalRemoved compId=%s matchId=%s: the write recorded %q but resolved no loser; restoring nobody (no entry this match recorded is provably stale)", + compID, matchID, storedDecision) + return nil + } + keep = loser.PlayerID + } + return e.restoreEligibilityRecordedByMatch(tx, compID, matchID, keep) +} + // rollbackMatchResultTx restores prior over a partial score-write within the -// same transaction. Shared by the two reject paths in -// RecordMatchResultWithIneligibilityTx: K3 (AlreadyIneligible) and mp-e2k1 -// (downstream knockout already scored). Within a tx, writes are in-memory WAL +// same transaction. Shared by the reject paths in +// RecordMatchResultWithIneligibilityTx: K3 (AlreadyIneligible) and the pool +// requalification refusals (requalifyAfterPoolWrite: a knockout match the +// move reaches is being fought, or was already fought and the operator has not +// confirmed). Within a tx, writes are in-memory WAL // intents coalesced last-write-wins, so this restore supersedes the forward // write before Commit applies the final state. prior must be non-nil. // @@ -359,15 +432,7 @@ func (e *Engine) StartMatchTx(tx state.StoreTx, compID, matchID string) error { if err != nil { return err } - for _, pid := range ids { - if pid == "" { - continue - } - if st, ok := statuses[pid]; ok && !st.Eligible && st.MatchID != matchID { - return &IneligibleCompetitorError{PlayerID: pid, Reason: st.Reason} - } - } - return nil + return eligibilityErrorForSides(statuses, matchID, ids) } // checkSimultaneousMatchTx returns *IneligibleCompetitorError if either @@ -402,34 +467,41 @@ func (e *Engine) StartMatchTx(tx state.StoreTx, compID, matchID string) error { func (e *Engine) checkSimultaneousMatchTx(h state.StoreTx, compID, matchID string) error { var sideA, sideB, rawIDA, rawIDB string isPoolMatch := false + // Load errors are returned as they are (see lookupExistingResult): an + // unreadable pool-matches.csv/bracket.json must not silently drop out of + // this check -- swallowing it let a corrupt file pass the simultaneity + // gate and start a competitor on a second court while their first match + // was still running. poolMatches, poolErr := h.LoadPoolMatches(compID) - if poolErr == nil { - if m, ok := findPoolMatch(poolMatches, matchID); ok { - isPoolMatch = true - sideA, sideB = m.SideA, m.SideB - rawIDA, rawIDB = m.SideAID, m.SideBID - } + if poolErr != nil { + return poolErr + } + if m, ok := findPoolMatch(poolMatches, matchID); ok { + isPoolMatch = true + sideA, sideB = m.SideA, m.SideB + rawIDA, rawIDB = m.SideAID, m.SideBID } // Loaded once, reused below both for this match's OWN identity (bracket // half only) and for the bracket-vs-bracket comparison loop. bracket, berr := h.LoadBracket(compID) + if berr != nil { + return berr + } if !isPoolMatch { - if berr == nil { - if bm := findBracketMatchInBracket(bracket, matchID); bm != nil { - sideA, sideB = bm.SideA, bm.SideB - rawIDA, rawIDB = bm.SideAID, bm.SideBID - } + if bm := findBracketMatchInBracket(bracket, matchID); bm != nil { + sideA, sideB = bm.SideA, bm.SideB + rawIDA, rawIDB = bm.SideAID, bm.SideBID } if sideA == "" && sideB == "" { - // Not found in the bracket read above (a berr load failure, or a - // genuinely unknown matchID): fall back to the general resolver, - // matching the previous behaviour. + // Not found in the bracket read above (a genuinely unknown + // matchID): fall back to the general resolver, matching the + // previous behaviour. var err error sideA, sideB, err = e.lookupMatchSides(h, compID, matchID) if err != nil { - return nil + return err } } // The row's OWN ids are preferred (above); the roster name scan only @@ -458,38 +530,60 @@ func (e *Engine) checkSimultaneousMatchTx(h state.StoreTx, compID, matchID strin return name } - if poolErr == nil { - for _, m := range poolMatches { - if m.ID == matchID || m.Status != state.MatchStatusRunning { - continue - } - if rawIDA != "" && (m.SideAID == rawIDA || m.SideBID == rawIDA) { - return &IneligibleCompetitorError{ - PlayerID: playerIDFor(rawIDA, sideA), - Reason: fmt.Sprintf("already fighting in match %s on court %s", m.ID, m.Court), - } + // bc-cse: the operator sentence for the gate itself (comp/bracket are + // already the ones this call needs to name the OTHER match, m/bm below, + // not matchID). comp is loaded LAZILY, inside the closure, not above: + // every caller of checkSimultaneousMatchTx returns immediately on the + // first conflict found, so this closure runs AT MOST ONCE per call, and + // loading comp here means the common case (no conflict) never pays for + // it at all. comp is still best-effort: OperatorMatchLabel degrades to + // the pool's own name on a nil comp (no League-heading override), so a + // load failure still gates the write correctly, only the sentence's + // match name gets plainer -- but the error is logged rather than + // discarded, per the errcheck rule. + simultaneousReason := func(name, otherMatchID, court string) string { + comp, err := h.LoadCompetition(compID) + if err != nil { + log.Printf("engine: checkSimultaneousMatchTx: LoadCompetition compId=%s: %v (label degrades to pool-phase-only)", compID, err) + } + label := OperatorMatchLabel(comp, bracket, otherMatchID) + return fmt.Sprintf("%s is fighting now in %s on Shiaijo %s. Finish that match first.", name, label, court) + } + + for _, m := range poolMatches { + if m.ID == matchID || m.Status != state.MatchStatusRunning { + continue + } + if rawIDA != "" && (m.SideAID == rawIDA || m.SideBID == rawIDA) { + return &IneligibleCompetitorError{ + PlayerID: playerIDFor(rawIDA, sideA), + Reason: simultaneousReason(sideA, m.ID, m.Court), + Simultaneous: true, } - if rawIDB != "" && (m.SideAID == rawIDB || m.SideBID == rawIDB) { - return &IneligibleCompetitorError{ - PlayerID: playerIDFor(rawIDB, sideB), - Reason: fmt.Sprintf("already fighting in match %s on court %s", m.ID, m.Court), - } + } + if rawIDB != "" && (m.SideAID == rawIDB || m.SideBID == rawIDB) { + return &IneligibleCompetitorError{ + PlayerID: playerIDFor(rawIDB, sideB), + Reason: simultaneousReason(sideB, m.ID, m.Court), + Simultaneous: true, } } } - if berr == nil && bracket != nil { + if bracket != nil { checkBracketMatch := func(bm *state.BracketMatch) error { if sideA != "" && matchesBracketSide(rawIDA, sideA, bm) { return &IneligibleCompetitorError{ - PlayerID: playerIDFor(rawIDA, sideA), - Reason: fmt.Sprintf("already fighting in match %s on court %s", bm.ID, bm.Court), + PlayerID: playerIDFor(rawIDA, sideA), + Reason: simultaneousReason(sideA, bm.ID, bm.Court), + Simultaneous: true, } } if sideB != "" && matchesBracketSide(rawIDB, sideB, bm) { return &IneligibleCompetitorError{ - PlayerID: playerIDFor(rawIDB, sideB), - Reason: fmt.Sprintf("already fighting in match %s on court %s", bm.ID, bm.Court), + PlayerID: playerIDFor(rawIDB, sideB), + Reason: simultaneousReason(sideB, bm.ID, bm.Court), + Simultaneous: true, } } return nil @@ -602,7 +696,7 @@ func (e *Engine) checkCourtExclusivityTx(tx state.StoreTx, compID, matchID strin // match back to running, so a court that already has a running match would end // up with TWO, wedging the exclusivity check for BOTH (the re-End of the // reopened match and every further score write to the genuinely live bout). -// See ReopenKachinukiMatch's COURT GATE note. +// See ReopenMatch's COURT GATE note. func courtFreeInCompTxWith(tx state.StoreTx, compID, matchID, court string, poolMatches []state.MatchResult, bracket *state.Bracket) error { if court == "" { return nil @@ -709,71 +803,6 @@ func resolvePlayerIDs(h state.StoreTx, compID, sideA, sideB string) (rawIDA, raw return lookupPlayerID(pool, sideA), lookupPlayerID(pool, sideB) } -// hasStartedKnockoutMatchTx reports whether any BRACKET (knockout) match -// with status running or completed currently lists one of playerNames as a -// side. This is the bracket-only counterpart of hasDownstreamMatchStarted -// (eligibility.go, called here through tx), pool matches are intentionally -// NOT scanned because a pool finisher legitimately appears in their own -// completed pool bouts, which must NOT trip the guard. -// -// mp-e2k1. -func (e *Engine) hasStartedKnockoutMatchTx(tx state.StoreTx, compID string, playerNames []string) (matchedName, matchID string, err error) { - wantSet := make(map[string]struct{}, len(playerNames)) - for _, n := range playerNames { - if n != "" { - wantSet[n] = struct{}{} - } - } - if len(wantSet) == 0 { - return "", "", nil - } - // matchedSide returns the displaced name found on this match (a or b), or "" - // if neither side is one of the displaced finishers. Returning the name keeps - // the caller's error payload consistent: the reported Finisher is the one - // actually sitting in the blocking match, not just displaced[0]. - matchedSide := func(a, b string) string { - if _, ok := wantSet[a]; ok { - return a - } - if _, ok := wantSet[b]; ok { - return b - } - return "" - } - isStarted := func(s state.MatchStatus) bool { - return s == state.MatchStatusRunning || s == state.MatchStatusCompleted - } - bracket, err := tx.LoadBracket(compID) - if err != nil { - // A genuinely absent bracket is NOT an error, LoadBracket maps a - // missing file to an empty bracket with nil error (parseBracketFile, - // os.IsNotExist). So a non-nil error here is a real fault (corrupt - // bracket.json, permission/IO error). Propagate it rather than treating - // it as "no started knockout match", which would let the caller's guard - // fail open and allow a re-score that should be blocked. - return "", "", err - } - if bracket == nil { - return "", "", nil - } - for _, round := range bracket.Rounds { - for _, bm := range round { - if !isStarted(bm.Status) { - continue - } - if name := matchedSide(bm.SideA, bm.SideB); name != "" { - return name, bm.ID, nil - } - } - } - if bm := bracket.ThirdPlaceMatch; bm != nil && isStarted(bm.Status) { - if name := matchedSide(bm.SideA, bm.SideB); name != "" { - return name, bm.ID, nil - } - } - return "", "", nil -} - // clientWriteStamp is the caller's server-relative write stamp, or 0 when the // caller has none to give. Variadic because the ONE caller that must always // stamp is the /decision HTTP handler: the engine-internal pass-through and the @@ -991,111 +1020,85 @@ func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, d if err != nil { return nil, nil, err } - // T103 undo: restore eligibility for whichever competitor-status ENTRY - // this exact match wrote (st.MatchID == matchID, st.Eligible == false) - // that the write above did NOT just (re)confirm. Restoring by the - // record's own MatchID -- not by re-deriving identity from the match's - // side names/ids the way the prior version of this block did -- is - // exact for every shape the write can take, including a same-name - // pairing: recordIneligibilityFromDecision already resolved and wrote - // this exact entry once, correctly, at kiken/fusenpai time (or refused - // to, for a row it could not resolve), so there is nothing left here to - // re-derive or guess. This also closes a starvation bug the old - // name/id-comparison version had: for a same-name pairing, the old - // ambiguity skip fired on every rescore of that match (not just the - // one that mattered), so the prior loser stayed permanently ineligible - // -- ReinstateCompetitor refuses unless Reinstateable, and neither - // kiken-voluntary nor fusenpai ever are. - // - // currentLoserID is the player the write above just confirmed - // ineligible for THIS match, if any (status is nil when the new - // decision no longer causes ineligibility at all, e.g. rescored as - // "fought", or when recordIneligibilityFromDecision's own write was a - // non-fatal no-op). Every OTHER MatchID==matchID/Eligible==false entry - // is stale by construction (recordIneligibilityFromDecision's K2 - // check-and-set only ever adds one such entry per call) and is restored. - // - // PR #416 finding 1: status==nil does NOT always mean "this match no - // longer makes anyone ineligible" -- RecordMatchResultWithIneligibilityTx - // also returns (nil, nil) when recordIneligibilityFromDecision could not - // RESOLVE the loser (its *ValidationError is logged and swallowed a few - // lines above) or hit a non-fatal load error. Re-recording the SAME - // kiken/fusenpai on a match whose side name has drifted from the roster - // would otherwise flip the STILL-withdrawn competitor back to - // Eligible:true: the loop below would see status==nil, treat every - // MatchID==matchID entry as stale, and restore it. Gate the restore on - // the write having actually SETTLED the loser: when the new decision is - // itself a withdrawal and the write resolved no status at all, no - // MatchID==matchID record is provably stale, so skip the restore - // outright and log why, rather than silently un-revoking eligibility the - // operator never rescinded. Restoring proceeds as before whenever the - // new decision is NOT a withdrawal (the "fought"/undo path this loop - // exists for) or when a status WAS resolved (the write settled who, - // this match, is currently the loser). - newIsWithdrawal := domain.IsWithdrawalDecisionStr(decision) - switch { - case newIsWithdrawal && status == nil: - log.Printf("engine: RecordDecisionTx compId=%s matchId=%s: new decision %q is a withdrawal but recordIneligibilityFromDecision did not resolve/write a loser; skipping the stale-eligibility restore (no MatchID==%s record is provably stale)", - compID, matchID, decision, matchID) - default: - if statuses, serr := tx.LoadCompetitorStatus(compID); serr != nil { - log.Printf("engine: RecordDecisionTx compId=%s matchId=%s: LoadCompetitorStatus for restore: %v", compID, matchID, serr) - } else { - var currentLoserID string - if status != nil { - currentLoserID = status.PlayerID - } - // PR #416 finding 2: iterate in sorted playerID order so the - // outcome is deterministic rather than depending on Go's - // randomized map iteration order -- matters once more than one - // stale entry needs restoring in the same call, which previously - // left BOTH which player's restore landed last (irrelevant, every - // resolved restore below is written unconditionally) and, more - // importantly, which one this function RETURNS to depend on map - // iteration order. - // - // This intentionally preserves the existing single-status - // priority (documented in the loop below by the fact that a - // restore, when one fires, still overwrites `status`), the same - // shape TestRecordDecision_KikenUndo and - // TestRecordDecisionTx_KikenUndoSucceeds already pin for a - // decisionBy flip (kiken moves from Alice to Bob): the RESTORED - // player's re-earned eligibility is what the operator's UI most - // needs surfaced from this single-value return -- the new - // loser's withdrawal is already conveyed by the returned - // MatchResult's own Decision/DecisionBy/Winner fields, while the - // restoration has no other channel. Changing that priority (or - // broadcasting every touched status individually) needs either a - // wider return shape or handler-side fan-out, both out of this - // change's scope; the sentence above is the deliberate, narrower - // slice of finding 2 this pass implements. - playerIDs := make([]string, 0, len(statuses)) - for playerID := range statuses { - playerIDs = append(playerIDs, playerID) - } - sort.Strings(playerIDs) - for _, playerID := range playerIDs { - st := statuses[playerID] - if st.MatchID != matchID || st.Eligible || playerID == currentLoserID { - continue - } - // A fresh minimal status, not the stale record with Eligible - // flipped: Reason/Reinstateable describe why the player WAS - // ineligible, which no longer applies once restored (mirrors the - // removed restoreCompetitorEligibility's own construction). - restored := domain.CompetitorStatus{ - PlayerID: playerID, - Eligible: true, - MatchID: matchID, - RecordedAt: time.Now().UTC(), - } - if werr := tx.SetCompetitorStatus(compID, restored); werr != nil { - log.Printf("engine: RecordDecisionTx compId=%s matchId=%s: restoring stale eligibility for playerId=%s: %v", compID, matchID, playerID, werr) - continue - } - status = &restored - } + // T103 undo: the write above already restored whoever the withdrawal it + // replaced had barred (restoreIfWithdrawalRemoved, called by + // RecordMatchResultWithIneligibilityTx for every door), keeping the new + // withdrawer, and returned the restored status in priority. A second + // restore here read that RESTORED status as the current loser and freed + // the new withdrawer, so there is none: the rule has one owner. + return result, status, nil +} + +// restoreEligibilityRecordedByMatch restores eligibility for every +// competitor-status ENTRY this exact match wrote (st.MatchID == matchID, +// st.Eligible == false) except keepPlayerID, the player the current write +// has just (re)confirmed ineligible for this match ("" when it confirmed +// nobody). It returns the last status it restored, or nil when it restored +// none. Its one caller is restoreIfWithdrawalRemoved, which every door that +// replaces a recorded withdrawal goes through: a withdrawal moved to the +// other side or replaced by another outcome, on /decision, /score and +// bulk-score alike, and a reopen that clears it. +// +// Restoring by the record's own MatchID -- not by re-deriving identity from +// the match's side names/ids the way an earlier version did -- is exact for +// every shape the write can take, including a same-name pairing: +// recordIneligibilityFromDecision already resolved and wrote this exact +// entry once, correctly, at kiken/fusenpai time (or refused to, for a row it +// could not resolve), so there is nothing left here to re-derive or guess. +// It also closes a starvation bug the old name/id-comparison version had: +// for a same-name pairing, the old ambiguity skip fired on every rescore of +// that match, so the prior loser stayed permanently ineligible -- +// ReinstateCompetitor refuses unless Reinstateable, and neither +// kiken-voluntary nor fusenpai ever are. +// +// Every kind of withdrawal is restored, kiken-voluntary included, even +// though FIK Art. 31 bars a voluntary withdrawer from following shiai: a +// withdrawal the operator replaces or removes was a wrong entry, and a +// withdrawal that never happened bars nobody (operator ruling 2026-09-24: +// "Everything should be able to be fixed, in case of a wrong entry"). +// +// PR #416 finding 2: players are visited in sorted playerID order so the +// returned status is deterministic rather than depending on Go's randomized +// map iteration order once more than one stale entry is restored in one +// call. The RESTORED player's status is what the caller returns because it +// is what the operator's UI most needs surfaced: a new loser's withdrawal is +// already conveyed by the returned MatchResult's own Decision/DecisionBy/ +// Winner fields, while the restoration has no other channel. +// +// Best effort, like the eligibility write it undoes: a load or write error +// is logged and the entry skipped, never failing the score write that has +// already landed. +func (e *Engine) restoreEligibilityRecordedByMatch(tx state.StoreTx, compID, matchID, keepPlayerID string) *domain.CompetitorStatus { + statuses, serr := tx.LoadCompetitorStatus(compID) + if serr != nil { + log.Printf("engine: restoreEligibilityRecordedByMatch compId=%s matchId=%s: LoadCompetitorStatus: %v", compID, matchID, serr) + return nil + } + playerIDs := make([]string, 0, len(statuses)) + for playerID := range statuses { + playerIDs = append(playerIDs, playerID) + } + sort.Strings(playerIDs) + var last *domain.CompetitorStatus + for _, playerID := range playerIDs { + st := statuses[playerID] + if st.MatchID != matchID || st.Eligible || playerID == keepPlayerID { + continue + } + // A fresh minimal status, not the stale record with Eligible + // flipped: Reason/Reinstateable describe why the player WAS + // ineligible, which no longer applies once restored. + restored := domain.CompetitorStatus{ + PlayerID: playerID, + Eligible: true, + MatchID: matchID, + RecordedAt: time.Now().UTC(), } + if werr := tx.SetCompetitorStatus(compID, restored); werr != nil { + log.Printf("engine: restoreEligibilityRecordedByMatch compId=%s matchId=%s: restoring playerId=%s: %v", compID, matchID, playerID, werr) + continue + } + last = &restored } - return result, status, nil + return last } diff --git a/internal/engine/scoring_tx_internal_test.go b/internal/engine/scoring_tx_internal_test.go index f7e117d89..2aaa90145 100644 --- a/internal/engine/scoring_tx_internal_test.go +++ b/internal/engine/scoring_tx_internal_test.go @@ -43,7 +43,7 @@ func TestRecordBracketMatchResultTx_HappyPath(t *testing.T) { } var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, txErr = eng.recordBracketMatchResult(tx, compID, "M1", result, matchWriteForward, false) + _, _, txErr = eng.recordBracketMatchResult(tx, compID, "M1", result, matchWriteForward, false) return nil }) require.NoError(t, txErr) @@ -70,7 +70,7 @@ func TestRecordBracketMatchResultTx_MatchNotFound(t *testing.T) { var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, txErr = eng.recordBracketMatchResult(tx, compID, "GHOST", &state.MatchResult{Winner: "X"}, matchWriteForward, false) + _, _, txErr = eng.recordBracketMatchResult(tx, compID, "GHOST", &state.MatchResult{Winner: "X"}, matchWriteForward, false) return nil }) require.Error(t, txErr) @@ -87,7 +87,7 @@ func TestRecordBracketMatchResultTx_NilBracket(t *testing.T) { var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, txErr = eng.recordBracketMatchResult(tx, compID, "M1", &state.MatchResult{Winner: "X"}, matchWriteForward, false) + _, _, txErr = eng.recordBracketMatchResult(tx, compID, "M1", &state.MatchResult{Winner: "X"}, matchWriteForward, false) return nil }) require.Error(t, txErr) diff --git a/internal/engine/scoring_tx_test.go b/internal/engine/scoring_tx_test.go index 57fe9011e..d84f5a3bd 100644 --- a/internal/engine/scoring_tx_test.go +++ b/internal/engine/scoring_tx_test.go @@ -657,7 +657,7 @@ func TestRecordMatchResultWithIneligibilityTx_HansokuAutoAward(t *testing.T) { assert.Equal(t, []string{"M"}, stored[0].IpponsA) } -// --- mp-e2k1: pool re-score guard against scored downstream knockout -------- +// --- pool corrections that move a qualifier the knockout already seated ---- // saveMixedCompForGuardTest sets up a minimal mixed competition with two pools // (poolWinners=1), saves the scheduled pool matches and the preview knockout @@ -784,24 +784,22 @@ func TestPoolRescore_NoFinisherChange_Allowed(t *testing.T) { assert.NoError(t, rescore, "re-score with same finisher must be allowed even after knockout match is scored") } -// TestPoolRescore_FinisherFlip_LeafScheduled_Allowed verifies that re-scoring -// a pool match to flip the 1st-place finisher while the knockout leaf is still -// scheduled succeeds and does NOT trigger the guard. -func TestPoolRescore_FinisherFlip_LeafScheduled_Allowed(t *testing.T) { +// TestPoolRescore_FinisherFlip_LeafScheduled_Repaints pins G1: once the +// competition has reached knockout status nothing re-resolved a pool, so a +// correction that moved the pool winner while the knockout leaf was still +// untouched left the OLD qualifier in the slot. The correcting write now +// seats the new one itself, in its own transaction, with no resolver call. +func TestPoolRescore_FinisherFlip_LeafScheduled_Repaints(t *testing.T) { eng, store, compID := saveMixedCompForGuardTest(t, 0) - // Score Pool A: A1 wins. Score Pool B: B1 wins. scorePoolMatchTx(t, eng, store, compID, "Pool A-0", "A1", "A2", "A1") scorePoolMatchTx(t, eng, store, compID, "Pool B-0", "B1", "B2", "B1") - - // Resolve the bracket so the knockout leaf has real names. _, allResolved, err := eng.ResolveQualifiedPools(compID) require.NoError(t, err) require.True(t, allResolved) + setCompStatus(t, store, compID, state.CompStatusKnockout) - // Do NOT score the knockout leaf, it stays scheduled. - - // RE-SCORE Pool A to flip finisher (A2 now wins). + // The knockout leaf stays scheduled. Flip Pool A (A2 now wins). var rescore error txErr := store.WithTransaction(compID, func(tx state.StoreTx) error { _, rescore = eng.RecordMatchResultWithIneligibilityTx(tx, compID, "Pool A-0", &state.MatchResult{ @@ -814,14 +812,54 @@ func TestPoolRescore_FinisherFlip_LeafScheduled_Allowed(t *testing.T) { return nil }) require.NoError(t, txErr) - assert.NoError(t, rescore, "finisher flip on unscored knockout leaf must be allowed") + require.NoError(t, rescore, "a finisher flip whose knockout leaf is untouched must be allowed") + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + leaf := b.Rounds[0][0] + a2 := poolPlayerID(t, store, compID, "A2") + if leaf.PlaceholderA == "Pool A-1st" { + assert.Equal(t, "A2", leaf.SideA) + assert.Equal(t, a2, leaf.SideAID) + } else { + require.Equal(t, "Pool A-1st", leaf.PlaceholderB) + assert.Equal(t, "A2", leaf.SideB) + assert.Equal(t, a2, leaf.SideBID) + } + assert.Equal(t, state.MatchStatusScheduled, leaf.Status) } -// TestPoolRescore_FinisherFlip_KnockoutCompleted_Rejected verifies that re-scoring -// a pool match to flip a finisher whose knockout leaf is completed returns -// DownstreamKnockoutScoredError (wrapping ErrDownstreamKnockoutScored) and rolls -// back the pool-match result to the prior state. -func TestPoolRescore_FinisherFlip_KnockoutCompleted_Rejected(t *testing.T) { +// setCompStatus moves a test competition to status. +func setCompStatus(t *testing.T, store *state.Store, compID string, status state.CompetitionStatus) { + t.Helper() + _, err := store.UpdateCompetitionChanged(compID, func(c *state.Competition) (*state.Competition, error) { + c.Status = status + return c, nil + }) + require.NoError(t, err) +} + +// poolPlayerID returns the participant id of the pool player named name. +func poolPlayerID(t *testing.T, store *state.Store, compID, name string) string { + t.Helper() + pools, err := store.LoadPools(compID) + require.NoError(t, err) + for _, p := range pools { + for _, pl := range p.Players { + if pl.Name == name { + return pl.ID + } + } + } + t.Fatalf("no pool player named %q", name) + return "" +} + +// TestPoolRescore_FinisherFlip_KnockoutCompleted_Warns verifies that re-scoring +// a pool match to flip a finisher whose knockout leaf was already fought +// returns DownstreamKnockoutPlayedError naming the leaf and the place that +// moves, and rolls back the pool-match result, until the operator confirms. +func TestPoolRescore_FinisherFlip_KnockoutCompleted_Warns(t *testing.T) { eng, store, compID := saveMixedCompForGuardTest(t, 0) // Score Pool A (A1 wins) and Pool B (B1 wins). @@ -864,14 +902,21 @@ func TestPoolRescore_FinisherFlip_KnockoutCompleted_Rejected(t *testing.T) { return nil }) require.NoError(t, txErr) - require.Error(t, rescore, "flipping finisher of a scored knockout must be rejected") - assert.ErrorIs(t, rescore, ErrDownstreamKnockoutScored) + require.Error(t, rescore, "flipping the finisher of a fought knockout must warn first") + assert.ErrorIs(t, rescore, ErrDownstreamKnockoutPlayed) - var dkErr *DownstreamKnockoutScoredError + var dkErr *DownstreamKnockoutPlayedError require.ErrorAs(t, rescore, &dkErr) - assert.Equal(t, "Pool A", dkErr.Pool) - assert.Equal(t, "A1", dkErr.Finisher) - assert.Equal(t, knockoutMatchID, dkErr.MatchID) + assert.Equal(t, "Pool A-0", dkErr.MatchID) + assert.Equal(t, knockoutMatchID, dkErr.BlockingMatchID) + require.Len(t, dkErr.QualifierChange, 1) + qc := dkErr.QualifierChange[0] + assert.Equal(t, "Pool A", qc.Pool) + assert.Equal(t, 1, qc.Rank) + assert.Equal(t, "1st", qc.Place) + assert.Equal(t, "A1", qc.From.Name) + assert.Equal(t, "A2", qc.To.Name) + assert.Equal(t, poolPlayerID(t, store, compID, "A2"), qc.To.ID) // Verify the pool match was rolled back to prior state (A1 wins). matches, err := store.LoadPoolMatches(compID) @@ -892,8 +937,10 @@ func TestPoolRescore_FinisherFlip_KnockoutCompleted_Rejected(t *testing.T) { assert.Equal(t, "A1", b.Rounds[0][0].Winner, "knockout match winner must remain unchanged after rejected pool re-score") } -// TestPoolRescore_FinisherFlip_KnockoutRunning_Rejected verifies the guard -// also fires when the downstream bracket match is RUNNING (not yet completed). +// TestPoolRescore_FinisherFlip_KnockoutRunning_Rejected verifies a knockout +// match being fought refuses the move outright, and that the operator's +// confirmation cannot get past it: reopening mid-bout would wipe strikes being +// scored at the shiaijo. func TestPoolRescore_FinisherFlip_KnockoutRunning_Rejected(t *testing.T) { eng, store, compID := saveMixedCompForGuardTest(t, 0) @@ -919,46 +966,49 @@ func TestPoolRescore_FinisherFlip_KnockoutRunning_Rejected(t *testing.T) { }) require.NoError(t, txErr) - // Attempt re-score Pool A flipping the finisher. - var rescore error - txErr = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, rescore = eng.RecordMatchResultWithIneligibilityTx(tx, compID, "Pool A-0", &state.MatchResult{ - SideA: "A1", - SideB: "A2", - Winner: "A2", - IpponsB: []string{"M"}, - Status: state.MatchStatusCompleted, + // Attempt re-score Pool A flipping the finisher, with and without force. + for _, force := range []bool{false, true} { + var rescore error + txErr = store.WithTransaction(compID, func(tx state.StoreTx) error { + _, rescore = eng.RecordMatchResultWithIneligibilityTx(tx, compID, "Pool A-0", &state.MatchResult{ + SideA: "A1", + SideB: "A2", + Winner: "A2", + IpponsB: []string{"M"}, + Status: state.MatchStatusCompleted, + }, ForceOptions{Force: force}) + return nil }) - return nil - }) - require.NoError(t, txErr) - require.Error(t, rescore) - assert.ErrorIs(t, rescore, ErrDownstreamKnockoutScored, "running knockout leaf must also block a finisher flip") + require.NoError(t, txErr) + require.Error(t, rescore, "force=%v", force) + assert.ErrorIs(t, rescore, ErrDownstreamKnockoutRunning, "a running knockout leaf refuses a finisher flip (force=%v)", force) + assert.NotErrorIs(t, rescore, ErrDownstreamKnockoutPlayed, "never offered as confirmable (force=%v)", force) + var runErr *DownstreamKnockoutRunningError + require.ErrorAs(t, rescore, &runErr) + require.Len(t, runErr.Running, 1) + assert.Equal(t, knockoutMatchID, runErr.Running[0].ID) + assert.Contains(t, runErr.Error(), "is being fought now. Finish it or send it back to the queue, then save again.") + } + poolA0 := loadPoolMatchByID(t, store, compID, "Pool A-0") + assert.Equal(t, "A1", poolA0.Winner, "the refused correction must not land") + b, err = store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusRunning, b.Rounds[0][0].Status) } -// TestPoolRescore_NamesakeFinisherSwap_KnockoutStarted_Rejected is the -// regression guard for the finding that oldTopN/newSet/displaced in -// RecordMatchResultWithIneligibilityTx were keyed by bare Player.Name: a -// re-score that swaps WHICH of two same-named, different-dojo competitors -// (CheckDuplicateEntriesByNameDojo explicitly allows this) holds a -// qualifying rank changed the IDENTITY at that rank without changing the -// bare name occupying it, so the name-keyed `displaced` computation stayed -// empty and hasStartedKnockoutMatchTx was never even consulted -- a silent -// identity swap under a started bracket slot. +// TestPoolRescore_NamesakeFinisherSwap_KnockoutPlayed_Warns: a correction +// that swaps WHICH of two same-named, different-dojo competitors +// (CheckDuplicateEntriesByNameDojo explicitly allows this) holds a qualifying +// place changes the competitor without changing the name in the slot. The +// change is measured by participant id against the slot's own id, so it is +// caught; a name comparison would see "Alice" before and after and let the +// swap through silently under a played knockout match. // -// Fixture: Pool A has three competitors -- P1 (always wins, rank 1) and two -// "Alice"s from different dojos. AliceX beats AliceY initially (AliceX ranks -// 2nd, qualifying with poolWinners=2; AliceY ranks 3rd, non-qualifying). A -// knockout match is hand-crafted as already RUNNING for the qualifying -// "Alice" (there is no way to tell from the bracket's bare-name side which -// dojo actually qualified, which is itself the point: the guard's downstream -// bracket lookup is name-only and deliberately over-broad). Re-scoring the -// AliceX-vs-AliceY pool match to flip the winner promotes AliceY to 2nd and -// drops AliceX out of the top-N: same bare name at rank 2, different -// competitor. The identity-keyed guard must detect this as a displacement and -// reject the re-score; the pre-fix bare-name-keyed version saw "Alice" still -// present at rank 2 and let it through. -func TestPoolRescore_NamesakeFinisherSwap_KnockoutStarted_Rejected(t *testing.T) { +// Fixture: Pool A has P1 (always 1st) and two Alices. AliceX beats AliceY, so +// AliceX is 2nd and qualifies (poolWinners=2). The knockout leaf seated for +// "Pool A-2nd" holds AliceX (by id) and has been fought. Re-scoring the +// AliceX-vs-AliceY match promotes AliceY. +func TestPoolRescore_NamesakeFinisherSwap_KnockoutPlayed_Warns(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "namesake-guard-test" @@ -971,7 +1021,7 @@ func TestPoolRescore_NamesakeFinisherSwap_KnockoutStarted_Rejected(t *testing.T) Name: compID, Kind: "individual", Format: state.CompFormatMixed, - Status: state.CompStatusPools, + Status: state.CompStatusKnockout, Courts: []string{"A"}, StartTime: "09:00", PoolWinners: 2, @@ -990,21 +1040,22 @@ func TestPoolRescore_NamesakeFinisherSwap_KnockoutStarted_Rejected(t *testing.T) scoreNamesakeMatchTx(t, eng, store, compID, "Pool A-1", "P1", "Alice", p1.ID, aliceY.ID, p1.ID) scoreNamesakeMatchTx(t, eng, store, compID, "Pool A-2", "Alice", "Alice", aliceX.ID, aliceY.ID, aliceX.ID) - // Sanity: AliceX (rank 2) qualifies with poolWinners=2, AliceY (rank 3) does not. standings, err := eng.CalculatePoolStandings(compID) require.NoError(t, err) require.Len(t, standings["Pool A"], 3) require.Equal(t, aliceX.ID, standings["Pool A"][1].Player.ID, "AliceX must be rank 2 before the re-score") - // Hand-craft a started knockout match for the qualifying "Alice" (bracket - // sides are bare names only; there is no dojo to tell the two apart). + // The seated knockout: AliceX (by id) in "Pool A-2nd", already fought. require.NoError(t, store.SaveBracket(compID, &state.Bracket{ - Rounds: [][]state.BracketMatch{ - {{ID: "r1-m0", SideA: "Alice", SideB: "P1", Status: state.MatchStatusRunning}}, - }, + Rounds: [][]state.BracketMatch{{{ + ID: "m-r1-0", MatchNumber: 1, + PlaceholderA: "Pool A-2nd", PlaceholderB: "Pool A-1st", + SideA: "Alice", SideAID: aliceX.ID, SideB: "P1", SideBID: p1.ID, + Winner: "Alice", WinnerID: aliceX.ID, IpponsA: []string{"M"}, + Status: state.MatchStatusCompleted, + }}}, })) - // Re-score Pool A-2, flipping the winner: AliceY now beats AliceX. var rescore error txErr := store.WithTransaction(compID, func(tx state.StoreTx) error { _, rescore = eng.RecordMatchResultWithIneligibilityTx(tx, compID, "Pool A-2", &state.MatchResult{ @@ -1020,22 +1071,18 @@ func TestPoolRescore_NamesakeFinisherSwap_KnockoutStarted_Rejected(t *testing.T) return nil }) require.NoError(t, txErr) - require.Error(t, rescore, "swapping which namesake holds the qualifying rank must be rejected while the knockout leaf is started") - assert.ErrorIs(t, rescore, ErrDownstreamKnockoutScored) - var dkErr *DownstreamKnockoutScoredError + require.Error(t, rescore, "swapping which namesake holds the place must warn while the knockout leaf is played") + var dkErr *DownstreamKnockoutPlayedError require.ErrorAs(t, rescore, &dkErr) - assert.Equal(t, "Pool A", dkErr.Pool) - - // Verify the pool match was rolled back: AliceX must still be the recorded winner. - matches, err := store.LoadPoolMatches(compID) - require.NoError(t, err) - var poolA2 *state.MatchResult - for i := range matches { - if matches[i].ID == "Pool A-2" { - poolA2 = &matches[i] - break - } - } + require.Len(t, dkErr.QualifierChange, 1) + qc := dkErr.QualifierChange[0] + assert.Equal(t, 2, qc.Rank) + assert.Equal(t, aliceX.ID, qc.From.ID) + assert.Equal(t, aliceY.ID, qc.To.ID) + assert.Equal(t, "Alice", qc.From.Name) + assert.Equal(t, "Alice", qc.To.Name) + + poolA2 := loadPoolMatchByID(t, store, compID, "Pool A-2") require.NotNil(t, poolA2) assert.Equal(t, aliceX.ID, poolA2.WinnerID, "pool match result must have been rolled back to AliceX as winner") } @@ -1099,8 +1146,8 @@ func TestPoolRescore_NonMixedComp_GuardIsNoOp(t *testing.T) { assert.NoError(t, rescore, "non-mixed comp must never trigger the downstream knockout guard") } -// TestPoolRescore_TeamMixed_GuardFires verifies that the guard applies equally -// to team-format mixed competitions (TeamSize > 0). +// TestPoolRescore_TeamMixed_GuardFires verifies that the warning applies +// equally to team-format mixed competitions (TeamSize > 0). func TestPoolRescore_TeamMixed_GuardFires(t *testing.T) { eng, store, compID := saveMixedCompForGuardTest(t, 3 /* TeamSize */) @@ -1143,14 +1190,15 @@ func TestPoolRescore_TeamMixed_GuardFires(t *testing.T) { return nil }) require.NoError(t, txErr) - require.Error(t, rescore, "team mixed comp must also be protected by the downstream knockout guard") - assert.ErrorIs(t, rescore, ErrDownstreamKnockoutScored) + require.Error(t, rescore, "team mixed comp must also warn before moving a played qualifier") + assert.ErrorIs(t, rescore, ErrDownstreamKnockoutPlayed) } -// TestPoolRescore_CorruptBracket_FailsClosed verifies the guard does NOT fail -// open when the bracket can't be read. A displacing re-score whose verification -// hits a corrupt bracket.json must be rejected (error surfaced) and the prior -// pool result preserved, never silently committed. (Copilot review, PR #246.) +// TestPoolRescore_CorruptBracket_FailsClosed verifies the requalification +// check does NOT fail open when the bracket can't be read. A re-score whose +// check hits a corrupt bracket.json must be rejected (error surfaced) and the +// prior pool result preserved, never silently committed. (Copilot review, PR +// #246.) func TestPoolRescore_CorruptBracket_FailsClosed(t *testing.T) { eng, store, dir := setupTestEngine(t) compID := "guard-corrupt" @@ -1190,8 +1238,8 @@ func TestPoolRescore_CorruptBracket_FailsClosed(t *testing.T) { bracketPath := filepath.Join(dir, "competitions", compID, "bracket.json") require.NoError(t, os.WriteFile(bracketPath, []byte("{ this is not valid json"), 0o600)) - // Re-score Pool A-0 to flip the finisher (A1 → A2). This displaces A1, so the - // guard tries to read the (now corrupt) bracket. It must fail closed. + // Re-score Pool A-0 to flip the finisher (A1 → A2). The requalification + // check reads the (now corrupt) bracket. It must fail closed. var rescore error txErr := store.WithTransaction(compID, func(tx state.StoreTx) error { _, rescore = eng.RecordMatchResultWithIneligibilityTx(tx, compID, "Pool A-0", &state.MatchResult{ @@ -1200,8 +1248,8 @@ func TestPoolRescore_CorruptBracket_FailsClosed(t *testing.T) { return nil // mirror the handler: surface the engine error out-of-band }) require.NoError(t, txErr) - require.Error(t, rescore, "a corrupt bracket must make the guard fail closed, not silently allow the re-score") - assert.NotErrorIs(t, rescore, ErrDownstreamKnockoutScored, "this is a read fault, not a clean downstream-scored rejection") + require.Error(t, rescore, "a corrupt bracket must make the check fail closed, not silently allow the re-score") + assert.NotErrorIs(t, rescore, ErrDownstreamKnockoutPlayed, "this is a read fault, not a clean downstream warning") // The corrupting re-score must NOT have persisted: Pool A-0 still A1-wins. stored, err := store.LoadPoolMatches(compID) @@ -1216,59 +1264,11 @@ func TestPoolRescore_CorruptBracket_FailsClosed(t *testing.T) { assert.Equal(t, "A1", poolA0.Winner, "prior pool result must be preserved after a fail-closed rejection") } -// TestHasStartedKnockoutMatchTx_ReportsMatchedFinisher verifies the helper -// returns the displaced name actually sitting in the started match, not just -// the first input name, so the 409 payload's Finisher stays consistent with -// MatchID when more than one finisher is displaced (poolWinners > 1). -// (Copilot review, PR #246.) -func TestHasStartedKnockoutMatchTx_ReportsMatchedFinisher(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "matched-finisher" - - require.NoError(t, store.SaveCompetition(&state.Competition{ - ID: compID, Name: compID, Kind: "individual", - Format: state.CompFormatMixed, Status: state.CompStatusPools, - Courts: []string{"A"}, StartTime: "09:00", PoolWinners: 2, - })) - // A1's leaf is still scheduled; A2's leaf is running. Scanning for both must - // return A2 (the one in the started match), regardless of slice order. - require.NoError(t, store.SaveBracket(compID, &state.Bracket{ - Rounds: [][]state.BracketMatch{{ - {ID: "r0-m0", SideA: "A1", SideB: "X1", Status: state.MatchStatusScheduled}, - {ID: "r0-m1", SideA: "X2", SideB: "A2", Status: state.MatchStatusRunning}, - }}, - })) - - run := func(names []string) (string, string) { - var gotName, gotID string - require.NoError(t, store.WithTransaction(compID, func(tx state.StoreTx) error { - var err error - gotName, gotID, err = eng.hasStartedKnockoutMatchTx(tx, compID, names) - return err - })) - return gotName, gotID - } - - name, id := run([]string{"A1", "A2"}) - assert.Equal(t, "A2", name, "must report the finisher in the started match, not displaced[0]") - assert.Equal(t, "r0-m1", id) - - // Order-independence: A2 first must give the same result. - name, id = run([]string{"A2", "A1"}) - assert.Equal(t, "A2", name) - assert.Equal(t, "r0-m1", id) - - // Only a scheduled-leaf finisher → no started match found. - name, id = run([]string{"A1"}) - assert.Empty(t, name) - assert.Empty(t, id) -} - -// TestKnockoutRescore_NotGatedAsPoolMatch verifies the guard does not mistake a -// knockout (bracket) match for a pool match. Bracket IDs ("m-rN-i") parse as a -// pool via poolNameFromMatchID's trailing-"-digits" rule, so without the -// IsPoolMatchID gate a KO re-score would run the pool-standings guard. Re-scoring -// a KO match must succeed and never raise DownstreamKnockoutScoredError. +// TestKnockoutRescore_NotGatedAsPoolMatch verifies the requalification check +// does not mistake a knockout (bracket) match for a pool match. Bracket IDs +// ("m-rN-i") parse as a pool via poolNameFromMatchID's trailing-"-digits" +// rule, so without the IsPoolMatchID gate a KO re-score would run the pool +// check. Re-scoring a KO match with nothing downstream must succeed. // (Copilot review, PR #246.) func TestKnockoutRescore_NotGatedAsPoolMatch(t *testing.T) { eng, store, compID := saveMixedCompForGuardTest(t, 0) @@ -1301,8 +1301,7 @@ func TestKnockoutRescore_NotGatedAsPoolMatch(t *testing.T) { }) return nil })) - assert.NoError(t, rescore, "knockout re-score must not be gated by the pool re-score guard") - assert.NotErrorIs(t, rescore, ErrDownstreamKnockoutScored) + assert.NoError(t, rescore, "knockout re-score must not be gated by the pool requalification check") } // TestCourtOccupied covers the PURE court-occupancy scan extracted for E4 diff --git a/internal/engine/sub_hantei_preserve_test.go b/internal/engine/sub_hantei_preserve_test.go index 8887b92a1..f9d71d8f0 100644 --- a/internal/engine/sub_hantei_preserve_test.go +++ b/internal/engine/sub_hantei_preserve_test.go @@ -797,7 +797,7 @@ func TestBracketForwardWrite_PreservedVerdictReachesTheWinner(t *testing.T) { SubResults: stored().SubResults, } result := silent() - mismatch, _, _ := applyPoolWrite(poolStored, result, matchWriteForward) + mismatch, _, _, _ := applyPoolWrite(poolStored, result, matchWriteForward) require.False(t, mismatch) assert.Equal(t, "Kyoto", poolStored.Winner) }) diff --git a/internal/engine/swiss.go b/internal/engine/swiss.go index ba6391bf6..406f0f21f 100644 --- a/internal/engine/swiss.go +++ b/internal/engine/swiss.go @@ -769,11 +769,17 @@ func (e *Engine) SwissStandings(compID string) ([]state.PlayerStanding, error) { if sA == nil || sB == nil { continue } - if isTeam && len(m.SubResults) > 0 { + if isTeam { // Team match: the parent carries no ippons of its own, so the // tie-break columns (IV/IL/IT/PW/PL) come from the sub-bouts, // via the same shared accrual the pool path uses in - // computeStandingsFrom. + // computeStandingsFrom. Unconditional, not gated on + // len(m.SubResults) > 0: routing is by isTeam alone (see + // computeStandingsFrom's twin comment), so a padding-less + // default-win match was never misrouted into the individual + // branch below -- the gap padding closes is CREDIT: without + // it, accrueTeamSubResults' loop over an empty slice is a + // no-op and silently contributes zero IV/PW for it. accrueTeamSubResults(sA, sB, m) } else { // Individual scoring: ippons at match level, via countScoringIppons diff --git a/internal/engine/testdata/bracket_match_numbers.json b/internal/engine/testdata/bracket_match_numbers.json index ade444a1b..720ffab32 100644 --- a/internal/engine/testdata/bracket_match_numbers.json +++ b/internal/engine/testdata/bracket_match_numbers.json @@ -63,12 +63,12 @@ "id": "m-r1-0", "sideA": "Player01", "sideB": "Player02", - "displayRound": 3, + "displayRound": 2, "feeders": [ "", "" ], - "matchNumber": 1 + "matchNumber": 2 }, { "id": "m-r1-1", @@ -91,7 +91,7 @@ "", "" ], - "matchNumber": 2 + "matchNumber": 1 } ], [ @@ -219,32 +219,32 @@ ] }, { - "name": "knockout on 1 shiaijo", + "name": "knockout on 1 shiaijo, orderings disagree here", "entrants": 9, - "discriminating": false, + "discriminating": true, "rounds": [ [ { "id": "m-r1-0", "sideA": "Player01", "sideB": "Player02", - "displayRound": 4, + "displayRound": 3, "feeders": [ "", "" ], - "matchNumber": 1 + "matchNumber": 2 }, { "id": "m-r1-1", "sideA": "Player03", "sideB": "Player04", - "displayRound": 4, + "displayRound": 3, "feeders": [ "", "" ], - "matchNumber": 2 + "matchNumber": 3 }, { "id": "m-r1-2", @@ -262,12 +262,12 @@ "id": "m-r1-4", "sideA": "Player05", "sideB": "Player06", - "displayRound": 4, + "displayRound": 3, "feeders": [ "", "" ], - "matchNumber": 3 + "matchNumber": 4 }, { "id": "m-r1-5", @@ -290,7 +290,7 @@ "", "" ], - "matchNumber": 4 + "matchNumber": 1 } ], [ @@ -298,12 +298,12 @@ "id": "m-r2-0", "sideA": "Winner of r4-m0", "sideB": "Winner of r4-m1", - "displayRound": 3, + "displayRound": 2, "feeders": [ "m-r1-0", "m-r1-1" ], - "matchNumber": 5 + "matchNumber": 6 }, { "id": "m-r2-1", @@ -326,7 +326,7 @@ "", "m-r1-7" ], - "matchNumber": 6 + "matchNumber": 5 } ], [ @@ -373,12 +373,12 @@ "id": "m-r1-0", "sideA": "Player01", "sideB": "Player02", - "displayRound": 4, + "displayRound": 3, "feeders": [ "", "" ], - "matchNumber": 1 + "matchNumber": 4 }, { "id": "m-r1-1", @@ -401,7 +401,7 @@ "", "" ], - "matchNumber": 2 + "matchNumber": 1 }, { "id": "m-r1-4", @@ -418,7 +418,7 @@ "", "" ], - "matchNumber": 3 + "matchNumber": 2 }, { "id": "m-r1-6", @@ -435,7 +435,7 @@ "", "" ], - "matchNumber": 4 + "matchNumber": 3 } ], [ @@ -864,32 +864,32 @@ ] }, { - "name": "knockout on 1 shiaijo", + "name": "knockout on 1 shiaijo, orderings disagree here", "entrants": 19, - "discriminating": false, + "discriminating": true, "rounds": [ [ { "id": "m-r1-0", "sideA": "Player01", "sideB": "Player02", - "displayRound": 5, + "displayRound": 4, "feeders": [ "", "" ], - "matchNumber": 1 + "matchNumber": 4 }, { "id": "m-r1-1", "sideA": "Player03", "sideB": "Player04", - "displayRound": 5, + "displayRound": 4, "feeders": [ "", "" ], - "matchNumber": 2 + "matchNumber": 5 }, { "id": "m-r1-2", @@ -907,12 +907,12 @@ "id": "m-r1-4", "sideA": "Player05", "sideB": "Player06", - "displayRound": 5, + "displayRound": 4, "feeders": [ "", "" ], - "matchNumber": 3 + "matchNumber": 6 }, { "id": "m-r1-5", @@ -935,18 +935,18 @@ "", "" ], - "matchNumber": 4 + "matchNumber": 1 }, { "id": "m-r1-8", "sideA": "Player10", "sideB": "Player11", - "displayRound": 5, + "displayRound": 4, "feeders": [ "", "" ], - "matchNumber": 5 + "matchNumber": 8 }, { "id": "m-r1-9", @@ -969,18 +969,18 @@ "", "" ], - "matchNumber": 6 + "matchNumber": 2 }, { "id": "m-r1-12", "sideA": "Player15", "sideB": "Player16", - "displayRound": 5, + "displayRound": 4, "feeders": [ "", "" ], - "matchNumber": 7 + "matchNumber": 10 }, { "id": "m-r1-13", @@ -1003,7 +1003,7 @@ "", "" ], - "matchNumber": 8 + "matchNumber": 3 } ], [ @@ -1011,12 +1011,12 @@ "id": "m-r2-0", "sideA": "Winner of r5-m0", "sideB": "Winner of r5-m1", - "displayRound": 4, + "displayRound": 3, "feeders": [ "m-r1-0", "m-r1-1" ], - "matchNumber": 9 + "matchNumber": 12 }, { "id": "m-r2-1", @@ -1039,7 +1039,7 @@ "", "m-r1-7" ], - "matchNumber": 10 + "matchNumber": 7 }, { "id": "m-r2-4", @@ -1056,7 +1056,7 @@ "", "m-r1-11" ], - "matchNumber": 11 + "matchNumber": 9 }, { "id": "m-r2-6", @@ -1073,7 +1073,7 @@ "", "m-r1-15" ], - "matchNumber": 12 + "matchNumber": 11 } ], [ diff --git a/internal/engine/testdata/match_labels.json b/internal/engine/testdata/match_labels.json new file mode 100644 index 000000000..19482d1cb --- /dev/null +++ b/internal/engine/testdata/match_labels.json @@ -0,0 +1,55 @@ +{ + "_comment": [ + "Operator-facing match labels, shared by BOTH language implementations:", + "OperatorMatchLabel in internal/engine/match_label.go (Go) and the", + "combination of poolLabel/leagueAwareLabel (viewer_utils.jsx),", + "poolMatchNumberOf (pool_ids.jsx) and matchLabel (write_result.jsx) that", + "scoreRowMatchLabel (admin_schedule_score_editor.jsx) already composes for", + "the score-editor header (JS). TestOperatorMatchLabel_GoldenTable", + "(internal/engine/match_label_test.go) drives the Go side; a JS test pins", + "the pool-phase rows against the same file.", + "", + "Schema, one entry per case:", + " id the match id exactly as stored (state.MatchResult.ID /", + " state.BracketMatch.ID)", + " format the competition's state.Competition.Format value", + " (\"\", \"mixed\", \"league\", \"swiss\", \"knockout\"); only", + " \"league\" changes a pool-phase heading (\"League table\"", + " instead of the id's own pool name -- CompFormatLeague", + " reuses the ordinary single-pool match generation, so the", + " id shape is identical to a real pool's)", + " label the expected operator-facing label", + " matchNumber KNOCKOUT cases only: state.BracketMatch.MatchNumber for", + " a synthetic bracket entry at this id (omitted/0 for a", + " pool-phase case, and for a knockout id no bracket carries", + " -- MatchLabel's own bare-id fallback then applies)", + " displayRound KNOCKOUT cases only: state.BracketMatch.DisplayRound for", + " the same synthetic entry (1 = Final, see", + " testdata/round_labels.json for the full table)", + "", + "Daihyosen/tiebreaker (\"-DH-N\"/\"-TB-N\") rows have no JS equivalent to", + "mirror: poolMatchNumberOf returns 0 for them and scoreRowMatchLabel then", + "renders no match-number suffix at all (admin_scoring_individual.jsx shows", + "a separate \"DH\" tag badge instead). \" daihyosen\"/\"", + "tiebreaker\" is this file's OWN spelling for the cases where Go code (a", + "refusal, a reopen message) must still name the bout in one string; a JS", + "test consuming these two rows checks that spelling, not an existing", + "render, since the JS side never had to name one in a single string", + "before." + ], + "cases": [ + { "id": "Pool A-0", "format": "", "label": "Pool A · Match 1" }, + { "id": "Pool B-2", "format": "mixed", "label": "Pool B · Match 3" }, + { "id": "Pool A-East-0", "format": "", "label": "Pool A-East · Match 1" }, + { "id": "Pool A-DH-0", "format": "", "label": "Pool A daihyosen" }, + { "id": "Pool A-TB-1", "format": "", "label": "Pool A tiebreaker" }, + { "id": "Pool A-4", "format": "league", "label": "League table · Match 5" }, + { "id": "Swiss-R2-0", "format": "swiss", "label": "Round 2 · Match 1" }, + { "id": "Swiss-R10-3", "format": "swiss", "label": "Round 10 · Match 4" }, + { "id": "m-r2-0", "format": "knockout", "label": "Match 1 (Semifinals)", "matchNumber": 1, "displayRound": 2 }, + { "id": "m-r3-0", "format": "knockout", "label": "Match 3 (Final)", "matchNumber": 3, "displayRound": 1 }, + { "id": "m-r1-4", "format": "knockout", "label": "Match 5 (R16)", "matchNumber": 5, "displayRound": 4 }, + { "id": "m-bronze", "format": "knockout", "label": "the 3rd-place match" }, + { "id": "m-unknown", "format": "knockout", "label": "m-unknown" } + ] +} diff --git a/internal/engine/testdata/round_labels.json b/internal/engine/testdata/round_labels.json new file mode 100644 index 000000000..7304521cb --- /dev/null +++ b/internal/engine/testdata/round_labels.json @@ -0,0 +1,28 @@ +{ + "_comment": [ + "The knockout round names, shared by BOTH language implementations:", + "roundLabelFromEnd in internal/engine/errors.go (the round a refusal or a", + "reopen names, through MatchLabel: \"Match 3 (Final)\") and", + "roundLabelFromEnd in web-mobile/js/bracket.jsx (the heading over each", + "bracket column, reached through bracketRoundLabel). Keyed by the match's", + "displayRound (1 = Final). TestRoundLabelFromEnd_GoldenTable", + "(internal/engine/match_label_test.go) drives the Go side; the \"round", + "label Go/JS mirror\" describe in", + "web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx drives", + "the JS side over this same file.", + "", + "Why a shared file: a dialog that says \"Match 3 (Semifinals)\" sends the", + "operator to the column headed Semifinals, so the two spellings must not", + "drift. Changing one language and its own tests together is the realistic", + "drift, and two independent tables both stay green through it; editing", + "this file fails the other language's suite at once." + ], + "cases": [ + { "displayRound": 1, "label": "Final" }, + { "displayRound": 2, "label": "Semifinals" }, + { "displayRound": 3, "label": "Quarterfinals" }, + { "displayRound": 4, "label": "R16" }, + { "displayRound": 5, "label": "R32" }, + { "displayRound": 7, "label": "R128" } + ] +} diff --git a/internal/engine/tiebreaker.go b/internal/engine/tiebreaker.go index 9f8e3a568..e99e47ff1 100644 --- a/internal/engine/tiebreaker.go +++ b/internal/engine/tiebreaker.go @@ -12,8 +12,14 @@ import ( // via hasNumericSuffixAfter (daihyosen.go) for the same reason as its // IsPoolDaihyosenMatchID sibling: a plain substring match would misclassify a // regular match in a pool whose name happens to contain "-TB-". +// IsTiebreakerMatchID reports whether matchID identifies a supplementary +// ippon-shobu tiebreaker match (IDs of the form "Pool X-TB-N"). +// +// Delegates to state.IsTiebreakerMatchID, the id grammar's one owner (see +// its IsPoolDaihyosenMatchID sibling's doc for why); kept here under the +// engine-facing name every other file in this package already uses. func IsTiebreakerMatchID(matchID string) bool { - return hasNumericSuffixAfter(matchID, "-TB-") + return state.IsTiebreakerMatchID(matchID) } // teamStandingPoints and individualStandingPoints compute the single packed @@ -126,25 +132,7 @@ func applyTiebreakSort(sorted []state.PlayerStanding, matches []state.MatchResul i := positions[0] j := positions[len(positions)-1] + 1 - ids := groupMemberIDs(sorted[i:j]) - - groupWins := map[string]int{} - for _, m := range matches { - if !isSupplementaryID(m.ID) || m.Status != state.MatchStatusCompleted || m.WinnerID == "" { - continue - } - if !ids[m.SideAID] || !ids[m.SideBID] || m.SideAID == m.SideBID { - continue - } - // Winner by id only (operator ruling bc-pnum); see resolveWinnerSide. - winnerIsA, winnerIsB := resolveWinnerSide(m) - switch { - case winnerIsA: - groupWins[m.SideAID]++ - case winnerIsB: - groupWins[m.SideBID]++ - } - } + groupWins := supplementaryGroupWins(sorted[i:j], matches, isSupplementaryID) if len(groupWins) > 0 { sort.SliceStable(sorted[i:j], func(a, b int) bool { keyA := sorted[i+a].Player.ID @@ -155,6 +143,76 @@ func applyTiebreakSort(sorted []state.PlayerStanding, matches []state.MatchResul } } +// supplementaryGroupWins counts each tied-group member's wins in the decided +// supplementary bouts (isSupplementaryID: TB or DH rows) fought WITHIN the +// group, keyed by participant id. It is the one tally both readers of a +// supplementary result use: applyTiebreakSort orders the group by it, and +// tieSettled asks whether it orders the group completely. Membership and +// attribution are id-only, exactly as applyTiebreakSort's doc comment states. +func supplementaryGroupWins(group []state.PlayerStanding, matches []state.MatchResult, isSupplementaryID func(string) bool) map[string]int { + ids := groupMemberIDs(group) + groupWins := map[string]int{} + for _, m := range matches { + if !isSupplementaryID(m.ID) || m.Status != state.MatchStatusCompleted || m.WinnerID == "" { + continue + } + if !ids[m.SideAID] || !ids[m.SideBID] || m.SideAID == m.SideBID { + continue + } + // Winner by id only (operator ruling bc-pnum); see resolveWinnerSide. + winnerIsA, winnerIsB := resolveWinnerSide(m) + switch { + case winnerIsA: + groupWins[m.SideAID]++ + case winnerIsB: + groupWins[m.SideBID]++ + } + } + return groupWins +} + +// strictlyOrderedByWins reports whether a win tally puts every member of the +// group on a DIFFERENT count, i.e. the bouts decided the group's whole order. +// A member with no id can hold no wins (supplementaryGroupWins is id-only), so +// it leaves the order undecided. Distinct counts are only reachable once the +// group's full pairwise round has been fought with no drawn bout (n distinct +// counts sum to at least n(n-1)/2, one per bout), so a round still in progress +// never reads as settled. +func strictlyOrderedByWins(group []state.PlayerStanding, wins map[string]int) bool { + seen := make(map[int]bool, len(group)) + for _, s := range group { + if s.Player.ID == "" { + return false + } + w := wins[s.Player.ID] + if seen[w] { + return false + } + seen[w] = true + } + return true +} + +// tieSettled reports whether a Points-tied group's finishing order has +// already been decided, so it is no longer a tie to break: the operator +// recorded a chusen for every member (chusenRecorded, the per-pool rank +// overrides), or the group's supplementary bouts ordered it completely, the +// ippon-shobu (TB) bouts in any competition and the daihyosen (DH) bouts in a +// team one, the same two passes computeStandingsFrom sorts by. Points never +// change after either (applyTiebreakSort only reorders within the group), so +// detectPoolTies alone still reports such a group, and the tie highlight read +// it as a tie forever (operator ruling 2026-09-24: a tie settled by chusen or +// by daihyosen is no longer a tie). +func tieSettled(group []state.PlayerStanding, matches []state.MatchResult, isTeam bool, groupOverrides map[string]int) bool { + if chusenRecorded(group, groupOverrides) { + return true + } + if strictlyOrderedByWins(group, supplementaryGroupWins(group, matches, IsTiebreakerMatchID)) { + return true + } + return isTeam && strictlyOrderedByWins(group, supplementaryGroupWins(group, matches, IsPoolDaihyosenMatchID)) +} + // tieAffectsAdvancement reports whether a tied group (identified by its 0-based // positions from detectPoolTies over the sorted standings) can change the pool // outcome and therefore warrants a supplementary bout. @@ -211,6 +269,63 @@ func tieNeedsIndividualBreak(comp *state.Competition, positions []int, group []s return tieAffectsAdvancement(positions, poolWinners) } +// pendingTieBreak is one tied group of a pool that still owes supplementary +// bouts: its positions (0-based, into the Points-sorted standings) and the +// bouts that would settle it. +type pendingTieBreak struct { + positions []int + bouts []state.MatchResult +} + +// pendingTieBreaks is the ONE statement of which supplementary bouts a pool +// still owes, given the rows already on disk. It is the per-pool body of both +// injectors (InjectTiebreakerMatches with daihyosen=false, +// InjectPoolDaihyosenMatches with daihyosen=true), extracted so the +// requalification planner (pool_requalify.go) can ask the same question +// inside a transaction, where the injectors themselves cannot run (they write +// through the bare store). +// +// A group whose bouts have all been generated returns nothing here even though +// detectPoolTies still reports it: Points never change after a supplementary +// bout (applyTiebreakSort only reorders within the group), so "tied" is not +// "unsettled". A caller wanting "unsettled" must use this, never +// detectPoolTies alone, or a settled pool reads as tied forever. +// +// The band each kind breaks is the injector's own: individual bouts follow +// tieNeedsIndividualBreak, team daihyosen follows tieAffectsAdvancement, both +// against EffectivePoolWinners (not MatchWinnerRanksNeeded: an +// extra-qualifier "-2nd" slot below that band is never given a bout, so +// treating it as owing one would leave it unresolved forever). +// +// existingRows are the pool's rows of the matching kind (TB rows for +// individual, DH rows for team); their count seeds the new bouts' ids. +func pendingTieBreaks(comp *state.Competition, poolName string, poolStandings []state.PlayerStanding, existingRows []state.MatchResult, court string, daihyosen bool) []pendingTieBreak { + poolWinners := comp.EffectivePoolWinners() + existingCount := len(existingRows) + var out []pendingTieBreak + for _, positions := range detectPoolTies(poolStandings) { + group := standingsAt(poolStandings, positions) + var bouts []state.MatchResult + if daihyosen { + if !tieAffectsAdvancement(positions, poolWinners) { + continue + } + bouts = generatePoolDaihyosenMatches(poolName, group, existingCount, court, existingRows) + } else { + if !tieNeedsIndividualBreak(comp, positions, group, poolWinners) { + continue + } + bouts = generateTiebreakerMatches(poolName, group, existingCount, court, existingRows) + } + if len(bouts) == 0 { + continue + } + existingCount += len(bouts) + out = append(out, pendingTieBreak{positions: positions, bouts: bouts}) + } + return out +} + // tiebreakerPairKey returns a canonical (order-independent) key for a // pair of player names, used to detect already-existing TB matches. func tiebreakerPairKey(a, b string) string { @@ -356,9 +471,7 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er } // Supplementary ippon-shobu bouts are held only where the tie affects - // advancement/seeding (see tieAffectsAdvancement): top poolWinners advance. - poolWinners := comp.EffectivePoolWinners() - + // advancement/seeding; pendingTieBreaks owns that band. standings, err := e.CalculatePoolStandings(compID) if err != nil { return nil, err @@ -375,11 +488,7 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er // the SPECIFIC tied group being processed via groupMemberIDs -- see // that function's doc comment for why a bare-name reduction at this scan // stage would collapse distinct namesake-involving pairs. - type poolTBInfo struct { - existingRows []state.MatchResult - count int - } - poolTB := map[string]*poolTBInfo{} + poolTB := map[string][]state.MatchResult{} poolCourt := map[string]string{} // regularIncomplete[pool] becomes true if ANY regular (non-TB) match in the // pool is not yet completed. Tiebreakers must only be injected once a pool's @@ -401,11 +510,7 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er poolCourt[pn] = m.Court } if IsTiebreakerMatchID(m.ID) { - if poolTB[pn] == nil { - poolTB[pn] = &poolTBInfo{} - } - poolTB[pn].count++ - poolTB[pn].existingRows = append(poolTB[pn].existingRows, m) + poolTB[pn] = append(poolTB[pn], m) } else if m.Status != state.MatchStatusCompleted { regularIncomplete[pn] = true } @@ -417,22 +522,8 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er if regularIncomplete[poolName] { continue } - info := poolTB[poolName] - existingCount := 0 - var existingRows []state.MatchResult - if info != nil { - existingCount = info.count - existingRows = info.existingRows - } - - for _, positions := range detectPoolTies(poolStandings) { - group := standingsAt(poolStandings, positions) - if !tieNeedsIndividualBreak(comp, positions, group, poolWinners) { - continue - } - newMatches := generateTiebreakerMatches(poolName, group, existingCount, poolCourt[poolName], existingRows) - existingCount += len(newMatches) - injected = append(injected, newMatches...) + for _, p := range pendingTieBreaks(comp, poolName, poolStandings, poolTB[poolName], poolCourt[poolName], false) { + injected = append(injected, p.bouts...) } } diff --git a/internal/engine/tiebreaker_test.go b/internal/engine/tiebreaker_test.go index 446781d3c..f8050a06e 100644 --- a/internal/engine/tiebreaker_test.go +++ b/internal/engine/tiebreaker_test.go @@ -13,34 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestIsTiebreakerMatchID(t *testing.T) { - tests := []struct { - id string - want bool - }{ - {"Pool A-TB-0", true}, - {"Pool A-TB-1", true}, - {"Pool B-TB-42", true}, - {"Pool A-East-TB-0", true}, // hyphenated pool name - {"Pool A-0", false}, - {"Pool A-1", false}, - {"Pool A-TB", false}, // no index after TB - {"Pool A-T-0", false}, // different prefix - {"Pool A-TBx-0", false}, // wrong prefix - {"TB-0", false}, // no pool name separator - {"", false}, - // Same sibling scenario as IsPoolDaihyosenMatchID: a pool literally - // named "Pool A-TB-East" must not have its regular match ids - // misclassified as tiebreaker bouts. - {"Pool A-TB-East-0", false}, - } - for _, tc := range tests { - t.Run(tc.id, func(t *testing.T) { - assert.Equal(t, tc.want, IsTiebreakerMatchID(tc.id)) - }) - } -} - // namesAt resolves a tied position group back to competitor names, for stable // assertions when the input was sorted by Points. func namesAt(standings []state.PlayerStanding, positions []int) []string { diff --git a/internal/engine/tied_standings_test.go b/internal/engine/tied_standings_test.go index 451c7d809..a7abdcdd9 100644 --- a/internal/engine/tied_standings_test.go +++ b/internal/engine/tied_standings_test.go @@ -5,9 +5,11 @@ import ( "testing" "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" "github.com/gitrgoliveira/bracket-creator/internal/state" bctest "github.com/gitrgoliveira/bracket-creator/internal/test/idstamp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // tiedStanding builds a standing with an explicit name and Points value. @@ -81,28 +83,28 @@ func TestMarkTiedStandings_Pools(t *testing.T) { t.Run("no matches yet → no amber even when all tied at 0", func(t *testing.T) { sorted := []state.PlayerStanding{tiedStanding("A", 0), tiedStanding("B", 0), tiedStanding("C", 0)} - markTiedStandings(comp, sorted, nil, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, nil, rosterIndexFrom(sorted), nil) assert.Empty(t, tiedNames(sorted), "pre-start pool must not surface amber") }) t.Run("pool complete with a tie → tied rows marked", func(t *testing.T) { sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 100), tiedStanding("C", 50)} matches := []state.MatchResult{completedMatch(0, "A", "B"), completedMatch(1, "A", "C"), completedMatch(2, "B", "C")} - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Equal(t, map[string]bool{"A": true, "B": true}, tiedNames(sorted)) }) t.Run("pool incomplete → no amber even if currently tied", func(t *testing.T) { sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 100), tiedStanding("C", 50)} matches := []state.MatchResult{completedMatch(0, "A", "B"), scheduledMatch(1, "A", "C")} - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Empty(t, tiedNames(sorted), "an unscored match must suppress amber") }) t.Run("pool complete, no tie → nothing marked", func(t *testing.T) { sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 60), tiedStanding("C", 50)} matches := []state.MatchResult{completedMatch(0, "A", "B"), completedMatch(1, "A", "C"), completedMatch(2, "B", "C")} - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Empty(t, tiedNames(sorted)) }) @@ -112,7 +114,7 @@ func TestMarkTiedStandings_Pools(t *testing.T) { completedMatch(0, "A", "B"), completedMatch(1, "A", "C"), completedMatch(2, "B", "C"), {ID: "Pool A-TB-0", SideA: "A", SideB: "B", Status: state.MatchStatusScheduled}, } - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Equal(t, map[string]bool{"A": true, "B": true}, tiedNames(sorted), "a pending TB bout is supplementary and must not gate the highlight") }) @@ -158,7 +160,7 @@ func TestMarkTiedStandings_League(t *testing.T) { // All matches scheduled → nobody has finished their fixtures. matches := leagueRoundRobin([]string{"A", "B", "C", "D"}, false) stampStandingsAndMatchIDs(sorted, matches) - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Empty(t, tiedNames(sorted)) }) @@ -170,7 +172,7 @@ func TestMarkTiedStandings_League(t *testing.T) { scheduledMatch(3, "B", "C"), scheduledMatch(4, "B", "D"), scheduledMatch(5, "C", "D"), } stampStandingsAndMatchIDs(sorted, matches) - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Equal(t, map[string]bool{"A": true, "B": true}, tiedNames(sorted), "emerging trigger fires once a top-N competitor is done, even though B isn't") }) @@ -183,7 +185,7 @@ func TestMarkTiedStandings_League(t *testing.T) { } matches := leagueRoundRobin([]string{"A", "B", "C", "D", "E"}, true) stampStandingsAndMatchIDs(sorted, matches) - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Empty(t, tiedNames(sorted), "a tie below the top-N band is not consequential") }) @@ -191,7 +193,7 @@ func TestMarkTiedStandings_League(t *testing.T) { sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 90), tiedStanding("C", 80)} matches := leagueRoundRobin([]string{"A", "B", "C"}, true) stampStandingsAndMatchIDs(sorted, matches) - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Empty(t, tiedNames(sorted)) }) }) @@ -238,7 +240,7 @@ func TestMarkTiedStandings_League_NamesakeCountersNotMerged(t *testing.T) { {ID: "Pool A-2", SideA: "Alice", SideB: "Bob", SideAID: aliceOsaka.ID, SideBID: bob.ID, Status: state.MatchStatusScheduled}, } - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.True(t, sorted[0].Tied, "Alice@Tokyo finished every fight of her own and is in the tied top-N band") assert.True(t, sorted[1].Tied, "the whole tied group (both Alices) must be marked once the trigger fires") @@ -255,7 +257,7 @@ func TestMarkTiedStandings_TwoThirdPlacesExemption(t *testing.T) { comp := &state.Competition{Format: state.CompFormatLeague, LeagueTiebreakTopN: 4, LeagueTwoThirdPlaces: true} sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 90), tiedStanding("C", 50), tiedStanding("D", 50)} stampStandingsAndMatchIDs(sorted, matches) - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Empty(t, tiedNames(sorted), "joint-3rd tie needs no decider when two-third-places is enabled") }) @@ -263,7 +265,7 @@ func TestMarkTiedStandings_TwoThirdPlacesExemption(t *testing.T) { comp := &state.Competition{Format: state.CompFormatLeague, LeagueTiebreakTopN: 4, LeagueTwoThirdPlaces: false} sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 90), tiedStanding("C", 50), tiedStanding("D", 50)} stampStandingsAndMatchIDs(sorted, matches) - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Equal(t, map[string]bool{"C": true, "D": true}, tiedNames(sorted)) }) @@ -272,7 +274,7 @@ func TestMarkTiedStandings_TwoThirdPlacesExemption(t *testing.T) { // Tie at positions 2-3 (MinPosition 2 < 3): a decider IS needed for 2nd. sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 80), tiedStanding("C", 80), tiedStanding("D", 40)} stampStandingsAndMatchIDs(sorted, matches) - markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted)) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) assert.Equal(t, map[string]bool{"B": true, "C": true}, tiedNames(sorted)) }) } @@ -285,12 +287,12 @@ func TestMarkTiedStandings_AutoClear(t *testing.T) { // Tied first. tied := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 100), tiedStanding("C", 50)} - markTiedStandings(comp, tied, matches, rosterIndexFrom(tied)) + markTiedStandings(comp, tied, matches, rosterIndexFrom(tied), nil) assert.Equal(t, map[string]bool{"A": true, "B": true}, tiedNames(tied)) // A later result separates A and B → no rows flagged. resolved := []state.PlayerStanding{tiedStanding("A", 110), tiedStanding("B", 100), tiedStanding("C", 50)} - markTiedStandings(comp, resolved, matches, rosterIndexFrom(resolved)) + markTiedStandings(comp, resolved, matches, rosterIndexFrom(resolved), nil) assert.Empty(t, tiedNames(resolved), "resolved tie must clear the highlight") } @@ -300,6 +302,120 @@ func TestMarkTiedStandings_AutoClear(t *testing.T) { func TestMarkTiedStandings_EmptyStandings(t *testing.T) { comp := &state.Competition{Format: state.CompFormatLeague} var sorted []state.PlayerStanding - markTiedStandings(comp, sorted, nil, rosterIndexFrom(sorted)) // must not panic + markTiedStandings(comp, sorted, nil, rosterIndexFrom(sorted), nil) // must not panic assert.Empty(t, tiedNames(sorted)) } + +// TestMarkTiedStandings_SettledTieIsNotTied: Tied marks a tie still to be +// broken, which is what the amber row tells the operator (operator ruling +// 2026-09-24: a tie settled by chusen or by daihyosen is no longer a tie). +// Pools branch; A and B stay equal on Points whatever settles them. +func TestMarkTiedStandings_SettledTieIsNotTied(t *testing.T) { + comp := &state.Competition{Format: state.CompFormatMixed} + build := func(extra ...state.MatchResult) ([]state.PlayerStanding, []state.MatchResult) { + sorted := []state.PlayerStanding{tiedStanding("A", 100), tiedStanding("B", 100), tiedStanding("C", 50)} + matches := append([]state.MatchResult{completedMatch(0, "A", "B"), completedMatch(1, "A", "C"), completedMatch(2, "B", "C")}, extra...) + stampStandingsAndMatchIDs(sorted, matches) + return sorted, matches + } + // The key SaveRankOverride writes (lookupPoolRankOverride's doc comment). + chusen := func(sorted []state.PlayerStanding, names ...string) map[string]int { + o := map[string]int{} + for i, s := range sorted { + for _, n := range names { + if s.Player.Name == n { + o[helper.CompetitorKey(s.Player.ID, "", "")] = i + 1 + } + } + } + return o + } + tb := state.MatchResult{ID: "Pool A-TB-1", SideA: "A", SideB: "B", Status: state.MatchStatusScheduled} + tied := map[string]bool{"A": true, "B": true} + + t.Run("nothing recorded: still a tie", func(t *testing.T) { + sorted, matches := build() + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) + assert.Equal(t, tied, tiedNames(sorted)) + }) + t.Run("chusen recorded for every member: not a tie", func(t *testing.T) { + sorted, matches := build() + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), chusen(sorted, "A", "B")) + assert.Empty(t, tiedNames(sorted)) + }) + t.Run("an override on one member only: still a tie", func(t *testing.T) { + sorted, matches := build() + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), chusen(sorted, "A")) + assert.Equal(t, tied, tiedNames(sorted)) + }) + t.Run("ippon-shobu not yet fought: still a tie", func(t *testing.T) { + sorted, matches := build(tb) + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) + assert.Equal(t, tied, tiedNames(sorted)) + }) + t.Run("ippon-shobu decided: not a tie", func(t *testing.T) { + decided := tb + decided.Status = state.MatchStatusCompleted + sorted, matches := build(decided) + matches[3].WinnerID = matches[3].SideAID + markTiedStandings(comp, sorted, matches, rosterIndexFrom(sorted), nil) + assert.Empty(t, tiedNames(sorted)) + }) +} + +// TestCalculatePoolStandings_SettledTieIsNotTied drives the same rule through +// the real standings path for a team pool, where a daihyosen is the +// supplementary bout and a chusen is recorded as rank overrides: three teams +// tied on every criterion stay tied through a daihyosen cycle until the +// chusen is recorded, and a strictly ordered daihyosen settles them outright. +func TestCalculatePoolStandings_SettledTieIsNotTied(t *testing.T) { + all := map[string]bool{"Alpha": true, "Beta": true, "Gamma": true} + tiedIn := func(t *testing.T, eng *Engine, compID string) map[string]bool { + t.Helper() + standings, err := eng.CalculatePoolStandings(compID) + require.NoError(t, err) + return tiedNames(standings["Pool A"]) + } + // Alpha > Beta, Beta > Gamma, Gamma > Alpha: one win each. Checked as an + // unordered pair (see TestChusenCandidates_CycleNeedsChusen). + cycle := func(sideA, sideB string) string { + pair := map[string]bool{sideA: true, sideB: true} + switch { + case pair["Alpha"] && pair["Beta"]: + return "Alpha" + case pair["Alpha"] && pair["Gamma"]: + return "Gamma" + } + return "Beta" + } + + t.Run("daihyosen cycle, then chusen", func(t *testing.T) { + compID := "settled-tie-chusen" + eng, store := setupTeamPoolComp(t, compID, true) + assert.Equal(t, all, tiedIn(t, eng, compID), "a tie on every criterion is a tie") + _, err := eng.InjectPoolDaihyosenMatches(compID) + require.NoError(t, err) + scoreInjectedDH(t, eng, store, compID, cycle) + assert.Equal(t, all, tiedIn(t, eng, compID), "a daihyosen cycle leaves the tie to a chusen") + + require.NoError(t, store.SaveRankOverride(compID, "Pool A", bctest.StampPlayerID("Beta", "Dojo Beta"), 1)) + require.NoError(t, store.SaveRankOverride(compID, "Pool A", bctest.StampPlayerID("Gamma", "Dojo Gamma"), 2)) + require.NoError(t, store.SaveRankOverride(compID, "Pool A", bctest.StampPlayerID("Alpha", "Dojo Alpha"), 3)) + eng.standingsCache.Delete(compID) + eng.standingsFlight.Delete(compID) + assert.Empty(t, tiedIn(t, eng, compID), "the recorded chusen settled it") + }) + t.Run("strictly ordered daihyosen", func(t *testing.T) { + compID := "settled-tie-dh" + eng, store := setupTeamPoolComp(t, compID, true) + _, err := eng.InjectPoolDaihyosenMatches(compID) + require.NoError(t, err) + scoreInjectedDH(t, eng, store, compID, func(sideA, sideB string) string { + if sideA == "Alpha" || sideB == "Alpha" { + return "Alpha" + } + return "Beta" + }) + assert.Empty(t, tiedIn(t, eng, compID), "the daihyosen settled it") + }) +} diff --git a/internal/engine/withdrawal_removed_restores_test.go b/internal/engine/withdrawal_removed_restores_test.go new file mode 100644 index 000000000..97f6b81ff --- /dev/null +++ b/internal/engine/withdrawal_removed_restores_test.go @@ -0,0 +1,187 @@ +package engine + +// Operator ruling 2026-09-24: "Everything should be able to be fixed, in case +// of a wrong entry." When a write REPLACES a recorded withdrawal with a result +// that is not one, the eligibility record follows the ruling: the competitor +// the withdrawal barred is eligible again, whichever kind of withdrawal it +// was, and the restored status is returned so the handler can broadcast it. +// A score sheet's correction never replaces one (KeepsWithdrawalRuling keeps +// it for "" and "hikiwake"; see withdrawal_ruling_kept_test.go), and the +// operator's way to remove one recorded by mistake is a reopen +// (reopen_withdrawal_test.go). What replaces it here is a decision: POST +// /decision's fusensho or daihyosen, or any decision outside that allowlist. +// These pin the rule (RecordMatchResultWithIneligibilityTx, +// restoreIfWithdrawalRemoved, restoreEligibilityRecordedByMatch) on both +// doors. + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func wrEligible(t *testing.T, store *state.Store, compID, playerID string) bool { + t.Helper() + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + st, ok := statuses[playerID] + return !ok || st.Eligible +} + +// An individual editor's fought rescore over a withdrawal (no bout rows) +// replaces the ruling and restores the competitor it barred. +// F1: POST /decision with fusensho or daihyosen on a match a team withdrawal +// decided. preserveLoserScore carries the fought bout rows onto the decision +// write, so a bout-row test kept the kiken while recordDecisionTx's default +// arm still restored the loser: a stored kiken with an eligible loser. The +// allowlist makes such a decision REPLACE the withdrawal, and the restore then +// describes what is stored. +func TestWithdrawalRemoved_DecisionReplacingATeamWithdrawalRestores(t *testing.T) { + for _, decision := range []string{"fusensho", "daihyosen"} { + t.Run(decision, func(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, "kiken-voluntary") + require.False(t, wrEligible(t, store, compID, wrTeamAID), "precondition: the kiken barred Ryu") + require.NotEmpty(t, wrPoolMatch(t, store, compID).SubResults, "precondition: the kiken kept the fought bout") + + _, status, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "", nil, false) + require.NoError(t, err) + + m := wrPoolMatch(t, store, compID) + assert.Equal(t, decision, m.Decision, "the decision replaces the withdrawal") + require.NotNil(t, status, "the restore is returned so the handler can broadcast it") + assert.Equal(t, wrTeamAID, status.PlayerID) + assert.True(t, status.Eligible) + assert.True(t, wrEligible(t, store, compID, wrTeamAID), "a replaced withdrawal bars nobody") + // bc-tmfn follow-up: seedPoolWithdrawal's own kiken-voluntary + // decision already padded bouts 2 and 3 (TeamSize 3, only bout 1 + // was ever fought; state.PadDefaultWinBoutPositions), and that + // padded shape is inherited here via preserveLoserScore. + require.Len(t, m.SubResults, 3, "the bout fought before it survives (preserveLoserScore), plus the padded rows 2/3") + assert.Equal(t, []string{"M"}, m.SubResults[0].IpponsA, "the fought bout is unchanged") + }) + } +} + +// The same rule on the bracket branch, through the Tx door. +// The same rule on the bracket branch, through the Tx door, for a decision a +// score sheet cannot send ("fought", outside KeepsWithdrawalRuling's allowlist). +func TestWithdrawalRemoved_BracketTxDoorRestores(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "wr-ko-ind" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "wr", Kind: "individual", Status: state.CompStatusKnockout, + })) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{ + ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusRunning, + }}}})) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + require.NoError(t, err) + require.False(t, wrEligible(t, store, compID, wrTeamBID)) + + var status any + err = inTx(t, store, compID, func(tx state.StoreTx) error { + st, e := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", &state.MatchResult{ + ID: "m-r1-0", SideA: wrTeamA, SideB: wrTeamB, Winner: wrTeamA, Decision: "fought", + IpponsA: []string{"M"}, IpponsB: []string{}, + Status: state.MatchStatusCompleted, CorrectionReason: "Wrong entry: it was fought", + }) + if st != nil { + status = st.PlayerID + } + return e + }) + require.NoError(t, err) + assert.Equal(t, wrTeamBID, status) + assert.True(t, wrEligible(t, store, compID, wrTeamBID)) +} + +// wrFoughtBouts is a complete 3-bout sheet Tora wins 2-1. +func wrFoughtBouts() []state.SubMatchResult { + return []state.SubMatchResult{ + {Position: 1, SideA: "r1", SideB: "t1", Winner: "r1", IpponsA: []string{"K"}}, + {Position: 2, SideA: "r2", SideB: "t2", Winner: "t2", IpponsB: []string{"M"}}, + {Position: 3, SideA: "r3", SideB: "t3", Winner: "t3", IpponsB: []string{"D"}}, + } +} + +// A write that does not land restores nothing: a superseded write that would +// replace the withdrawal leaves it and the competitor's status as recorded. +func TestWithdrawalRemoved_SupersededWriteRestoresNothing(t *testing.T) { + eng, store, compID, dir := seedPoolWithdrawal(t, "kiken-voluntary") + // Re-record the same withdrawal with a stamp, so there is a time to lose to. + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "knee", nil, false, 1_900_000_000_000) + require.NoError(t, err) + before := string(readStatusFile(t, dir, compID)) + stored := wrPoolMatch(t, store, compID) + require.NotZero(t, stored.ModifiedAt) + + _, err = eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", &state.MatchResult{ + ID: "Pool A-0", SideA: wrTeamA, SideB: wrTeamB, Winner: wrTeamB, Decision: "fought", + Status: state.MatchStatusCompleted, ModifiedAt: stored.ModifiedAt - 1000, + SubResults: wrFoughtBouts(), + }) + require.ErrorIs(t, err, ErrMatchSuperseded) + assert.Equal(t, before, string(readStatusFile(t, dir, compID))) + assert.Equal(t, "kiken-voluntary", wrPoolMatch(t, store, compID).Decision) +} + +// RecordMatchResult (writeMatchResult) is the other forward door with an +// eligibility side effect, and follows the same rule. +func TestWithdrawalRemoved_RecordMatchResultDoorRestores(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, "kiken-voluntary") + require.False(t, wrEligible(t, store, compID, wrTeamAID)) + + require.NoError(t, eng.RecordMatchResult(compID, "Pool A-0", &state.MatchResult{ + ID: "Pool A-0", SideA: wrTeamA, SideB: wrTeamB, Winner: wrTeamB, Decision: "fought", + Status: state.MatchStatusCompleted, SubResults: wrFoughtBouts(), + })) + assert.True(t, wrEligible(t, store, compID, wrTeamAID)) + assert.Equal(t, "fought", wrPoolMatch(t, store, compID).Decision) +} + +// A score write (PUT /score, bulk-score) that records the withdrawal against +// the OTHER side moves it, exactly as POST /decision does: the new withdrawer +// is barred and the team the first entry barred by mistake is eligible again, +// with the restore returned for the broadcast. Before, only /decision's own +// restore did this, so the score doors left both teams barred. +func TestWithdrawalMoved_ScoreWriteRestoresTheFirstWithdrawer(t *testing.T) { + for name, write := range map[string]func(eng *Engine, compID string, r *state.MatchResult) (string, error){ + "RecordMatchResultWithIneligibility": func(eng *Engine, compID string, r *state.MatchResult) (string, error) { + st, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", r) + if st == nil { + return "", err + } + return st.PlayerID, err + }, + "RecordMatchResult": func(eng *Engine, compID string, r *state.MatchResult) (string, error) { + return "", eng.RecordMatchResult(compID, "Pool A-0", r) + }, + } { + t.Run(name, func(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, "kiken-voluntary") + require.False(t, wrEligible(t, store, compID, wrTeamAID), "precondition: the kiken barred Ryu") + + restored, err := write(eng, compID, &state.MatchResult{ + ID: "Pool A-0", SideA: wrTeamA, SideB: wrTeamB, + Winner: wrTeamA, WinnerID: wrTeamAID, Decision: "kiken-voluntary", DecisionBy: "shiro", + IpponsA: []string{"○", "○"}, IpponsB: []string{}, + Status: state.MatchStatusCompleted, CorrectionReason: "The other team withdrew", + SubResults: []state.SubMatchResult{wrBout1("M")}, + }) + require.NoError(t, err) + + m := wrPoolMatch(t, store, compID) + assert.Equal(t, "kiken-voluntary", m.Decision) + assert.Equal(t, wrTeamA, m.Winner, "the withdrawal now names Tora") + assert.True(t, wrEligible(t, store, compID, wrTeamAID), "Ryu never withdrew, so Ryu is eligible again") + assert.False(t, wrEligible(t, store, compID, wrTeamBID), "Tora withdrew") + if name == "RecordMatchResultWithIneligibility" { + assert.Equal(t, wrTeamAID, restored, "the restore is returned for the broadcast, as on /decision") + } + }) + } +} diff --git a/internal/engine/withdrawal_ruling_kept_test.go b/internal/engine/withdrawal_ruling_kept_test.go new file mode 100644 index 000000000..67a51dec1 --- /dev/null +++ b/internal/engine/withdrawal_ruling_kept_test.go @@ -0,0 +1,457 @@ +package engine + +// bc-tmfn operator ruling 2026-09-24: "Save correction should just save what +// the operator enters." A match ended by a match-level withdrawal (kiken, +// fusenpai) is corrected through a score sheet, which says nothing about the +// withdrawal: the team sheet sends the bouts, the individual sheet the +// scoreline. What the operator entered must be saved and the recorded ruling, +// with every consequence it had, must stay exactly as recorded. +// preserveWithdrawalRuling is the owner; these pin it on both branches and +// pin the writes it must leave alone. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + wrTeamA = "Ryu" + wrTeamB = "Tora" + wrTeamC = "Kuma" + wrTeamAID = "11111111-1111-4111-8111-111111111111" + wrTeamBID = "22222222-2222-4222-8222-222222222222" + wrTeamCID = "33333333-3333-4333-8333-333333333333" +) + +// wrBout1 is bout 1, which Ryu's fighter won by `waza`. +func wrBout1(waza string) state.SubMatchResult { + return state.SubMatchResult{Position: 1, SideA: "r1", SideB: "t1", Winner: "r1", IpponsA: []string{waza}} +} + +// wrCorrection is what the team editor sends on Save correction: the sheet's +// bouts, match-level ippons [] and a winner DERIVED FROM THE BOUTS (Ryu, the +// team that withdrew), no decision. +func wrCorrection(id string) *state.MatchResult { + return &state.MatchResult{ + ID: id, SideA: wrTeamA, SideB: wrTeamB, + Winner: wrTeamA, IpponsA: []string{}, IpponsB: []string{}, + Status: state.MatchStatusCompleted, CorrectionReason: "Scoring error: wrong waza entered", + SubResults: []state.SubMatchResult{wrBout1("K")}, + } +} + +func wrSaveTeams(t *testing.T, store *state.Store, compID string) { + t.Helper() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: wrTeamAID, Name: wrTeamA, Dojo: "DojoR"}, + {ID: wrTeamBID, Name: wrTeamB, Dojo: "DojoT"}, + {ID: wrTeamCID, Name: wrTeamC, Dojo: "DojoK"}, + })) +} + +// seedPoolWithdrawal builds a team league with Ryu v Tora running, bout 1 +// fought, then Ryu (aka) withdraws through the real decision path. +func seedPoolWithdrawal(t *testing.T, decision string) (*Engine, *state.Store, string, string) { + t.Helper() + eng, store, dir := setupTestEngine(t) + const compID = "wr-pool" + createTestCompetition(t, store, compID, "league", 3, func(c *state.Competition) { + c.Kind = "team" + c.TeamSize = 3 + }) + wrSaveTeams(t, store, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{wrBout1("M")}, + }})) + _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "knee", nil, false) + require.NoError(t, err) + return eng, store, compID, dir +} + +func wrPoolMatch(t *testing.T, store *state.Store, compID string) state.MatchResult { + t.Helper() + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range ms { + if m.ID == "Pool A-0" { + return m + } + } + t.Fatalf("Pool A-0 not found") + return state.MatchResult{} +} + +func readStatusFile(t *testing.T, dir, compID string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, "competitions", compID, "competitor-status.yaml")) + require.NoError(t, err) + return b +} + +// assertRulingKept compares every field of the inherited unit. +func assertRulingKept(t *testing.T, before, after withdrawalRuling) { + t.Helper() + assert.Equal(t, before.Decision, after.Decision, "Decision") + assert.Equal(t, before.DecisionBy, after.DecisionBy, "DecisionBy") + assert.Equal(t, before.DecisionReason, after.DecisionReason, "DecisionReason") + assert.Equal(t, before.Winner, after.Winner, "Winner") + assert.Equal(t, before.WinnerID, after.WinnerID, "WinnerID") + assert.Equal(t, before.IpponsA, after.IpponsA, "IpponsA") + assert.Equal(t, before.IpponsB, after.IpponsB, "IpponsB") + assert.Equal(t, before.Encho, after.Encho, "Encho") +} + +func TestWithdrawalRulingKept_PoolCorrection(t *testing.T) { + for _, decision := range []string{"kiken-voluntary", "kiken-injury", "fusenpai"} { + t.Run(decision, func(t *testing.T) { + eng, store, compID, dir := seedPoolWithdrawal(t, decision) + before := wrPoolMatch(t, store, compID) + require.Equal(t, wrTeamB, before.Winner, "Ryu withdrew, so Tora won") + statusBefore := readStatusFile(t, dir, compID) + + status, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", wrCorrection("Pool A-0")) + require.NoError(t, err) + assert.Nil(t, status, "no status change to broadcast: nobody changed the ruling") + + after := wrPoolMatch(t, store, compID) + assertRulingKept(t, rulingOfMatch(&before), rulingOfMatch(&after)) + assert.Equal(t, domain.DefaultWinIppons(false), after.IpponsB, "the winner's maru survives") + // bc-tmfn follow-up: seedPoolWithdrawal's own kiken already padded + // bouts 2 and 3 (TeamSize 3, only bout 1 was ever fought), and this + // correction only touches bout 1, so the padded rows persist. + require.Len(t, after.SubResults, 3) + assert.Equal(t, []string{"K"}, after.SubResults[0].IpponsA, "the bout edit is saved") + assert.Equal(t, 2, after.SubResults[1].Position) + assert.False(t, after.SubResults[1].HasResult(), "bout 2 was never fought; stays a padded empty row") + assert.Equal(t, 3, after.SubResults[2].Position) + assert.False(t, after.SubResults[2].HasResult(), "bout 3 was never fought; stays a padded empty row") + assert.Equal(t, "Scoring error: wrong waza entered", after.CorrectionReason) + assert.Equal(t, string(statusBefore), string(readStatusFile(t, dir, compID)), + "the eligibility record is byte-for-byte unchanged, RecordedAt included") + }) + } +} + +// RecordMatchResult (writeMatchResult) is the other door with an eligibility +// side effect; it skips it on the same report. +func TestWithdrawalRulingKept_RecordMatchResultDoor(t *testing.T) { + eng, store, compID, dir := seedPoolWithdrawal(t, "kiken-voluntary") + before := wrPoolMatch(t, store, compID) + statusBefore := readStatusFile(t, dir, compID) + + require.NoError(t, eng.RecordMatchResult(compID, "Pool A-0", wrCorrection("Pool A-0"))) + + after := wrPoolMatch(t, store, compID) + assertRulingKept(t, rulingOfMatch(&before), rulingOfMatch(&after)) + assert.Equal(t, string(statusBefore), string(readStatusFile(t, dir, compID))) +} + +// A kiken-injury competitor reinstated by a doctor stays reinstated when the +// bouts of the match they withdrew from are corrected. +func TestWithdrawalRulingKept_ReinstatementSurvivesCorrection(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, "kiken-injury") + _, err := eng.ReinstateCompetitor(compID, wrTeamAID) + require.NoError(t, err) + + _, err = eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", wrCorrection("Pool A-0")) + require.NoError(t, err) + + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[wrTeamAID].Eligible, "the correction must not undo the reinstatement") +} + +// The individual editor's write carries no bout rows: it states the match WAS +// fought, so it replaces the ruling exactly as before. A team competition's +// -TB- rep bout is a single bout and follows the same rule. +func TestWithdrawalRulingKept_SingleBoutCorrectionKeepsTheRuling(t *testing.T) { + for _, tc := range []struct { + name, matchID string + team bool + decision string + }{ + {name: "individual match", matchID: "Pool A-0"}, + {name: "individual match, draw toggle on", matchID: "Pool A-0", decision: "hikiwake"}, + {name: "tiebreaker rep bout in a team competition", matchID: "Pool A-TB-1", team: true}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, dir := setupTestEngine(t) + const compID = "wr-ind" + createTestCompetition(t, store, compID, "league", 3, func(c *state.Competition) { + if tc.team { + c.Kind = "team" + c.TeamSize = 3 + } + }) + wrSaveTeams(t, store, compID) + // Ryu (aka) had struck a men and Tora a kote when Ryu withdrew. + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: tc.matchID, SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + IpponsA: []string{"M"}, IpponsB: []string{"K"}, + Status: state.MatchStatusRunning, + }})) + _, _, err := eng.RecordDecision(compID, tc.matchID, "kiken-voluntary", "aka", "knee", nil, false) + require.NoError(t, err) + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + before := ms[0] + require.Equal(t, []string{"M"}, before.IpponsA, "precondition: the withdrawing side keeps what it struck (FIK Art. 32)") + require.Equal(t, domain.DefaultWinIppons(false), before.IpponsB, "precondition: the winner holds the maru") + statusBefore := readStatusFile(t, dir, compID) + + // The operator fixes Ryu's letter (it was a do, not a men). The sheet + // echoes the maru back on Tora's side and says nothing about the + // withdrawal: decision "" (or "hikiwake" with the draw toggle). + status, err := eng.RecordMatchResultWithIneligibility(compID, tc.matchID, &state.MatchResult{ + ID: tc.matchID, SideA: wrTeamA, SideB: wrTeamB, Winner: wrTeamB, Decision: tc.decision, + IpponsA: []string{"D"}, IpponsB: []string{"○", "○"}, + Status: state.MatchStatusCompleted, CorrectionReason: "Scoring error: it was a do", + }) + require.NoError(t, err) + assert.Nil(t, status, "no status change to broadcast: nobody changed the ruling") + + ms, err = store.LoadPoolMatches(compID) + require.NoError(t, err) + after := ms[0] + assert.Equal(t, "kiken-voluntary", after.Decision, "the withdrawal is kept") + assert.Equal(t, "aka", after.DecisionBy) + assert.Equal(t, wrTeamB, after.Winner) + assert.Equal(t, wrTeamBID, after.WinnerID) + assert.Equal(t, []string{"D"}, after.IpponsA, "the withdrawing side's letters are what the operator entered") + assert.Equal(t, domain.DefaultWinIppons(false), after.IpponsB, "the winner's maru is the ruling and stays") + assert.Equal(t, string(statusBefore), string(readStatusFile(t, dir, compID)), + "the eligibility record is byte-for-byte unchanged") + }) + } +} + +// seedBracketWithdrawal builds a team knockout: Ryu v Tora in round 1 (bout 1 +// fought, then Ryu withdraws), feeding Tora into the final against Kuma, +// which Tora has since WON. The correction's bout-derived winner (Ryu) differs +// from the recorded one, so a correction that failed to keep the ruling would +// meet the downstream guard. +func seedBracketWithdrawal(t *testing.T, bronze bool) (*Engine, *state.Store, string, string, string) { + t.Helper() + eng, store, dir := setupTestEngine(t) + const compID = "wr-ko" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "wr", Kind: "team", TeamSize: 3, Status: state.CompStatusKnockout, + })) + wrSaveTeams(t, store, compID) + matchID := "m-r1-0" + if bronze { + matchID = "m-bronze" + } + r1 := state.BracketMatch{ID: matchID, SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{wrBout1("M")}} + b := &state.Bracket{Rounds: [][]state.BracketMatch{{r1}, {{ID: "m-r2-0", SideB: wrTeamC, SideBID: wrTeamCID}}}} + if bronze { + b = &state.Bracket{ + Rounds: [][]state.BracketMatch{{{ID: "m-r1-0", SideA: wrTeamC, SideB: "Other", Status: state.MatchStatusCompleted, Winner: wrTeamC}}}, + ThirdPlaceMatch: &r1, + } + } + require.NoError(t, store.SaveBracket(compID, b)) + _, _, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "aka", "knee", nil, false) + require.NoError(t, err) + if !bronze { + _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r2-0", &state.MatchResult{ + ID: "m-r2-0", SideA: wrTeamB, SideB: wrTeamC, Winner: wrTeamB, Status: state.MatchStatusCompleted, + SubResults: []state.SubMatchResult{{Position: 1, SideA: "t1", SideB: "k1", Winner: "t1", IpponsA: []string{"M"}}}, + }) + require.NoError(t, err) + } + return eng, store, compID, dir, matchID +} + +func wrBracketMatch(t *testing.T, store *state.Store, compID string, bronze bool) state.BracketMatch { + t.Helper() + b, err := store.LoadBracket(compID) + require.NoError(t, err) + if bronze { + return *b.ThirdPlaceMatch + } + return b.Rounds[0][0] +} + +func TestWithdrawalRulingKept_BracketCorrection(t *testing.T) { + for _, bronze := range []bool{false, true} { + name := "round" + if bronze { + name = "bronze" + } + t.Run(name, func(t *testing.T) { + eng, store, compID, dir, matchID := seedBracketWithdrawal(t, bronze) + before := wrBracketMatch(t, store, compID, bronze) + require.Equal(t, wrTeamB, before.Winner) + finalBefore, err := store.LoadBracket(compID) + require.NoError(t, err) + statusBefore := readStatusFile(t, dir, compID) + + var reopened []ReopenedMatch + err = inTx(t, store, compID, func(tx state.StoreTx) error { + status, e := eng.RecordMatchResultWithIneligibilityTx(tx, compID, matchID, wrCorrection(matchID), ForceOptions{Reopened: &reopened}) + assert.Nil(t, status) + return e + }) + require.NoError(t, err, "a correction that keeps the winner must not meet the downstream guard") + assert.Empty(t, reopened) + + after := wrBracketMatch(t, store, compID, bronze) + assertRulingKept(t, rulingOfBracketMatch(&before), rulingOfBracketMatch(&after)) + // bc-tmfn follow-up: seedBracketWithdrawal's own kiken already + // padded bouts 2 and 3 (TeamSize 3, only bout 1 was ever fought), + // and this correction only touches bout 1, so the padded rows + // persist. + require.Len(t, after.SubResults, 3) + assert.Equal(t, []string{"K"}, after.SubResults[0].IpponsA, "the bout edit is saved") + assert.False(t, after.SubResults[1].HasResult(), "bout 2 was never fought; stays a padded empty row") + assert.False(t, after.SubResults[2].HasResult(), "bout 3 was never fought; stays a padded empty row") + assert.Equal(t, "Scoring error: wrong waza entered", after.CorrectionReason) + assert.Equal(t, string(statusBefore), string(readStatusFile(t, dir, compID))) + if !bronze { + b, lerr := store.LoadBracket(compID) + require.NoError(t, lerr) + assert.Equal(t, finalBefore.Rounds[1][0], b.Rounds[1][0], "the final Tora won is untouched") + } + }) + } +} + +// KeepsWithdrawalRuling: every clause is load-bearing. +func TestKeepsWithdrawalRuling(t *testing.T) { + correction := func(mut func(*state.MatchResult)) *state.MatchResult { + r := &state.MatchResult{Status: state.MatchStatusCompleted, SubResults: []state.SubMatchResult{wrBout1("M")}} + if mut != nil { + mut(r) + } + return r + } + cases := []struct { + name string + storedStatus state.MatchStatus + storedDecision string + incoming *state.MatchResult + want bool + }{ + {"bout correction over a kiken", state.MatchStatusCompleted, "kiken-voluntary", correction(nil), true}, + {"over a legacy kiken", state.MatchStatusCompleted, "kiken", correction(nil), true}, + {"over a fusenpai", state.MatchStatusCompleted, "fusenpai", correction(nil), true}, + {"a hikiwake-mapped sheet still keeps it", state.MatchStatusCompleted, "kiken-injury", correction(func(r *state.MatchResult) { r.Decision = "hikiwake" }), true}, + {"stored fought", state.MatchStatusCompleted, "", correction(nil), false}, + {"stored fusensho is kept exactly as a kiken/fusenpai (bc-tmfn follow-up: widened from IsWithdrawalDecisionStr to IsDefaultWinDecisionStr)", state.MatchStatusCompleted, "fusensho", correction(nil), true}, + {"stored not completed", state.MatchStatusRunning, "kiken-voluntary", correction(nil), false}, + {"incoming not completed", state.MatchStatusCompleted, "kiken-voluntary", correction(func(r *state.MatchResult) { r.Status = state.MatchStatusScheduled }), false}, + {"an individual correction (no bout rows) keeps it", state.MatchStatusCompleted, "kiken-voluntary", correction(func(r *state.MatchResult) { r.SubResults = nil }), true}, + {"an individual correction with the draw toggle keeps it", state.MatchStatusCompleted, "fusenpai", correction(func(r *state.MatchResult) { + r.SubResults = nil + r.Decision = "hikiwake" + }), true}, + {"incoming is a re-decision", state.MatchStatusCompleted, "kiken-voluntary", correction(func(r *state.MatchResult) { r.Decision = "fusenpai" }), false}, + // The allowlist: every decision a score sheet cannot send replaces the + // ruling. fusensho and daihyosen arrive from POST /decision with the + // prior bout rows attached (preserveLoserScore), which a denylist kept. + {"incoming fusensho replaces it", state.MatchStatusCompleted, "kiken-voluntary", correction(func(r *state.MatchResult) { r.Decision = "fusensho" }), false}, + {"incoming daihyosen replaces it", state.MatchStatusCompleted, "kiken-injury", correction(func(r *state.MatchResult) { r.Decision = "daihyosen" }), false}, + {"incoming kachinuki-exhaustion replaces it", state.MatchStatusCompleted, "fusenpai", correction(func(r *state.MatchResult) { r.Decision = "kachinuki-exhaustion" }), false}, + {"incoming fought replaces it", state.MatchStatusCompleted, "kiken-voluntary", correction(func(r *state.MatchResult) { r.Decision = "fought" }), false}, + {"incoming ippon-shobu replaces it", state.MatchStatusCompleted, "kiken-voluntary", correction(func(r *state.MatchResult) { r.Decision = "ippon-shobu" }), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, KeepsWithdrawalRuling(tc.storedStatus, tc.storedDecision, tc.incoming)) + }) + } +} + +// keptWithdrawalScoreline: the winner's maru is the ruling's, the withdrawing +// side's letters are the write's (through struckIppons), except where the +// write is a team sheet, the ruling names no side, or the write omits the side. +func TestKeptWithdrawalScoreline(t *testing.T) { + maru := domain.DefaultWinIppons(false) + stored := func(by string) withdrawalRuling { + if by == "shiro" { + return withdrawalRuling{DecisionBy: by, IpponsA: maru, IpponsB: []string{"M"}} + } + return withdrawalRuling{DecisionBy: by, IpponsA: []string{"M"}, IpponsB: maru} + } + for _, tc := range []struct { + name string + stored withdrawalRuling + incoming *state.MatchResult + wantA, wantB []string + }{ + {"aka withdrew: A from the write, B the maru", + stored("aka"), &state.MatchResult{IpponsA: []string{"D"}, IpponsB: []string{"K"}}, + []string{"D"}, maru}, + {"shiro withdrew: B from the write, A the maru", + stored("shiro"), &state.MatchResult{IpponsA: []string{"K"}, IpponsB: []string{"K", "D"}}, + maru, []string{"K", "D"}}, + {"an echoed maru or hantei mark is not a strike", + stored("aka"), &state.MatchResult{IpponsA: []string{"○", domain.HanteiMark, "M"}}, + []string{"M"}, maru}, + {"an explicit [] clears the letters", + stored("aka"), &state.MatchResult{IpponsA: []string{}}, + nil, maru}, + {"an omitted side keeps the stored letters", + stored("aka"), &state.MatchResult{}, + []string{"M"}, maru}, + {"a team sheet keeps the stored scoreline", + stored("aka"), &state.MatchResult{IpponsA: []string{}, SubResults: []state.SubMatchResult{wrBout1("M")}}, + []string{"M"}, maru}, + {"a ruling with no side keeps the stored scoreline", + stored(""), &state.MatchResult{IpponsA: []string{"D"}}, + []string{"M"}, maru}, + } { + t.Run(tc.name, func(t *testing.T) { + a, b := keptWithdrawalScoreline(tc.stored, tc.incoming) + assert.Equal(t, tc.wantA, a) + assert.Equal(t, tc.wantB, b) + }) + } +} + +// A restore replays a trusted snapshot and inherits nothing. +func TestPreserveWithdrawalRuling_RestoreInheritsNothing(t *testing.T) { + stored := withdrawalRuling{Status: state.MatchStatusCompleted, Decision: "kiken-voluntary", Winner: wrTeamB} + result := &state.MatchResult{Status: state.MatchStatusCompleted, Winner: wrTeamA, SubResults: []state.SubMatchResult{wrBout1("M")}} + assert.False(t, preserveWithdrawalRuling(stored, result, matchWriteRestore)) + assert.Equal(t, wrTeamA, result.Winner) + assert.True(t, preserveWithdrawalRuling(stored, result, matchWriteForward)) + assert.Equal(t, wrTeamB, result.Winner) +} + +// Recovery path (1): a withdrawal recorded against the wrong team is fixed by +// re-deciding it for the other side (POST .../decision), which this change +// does not touch: the bouts fought before it survive (preserveLoserScore), +// the other team becomes the winner, and the wrongly withdrawn team is +// eligible again (RecordDecisionTx's MatchID-keyed restore), including after +// a bout correction has been saved in between. +func TestWithdrawalRulingKept_OtherSideStillFixesAWrongWithdrawal(t *testing.T) { + eng, store, compID, _ := seedPoolWithdrawal(t, "kiken-voluntary") + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", wrCorrection("Pool A-0")) + require.NoError(t, err) + + _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "wrong team", nil, false) + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, wrTeamAID, status.PlayerID) + assert.True(t, status.Eligible, "the wrongly withdrawn team is eligible again") + + m := wrPoolMatch(t, store, compID) + assert.Equal(t, wrTeamA, m.Winner) + assert.Equal(t, "shiro", m.DecisionBy) + // bc-tmfn follow-up: the original kiken already padded bouts 2 and 3. + require.Len(t, m.SubResults, 3) + assert.Equal(t, []string{"K"}, m.SubResults[0].IpponsA, "the corrected bout survives the re-decision") + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.False(t, statuses[wrTeamBID].Eligible, "the team that actually withdrew is now the ineligible one") +} diff --git a/internal/export/builder.go b/internal/export/builder.go index 1421b664b..b099740db 100644 --- a/internal/export/builder.go +++ b/internal/export/builder.go @@ -465,8 +465,12 @@ func overlayTeamPoolScores(f *excelize.File, pools []helper.Pool, resultByID map writeTeamSummaryCells(f, sheetName, courtStartCol, summaryExcelRow, mr, mirror) // Sub-match rows start two rows below the Red header (1-based). + // credit: same call writeTeamSummaryCells makes internally, so + // the summary row and the bout rows below agree on which side a + // default-win ruling credits. subStartExcelRow := rowIdx + 3 - writeTeamSubMatchScores(f, sheetName, courtStartCol, subStartExcelRow, mr.SubResults, teamSize, mirror, mr.SideA, mr.SideB) + credit := state.DefaultWinCreditSide(mr.Status, mr.Decision, mr.DecisionBy, mr.Attribution()) + writeTeamSubMatchScores(f, sheetName, courtStartCol, subStartExcelRow, mr.SubResults, teamSize, mirror, mr.SideA, mr.SideB, credit) } } @@ -513,12 +517,18 @@ func writeTeamSummaryCells(f *excelize.File, sheetName string, courtStartCol, ex rPCol := colNum(courtStartCol + 4) rVCol := colNum(courtStartCol + 5) - lMark, rMark := SideMarksLR(mr.Decision, mr.HanteiDecided(), domain.WinnerAttribution{ - WinnerID: mr.WinnerID, SideAID: mr.SideAID, SideBID: mr.SideBID, - Winner: mr.Winner, SideA: mr.SideA, SideB: mr.SideB, - }, mirror) - - line := state.TeamResultFrom(mr.SubResults, mr.SideA, mr.SideB) + att := mr.Attribution() + lMark, rMark := SideMarksLR(mr.Decision, mr.HanteiDecided(), att, mirror) + + // credit is state.DefaultWinCreditSide's answer for THIS match: which + // side (if any) a default-win ruling (kiken/fusenpai/fusensho) closing + // it credits every unfought numbered bout to. TeamResultFrom routes it + // through SubBoutEffectiveResult per bout, so a match decided before + // every bout was scored still shows the default-win maru for the + // credited side's IV/PW, the same way a played bout with its own + // per-bout fusensho decision already does. + credit := state.DefaultWinCreditSide(mr.Status, mr.Decision, mr.DecisionBy, att) + line := state.TeamResultFrom(mr.SubResults, mr.SideA, mr.SideB, credit) if line != nil { // SideA = Aka, SideB = Shiro. Left is Aka unless mirror. leftIV, leftPW := line.AkaIV, line.AkaPW @@ -531,8 +541,15 @@ func writeTeamSummaryCells(f *excelize.File, sheetName string, courtStartCol, ex setIVCellWithMark(f, sheetName, rVCol, excelRow, rightIV, rMark) setIntCellDirect(f, sheetName, rPCol, excelRow, rightPW) } else { - // No summary line (e.g. a forfeit before any bout was fought): - // the result marks still need a home in the competitor's cell. + // No summary line: mr.SubResults is genuinely empty (0 rows). Since + // a completed default-win team match is now padded with one row per + // numbered position (state.PadDefaultWinBoutPositions, applied both + // at write time and by the legacy-load repair), this is no longer + // the ordinary "forfeit before any bout was fought" case it used to + // be -- it is now the residual fallback for what padding does not + // reach: a bye, a daihyosen/tiebreaker row, or a match this view's + // SubResults were never threaded through at all. The result marks + // still need a home in the competitor's cell. if lMark != "" { setCellStr(f, sheetName, lVCol, excelRow, lMark) } @@ -597,9 +614,19 @@ func bracketMatchResultView(bm *state.BracketMatch) state.MatchResult { // the app decided it by id. A row that carries no id (a bye, an // unresolved feeder, an unrepaired legacy row) passes "" and takes // the name path exactly as before. - SideAID: bm.SideAID, - SideBID: bm.SideBID, - WinnerID: bm.WinnerID, + SideAID: bm.SideAID, + SideBID: bm.SideBID, + WinnerID: bm.WinnerID, + // Status and DecisionBy travel too (bc-tmfn follow-up): + // state.DefaultWinCreditSide (called from writeTeamSummaryCells) + // needs both to decide whether a default-win ruling credits an + // unfought numbered bout, and a bracket match's own Status/ + // DecisionBy are what that decision rests on -- omitting them left + // Status at its zero value ("", never MatchStatusCompleted), so a + // completed bracket kiken/fusenpai/fusensho could never be credited + // through this projection. + Status: bm.Status, + DecisionBy: bm.DecisionBy, Decision: bm.Decision, Encho: bm.Encho, SubResults: bm.SubResults, @@ -638,7 +665,16 @@ func setIVCellWithMark(f *excelize.File, sheetName, col string, row, iv int, mar // this same sheet's IV/PW summary still counts the bout. state.SubBoutWinnerSide // and the JS twin subWinnerSides both carry the same match-level arms; this is // the third reader of that rule and it was the one without them. -func writeTeamSubMatchScores(f *excelize.File, sheetName string, courtStartCol, subStartExcelRow int, subResults []state.SubMatchResult, teamSize int, mirror bool, matchSideA, matchSideB string) { +// credit is state.DefaultWinCreditSide's answer for the whole match (the +// caller computes it once, shared with writeTeamSummaryCells so the summary +// row and this sheet's bout rows never disagree about which side a +// default-win ruling credits): a numbered bout with no result of its own is +// routed through state.SubBoutEffectiveResult, which shows the FIK +// default-win maru for that side without a per-row Kiken/Fus. mark -- the +// mark names the ONE competitor who withdrew and already rides the summary +// row's IV cell. domain.MatchSideNone reproduces the pre-credit behaviour +// exactly (an unfought bout's cells stay blank). +func writeTeamSubMatchScores(f *excelize.File, sheetName string, courtStartCol, subStartExcelRow int, subResults []state.SubMatchResult, teamSize int, mirror bool, matchSideA, matchSideB string, credit domain.MatchSide) { lVCol := colNum(courtStartCol + 1) rVCol := colNum(courtStartCol + 5) @@ -646,6 +682,14 @@ func writeTeamSubMatchScores(f *excelize.File, sheetName string, courtStartCol, if sub.Position <= 0 || sub.Position > teamSize { continue // skip daihyosen placeholder / unpositioned / out-of-range rows } + // state.SubBoutEffectiveResult substitutes the FIK default-win maru + // (Winner set to the credited side's team name, IpponsA/IpponsB the + // maru) for a bout with no result of its own on a match a + // default-win ruling closed; unchanged otherwise (a fought bout, or + // credit == domain.MatchSideNone). Decision is deliberately left + // untouched by that substitution, so this row draws no Kiken/Fus. + // mark -- only the match-level summary row names the withdrawer. + sub = state.SubBoutEffectiveResult(sub, credit, matchSideA, matchSideB) // Sub-match row for Position P is the P-th sub row (1-based Position). excelRow := subStartExcelRow + (sub.Position - 1) @@ -1081,8 +1125,13 @@ func overlayTeamBracketScores(f *excelize.File, bracketByNum map[int]state.Brack } // Sub-match ippon letters: Position p sits at H+2+p, i.e. the sub - // rows start at H+3. Same writer as the pool sheet. - writeTeamSubMatchScores(f, sheetName, courtStartCol, headerExcelRow+3, bm.SubResults, teamSize, mirror, bm.SideA, bm.SideB) + // rows start at H+3. Same writer as the pool sheet. credit is + // computed straight off bm (not the bracketMatchResultView below, + // which exists for writeTeamSummaryCells' state.MatchResult + // param) but is the SAME answer: both read Status/Decision/ + // DecisionBy/Attribution off this one bm. + credit := state.DefaultWinCreditSide(bm.Status, bm.Decision, bm.DecisionBy, bm.Attribution()) + writeTeamSubMatchScores(f, sheetName, courtStartCol, headerExcelRow+3, bm.SubResults, teamSize, mirror, bm.SideA, bm.SideB, credit) // IV/PW summary row = H + 5 + teamSize. Route through the shared // pool-sheet writer so the IV-mark contract (and the forfeit diff --git a/internal/export/builder_test.go b/internal/export/builder_test.go index ae3117cb1..a3d733472 100644 --- a/internal/export/builder_test.go +++ b/internal/export/builder_test.go @@ -2459,7 +2459,7 @@ func TestScoreCellsCarryOutstandingHansokuTriangle(t *testing.T) { {Position: 1, SideA: "Ann", SideB: "Ben", Winner: "Ann", IpponsA: []string{"M", "K"}, HansokuB: 1}, } - writeTeamSubMatchScores(f, sheet, 1, 5, subs, 3, false, "", "") + writeTeamSubMatchScores(f, sheet, 1, 5, subs, 3, false, "", "", domain.MatchSideNone) left, _ := f.GetCellValue(sheet, "B5") right, _ := f.GetCellValue(sheet, "F5") assert.Equal(t, "MK", left) @@ -2481,11 +2481,31 @@ func TestScoreCellsCarryOutstandingHansokuTriangle(t *testing.T) { subs := []state.SubMatchResult{ {Position: 1, SideA: "", SideB: "", Winner: "Tora A", Decision: "fusensho"}, } - writeTeamSubMatchScores(f, sheet, 1, 5, subs, 3, false, "Tora A", "Kenshi B") + writeTeamSubMatchScores(f, sheet, 1, 5, subs, 3, false, "Tora A", "Kenshi B", domain.MatchSideNone) left, _ := f.GetCellValue(sheet, "B5") assert.Contains(t, left, "Fus.", "the no-show mark must name the winning side") assert.Contains(t, left, "○", "and the default win must still award its maru") }) + + // bc-tmfn follow-up: a bout with NO result of its own (padded, + // state.PadDefaultWinBoutPositions) on a match a default-win ruling + // closed shows the maru via the credit parameter, but -- unlike the + // fixed-order fusensho row above, whose OWN Decision drives the mark -- + // carries no per-row Kiken/Fus. mark: that mark names the withdrawer and + // already rides the match-level summary row (writeTeamSummaryCells). + t.Run("credited unfought row: maru with no per-row mark", func(t *testing.T) { + f := excelize.NewFile() + defer f.Close() + sheet := helper.SheetPoolMatches + f.NewSheet(sheet) + + subs := []state.SubMatchResult{{Position: 1}} + writeTeamSubMatchScores(f, sheet, 1, 5, subs, 3, false, "Tora A", "Kenshi B", domain.MatchSideA) + left, _ := f.GetCellValue(sheet, "B5") + right, _ := f.GetCellValue(sheet, "F5") + assert.Equal(t, "○○", left, "the credited side's maru, and nothing else") + assert.Empty(t, right, "the other side's cell stays empty") + }) } // TestWriteTeamSubMatchScores_OutOfRangePositionSkipped verifies the upper-bound @@ -2506,7 +2526,7 @@ func TestWriteTeamSubMatchScores_OutOfRangePositionSkipped(t *testing.T) { {Position: 3, IpponsA: []string{"K"}}, {Position: 9, IpponsA: []string{"D"}}, // corrupted: > teamSize } - writeTeamSubMatchScores(f, sheet, courtStartCol, subStartRow, subs, teamSize, false, "", "") + writeTeamSubMatchScores(f, sheet, courtStartCol, subStartRow, subs, teamSize, false, "", "", domain.MatchSideNone) // Position 1 -> row 5, Position 3 -> row 7 (both written). v1, _ := f.GetCellValue(sheet, "B5") diff --git a/internal/export/team_bracket_submatch_cells_test.go b/internal/export/team_bracket_submatch_cells_test.go index 01fd0373c..4a7cb8914 100644 --- a/internal/export/team_bracket_submatch_cells_test.go +++ b/internal/export/team_bracket_submatch_cells_test.go @@ -156,3 +156,122 @@ func TestBuildResultsWorkbook_TeamBracketSubMatchScoresLandInCorrectCells(t *tes // SubResults) must stay untouched by either write. assert.Empty(t, cellAt(lCol, headerExcelRow+4), "no stray content between the two populated sub-bout rows") } + +// TestBuildResultsWorkbook_TeamBracketDefaultWinCreditedBoutShowsMaruNoMark +// pins the bc-tmfn follow-up export contract: a bracket team match a +// default-win ruling (kiken/fusenpai/fusensho) closes before every numbered +// bout has a result shows the FIK default-win maru on each credited bout row +// (state.SubBoutEffectiveResult), but carries NO per-row Kiken mark there -- +// the mark names the ONE competitor who withdrew and already rides the +// match-level summary row's IV cell (operator ruling; see +// writeTeamSubMatchScores' and writeTeamSummaryCells' doc comments). +func TestBuildResultsWorkbook_TeamBracketDefaultWinCreditedBoutShowsMaruNoMark(t *testing.T) { + t.Parallel() + dir, store, eng, compID := testSetup(t) + defer os.RemoveAll(dir) + + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + comp.Kind = "team" + comp.TeamSize = 3 + comp.Format = state.CompFormatMixed + require.NoError(t, store.SaveCompetition(comp)) + + pools := makeTeamPools() + require.NoError(t, store.SavePools(compID, pools)) + poolResults := []state.MatchResult{ + { + ID: "Pool A-0", SideA: "Red A", SideAID: "Red A", SideB: "Blue A", SideBID: "Blue A", + Status: state.MatchStatusCompleted, Winner: "Red A", WinnerID: "Red A", + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "Red A", SideB: "Blue A", IpponsA: []string{"M", "K"}, Winner: "Red A"}, + {Position: 2, SideA: "Red A", SideB: "Blue A", IpponsB: []string{"M"}, Winner: "Blue A"}, + {Position: 3, SideA: "Red A", SideB: "Blue A", IpponsA: []string{"D"}, Winner: "Red A"}, + }, + }, + { + ID: "Pool B-0", SideA: "Red B", SideAID: "Red B", SideB: "Blue B", SideBID: "Blue B", + Status: state.MatchStatusCompleted, Winner: "Red B", WinnerID: "Red B", + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "Red B", SideB: "Blue B", IpponsA: []string{"M"}, Winner: "Red B"}, + {Position: 2, SideA: "Red B", SideB: "Blue B", IpponsA: []string{"K"}, Winner: "Red B"}, + {Position: 3, SideA: "Red B", SideB: "Blue B", IpponsB: []string{"M"}, Winner: "Blue B"}, + }, + }, + } + require.NoError(t, store.SavePoolMatches(compID, poolResults)) + + // The final: Red B (bm.SideB) withdraws before any bout is fought. + // DecisionBy "shiro" credits Red A (aka = SideA) for all 3 bouts -- + // state.PadDefaultWinBoutPositions is what a real kiken write applies + // (pinned end to end at the engine level, TestDefaultWinBoutCredit_ + // BracketWritePads / _BracketTeamResult); this fixture builds the SAME + // post-write shape directly so the test stays scoped to export rendering. + bracket := &state.Bracket{ + Rounds: [][]state.BracketMatch{ + { + { + ID: "B1", SideA: "Red A", SideB: "Red B", Winner: "Red A", + Status: state.MatchStatusCompleted, MatchNumber: 1, + Decision: "kiken-voluntary", DecisionBy: "shiro", + SubResults: state.PadDefaultWinBoutPositions(nil, 3), + }, + }, + }, + } + require.NoError(t, store.SaveBracket(compID, bracket)) + + data, err := BuildResultsWorkbook(store, eng, compID) + require.NoError(t, err) + f, err := excelize.OpenReader(bytes.NewReader(data)) + require.NoError(t, err) + defer f.Close() + + rows, err := f.GetRows(helper.SheetEliminationMatches) + require.NoError(t, err) + + headerRow, headerCol := -1, -1 + for r, row := range rows { + for c, cellVal := range row { + if parseRoundMatchLabel(cellVal) == 1 { + headerRow, headerCol = r, c + } + } + if headerRow >= 0 { + break + } + } + require.GreaterOrEqual(t, headerRow, 0, "Elimination Matches sheet must contain the 'Round _ - Match 1' header") + + headerExcelRow := headerRow + 1 + courtStartCol := headerCol + 1 + lCol := colNum(courtStartCol + 1) + rCol := colNum(courtStartCol + 5) + + cellAt := func(col string, row int) string { + v, cellErr := f.GetCellValue(helper.SheetEliminationMatches, fmt.Sprintf("%s%d", col, row)) + require.NoError(t, cellErr) + return v + } + + // Bouts 1-3 (rows H+3, H+4, H+5): the maru on Red A's (left) column, no + // "Kiken" text anywhere on the row -- not even on Red B's (right, empty) + // column, which is the side the mark would name. + for pos := 1; pos <= 3; pos++ { + row := headerExcelRow + 2 + pos + left := cellAt(lCol, row) + right := cellAt(rCol, row) + assert.Equal(t, "○○", left, "bout %d: the default-win maru, no per-row mark", pos) + assert.Empty(t, right, "bout %d: the credited side's opponent column stays empty", pos) + assert.NotContains(t, left, "Kiken", "bout %d: no per-row Kiken mark", pos) + } + + // The summary row (H+5+teamSize=H+8) carries the ONE Kiken mark, on Red + // B's (right, the withdrawer) column, alongside Red A's IV=3 count with + // no mark on its own column. + summaryRow := headerExcelRow + 8 + leftSummary := cellAt(lCol, summaryRow) + rightSummary := cellAt(rCol, summaryRow) + assert.Equal(t, "3", leftSummary, "Red A: IV 3, no mark on the credited side") + assert.Contains(t, rightSummary, "Kiken", "Red B: the withdrawer's mark rides the summary row") +} diff --git a/internal/export/team_name_fallback_asymmetry_test.go b/internal/export/team_name_fallback_asymmetry_test.go index 0884dccf9..b558dace9 100644 --- a/internal/export/team_name_fallback_asymmetry_test.go +++ b/internal/export/team_name_fallback_asymmetry_test.go @@ -44,7 +44,7 @@ func TestTeamNameFallback_SheetAndStandingsDivergeOnANamedRow(t *testing.T) { defer f.Close() sheet := helper.SheetPoolMatches f.NewSheet(sheet) - writeTeamSubMatchScores(f, sheet, 1, 5, []state.SubMatchResult{drifted}, 3, false, "Tora A", "Kenshi B") + writeTeamSubMatchScores(f, sheet, 1, 5, []state.SubMatchResult{drifted}, 3, false, "Tora A", "Kenshi B", domain.MatchSideNone) left, err := f.GetCellValue(sheet, "B5") assert.NoError(t, err) @@ -65,7 +65,7 @@ func TestTeamNameFallback_SheetAndStandingsDivergeOnANamedRow(t *testing.T) { g := excelize.NewFile() defer g.Close() g.NewSheet(sheet) - writeTeamSubMatchScores(g, sheet, 1, 5, []state.SubMatchResult{silent}, 3, false, "Tora A", "Kenshi B") + writeTeamSubMatchScores(g, sheet, 1, 5, []state.SubMatchResult{silent}, 3, false, "Tora A", "Kenshi B", domain.MatchSideNone) marked, err := g.GetCellValue(sheet, "B5") assert.NoError(t, err) assert.Contains(t, marked, "Fus.", "control: a row naming no fighter DOES take the encounter's names") diff --git a/internal/helper/draw.go b/internal/helper/draw.go index 96f36623f..f795b2b7e 100644 --- a/internal/helper/draw.go +++ b/internal/helper/draw.go @@ -228,13 +228,11 @@ func NewKnockoutDraw(root *Node, numCourts int) *KnockoutDraw { if root == nil { return nil } - // Normalize through the slot codec so every knockout consumer sees ONE - // geometry. CreateBalancedTree gives a ragged roster a riseless tree whose - // shallow pairs classify a round late; the skeleton export rebuilds from - // the bracket's slots and gets the risen tree, which fights those pairs in - // round 1 as the reference sheets print (Node.risen). Without this, the - // CLI's printed rounds and the app export's disagree about the same draw. - // BuildSlotTree(SlotArray(x)) is idempotent on slot-built trees. + // Normalize through the slot codec so every knockout consumer holds the + // tree the app export rebuilds from the bracket's slots (EliminationDraw), + // rise marks included (Node.risenAfter). A CreateBalancedTree input keeps + // its shape; it only gains the marks. BuildSlotTree(SlotArray(x)) is + // idempotent on slot-built trees. root = BuildSlotTree(SlotArray(root)) if root == nil { return nil @@ -405,12 +403,12 @@ func byeRankClass(rank int) int { // buildBlock lays one block's occupants out inside its own subtree. // -// The layout is GREEDY (D4): the round-1 layer holds floor(q/2) real matches +// The layout is GREEDY (D4): the first slot layer holds floor(q/2) real pairs // and, when q is odd, exactly ONE named bye, which goes to the block's // highest-precedence occupant under R6. Every other empty slot pairs with // another empty slot into a phantom match that is dropped downstream and never -// printed. Deeper byes fall to whichever slot the phantom pairs leave and are -// taken by match WINNERS, not pools, so R6 does not allocate them. +// printed. A real pair beside a phantom pair is fought at its distance from +// the final, as the sheets print it; R6 places only the named bye. // // Concretely, q occupants fill a NextPow2(q) slot array as // @@ -419,9 +417,10 @@ func byeRankClass(rank int) int { // and the tail is padded with empty slots. The single inserted gap after the // bye occupant is what makes it the round-1 bye; everything after it pairs up // consecutively. For the EKC Junior Individual Male court A (q=5) that is -// [P1, "", P2, P3, P4, P5, "", ""]: P1 byes, P2 v P3, P4 v P5, and the round-2 -// bye falls to W(P4 v P5) rather than to P1 -- the ladder the sheet shows, and -// the one recursive halving provably cannot produce. +// [P1, "", P2, P3, P4, P5, "", ""]: P1 meets W(P2 v P3), and P4 v P5 is the +// other half of the court final -- the pairings the sheet prints (P2 v P3 +// alone in column 1, the other two bouts in column 2), and ones recursive +// halving provably cannot produce. // // The BLOCK is D4's unit, not the printable region. The two coincide at every // shiaijo count but one, which is why D4 was first written in terms of a @@ -432,9 +431,9 @@ func byeRankClass(rank int) int { // holds at most one of them, and from the 3rd on the blocks run out before the // quarters do (planBlocks). // -// Within a block the order interleaves the RANK groups, so a home 1st meets a -// crossed-in lower finisher in round 1 rather than another home 1st (EKC Junior -// Team Q1: P1#1 v P5#2, P2#1 v P6#2). +// Within a block the order interleaves the RANK groups, so a home 1st's first +// opponent is a crossed-in lower finisher rather than another home 1st (EKC +// Junior Team Q1: P1#1 v P5#2, P2#1 v P6#2). // // mirrored is the block's position in its half: false for the half's first // (outer-top) block, true for its second. It matters only to the R6(c) @@ -909,15 +908,12 @@ func BuildSlotTree(slots []string) *Node { mid := len(slots) / 2 left := BuildSlotTree(slots[:mid]) right := BuildSlotTree(slots[mid:]) - // A SLOT-level collapse marks the survivor as RISEN so round - // classification can put it back at the level it was built at (see - // Node.risen): without the mark, a phantom-risen match schedules a round - // late on every surface that reads rounds -- the Excel columns, the match - // numbers and the app's bracket -- where the sheet fights it in round 1. - // The mark belongs HERE and not in joinNodes: combine's assembly joins - // (a lone block against an empty half, say) collapse structure, not empty - // slots, and marking those lifted a 2-entrant draw's only match clean out - // of every round. + // A SLOT-level collapse marks the survivor as RISEN, on the side the + // empty half sat, so SlotArray can put that half back and the court + // geometry can count its slots (see Node.risenAfter); the mark does not + // move the survivor's round. It belongs HERE and not in joinNodes: + // combine's assembly joins (a lone block against an empty half, say) + // collapse structure, not empty slots. switch { case left == nil: if right != nil { @@ -1409,7 +1405,7 @@ func (d *KnockoutDraw) RegionSpans() [][2]int { // The BAND offset, not the content one: a region's span has to cover // every slot the region occupies, rise slots included, or the slots a // leading collapse left empty would be attributed to the NEXT court. - walkLeafOffsets(d.Root, 0, func(node *Node, bandAt, _, width int) { + walkLeafOffsets(d.Root, 0, func(node *Node, bandAt, width int) { if i, ok := index[node]; ok && i < len(spans) { spans[i] = [2]int{bandAt, bandAt + width} } @@ -1418,27 +1414,25 @@ func (d *KnockoutDraw) RegionSpans() [][2]int { } // walkLeafOffsets visits every node with its offset and width in -// TreeToLeafArray(root) -- the ONE traversal that turns the tree back into leaf +// SlotArray(root) -- the ONE traversal that turns the tree back into leaf // positions. The padding rule is not restated here, nor in leafArrayWidth: both -// defer to leafPadTarget (tree.go), so a change to TreeToLeafArray's geometry -// has one place to land. +// defer to leafPadTarget (tree.go), so a change to the padding has one place +// to land. // // Both readers of that geometry go through it. They must agree exactly: one // (RegionSpans) decides which slots a shiaijo's region owns, the other // (NodeCourts) decides which shiaijo a bout prints under, and a disagreement // puts the operator console and the printed running order on different courts // with nothing to catch it. Written once for the same reason leafPadTarget is. -// The two offsets are reported SEPARATELY because the two readers want -// different ones, and conflating them is how the risen-before geometry went -// wrong: a node owns the whole BAND [bandOffset, bandOffset+width), rises -// included, but its entrants sit in the CONTENT sub-range, which for a -// leading collapse starts partway in. RegionSpans tiles the leaf array and so -// needs the band (content-only spans would leave the rise slots owned by -// nobody); SlotRoundMatches locates a bout's first-round window and so needs -// the content. Passing the content offset with the band WIDTH, as this used -// to, gave RegionSpans a span running past the node's own slots, and so -// handed the first slots of the NEXT region to this court. -func walkLeafOffsets(n *Node, offset int, visit func(node *Node, bandOffset, contentOffset, width int)) { +// Each node is reported with its BAND [bandOffset, bandOffset+width), rises +// included: RegionSpans tiles the leaf array, and a content-only span would +// leave the rise slots owned by nobody. The node's entrants sit in the +// CONTENT sub-range, which for a leading collapse starts partway into the +// band, and that is where its children are walked from. Reporting the content +// offset with the band WIDTH, as this once did, gave RegionSpans a span +// running past the node's own slots, and so handed the first slots of the +// NEXT region to this court. +func walkLeafOffsets(n *Node, bandOffset int, visit func(node *Node, bandOffset, width int)) { if n == nil { return } @@ -1448,13 +1442,13 @@ func walkLeafOffsets(n *Node, offset int, visit func(node *Node, bandOffset, con // when it led, so offsets below stay slot-true (SlotArray is the same // reading; before- and after-rises never mix on one node in practice). content := width >> (n.risenAfter + n.risenBefore) - contentOffset := offset + contentOffset := bandOffset level := width for i := 0; i < n.risenBefore; i++ { level /= 2 contentOffset += level } - visit(n, offset, contentOffset, width) + visit(n, bandOffset, width) if n.LeafNode { return } @@ -1463,66 +1457,9 @@ func walkLeafOffsets(n *Node, offset int, visit func(node *Node, bandOffset, con walkLeafOffsets(n.Right, contentOffset+side, visit) } -// leafArrayWidth is len(TreeToLeafArray(n)) without building the slice. It -// measures with leafPadTarget, the same rule TreeToLeafArray builds with, so -// the two cannot disagree about where a region starts. -// SlotRoundMatch locates one bout of a draw in pow2-bracket terms: the slot -// offset and entrant width of its first-round window, plus the round the -// risen-aware walk (BuildEliminationMatchRounds) fights it in, 0-based from -// the first round. The engine uses this to stamp DisplayRound on its pow2 -// bracket matches: neither the pow2 row (which tail-pads an assembly-level -// late bout into round-1 adjacency) nor the feeder graph (which defers a -// phantom-risen pair the sheets fight in round 1) can tell those two shapes -// apart on their own -- the risen tree is the one place the distinction -// lives, so its walk is the one source of a bout's round. -type SlotRoundMatch struct { - Offset int - EntrantWidth int - Round int -} - -// SlotRoundMatches maps every bout of the draw tree through walkLeafOffsets' -// slot geometry. EntrantWidth is the bout's content width (its slot width -// with the rises stripped), i.e. the width of the pow2 round row the bout's -// entrants sit in. -func SlotRoundMatches(root *Node) []SlotRoundMatch { - type geo struct{ offset, content int } - geos := map[*Node]geo{} - // The CONTENT offset: a bout's first-round window is where its entrants - // actually sit, which for a leading collapse starts partway into the band. - walkLeafOffsets(root, 0, func(n *Node, _, contentAt, width int) { - geos[n] = geo{contentAt, width >> (n.risenAfter + n.risenBefore)} - }) - var out []SlotRoundMatch - for roundIdx, round := range BuildEliminationMatchRounds(root) { - for _, m := range round { - if g, ok := geos[m]; ok { - out = append(out, SlotRoundMatch{Offset: g.offset, EntrantWidth: g.content, Round: roundIdx}) - } - } - } - return out -} - -// slotDepth is the tree depth in SLOT levels: the depth CalculateDepth would -// report had no empty sibling ever been collapsed. The two differ exactly on -// trees whose top carries rises -- a split page holding one risen block, say -- -// where physical depth under-counts and would drop the block's bout from every -// round (its virtual level exceeds the physical target range). leafArrayWidth -// is always a power of two, so this is log2(width)+1 computed by bit length. -func slotDepth(n *Node) int { - if n == nil { - return 0 - } - w := leafArrayWidth(n) - d := 0 - for w > 0 { - d++ - w >>= 1 - } - return d -} - +// leafArrayWidth is len(SlotArray(n)) without building the slice: the same +// leafPadTarget padding, doubled once per rise, so the two cannot disagree +// about where a region starts. func leafArrayWidth(n *Node) int { if n == nil { return 0 @@ -1561,7 +1498,7 @@ func (d *KnockoutDraw) NodeCourts() map[*Node]int { out := make(map[*Node]int) // The BAND, matching RegionSpans above: the two are compared against each // other, so they must measure the same thing. - walkLeafOffsets(d.Root, 0, func(node *Node, bandAt, _, width int) { + walkLeafOffsets(d.Root, 0, func(node *Node, bandAt, width int) { out[node] = CourtForSpan(spans, bandAt, width) }) return out diff --git a/internal/helper/draw_ekc_2026_individual_test.go b/internal/helper/draw_ekc_2026_individual_test.go index 3ef52fd0f..c1b37a732 100644 --- a/internal/helper/draw_ekc_2026_individual_test.go +++ b/internal/helper/draw_ekc_2026_individual_test.go @@ -31,41 +31,22 @@ import ( // real invariants a transcription error would violate, independent of the // draw builder. // -// ekc2026Region.rounds carries a leading EMPTY round for Ladies courts B and -// C. Both are a clean 8 = 2^3 occupants with no bye, so read in isolation -// their first live round would be index 0; the sheet PRINTS their first live -// bouts in the same COLUMN as the round-2 bouts of their depth-4 semifinal -// siblings A and D (10 occupants each, needing a genuine round to shed 2 -// occupants down to 8 before the shared tree can align), and column position -// is round depth on these sheets. +// ekc2026Region.rounds is each court's OWN round list (regionRounds, counted +// within the region). Ladies courts B and C are a clean 8 = 2^3 occupants with +// no bye, so their first bouts are index 0 there. The sheet prints those bouts +// in the same COLUMN as the round-2 bouts of their semifinal siblings A and D +// (10 occupants each): its columns count back from the final, and the whole +// draw's rounds (BuildEliminationMatchRounds on draw.Root, the walk that +// numbers the matches) group them the same way. A court's own rounds cannot +// show that, so the literals below carry no leading empty round for B and C. // -// LP-3a checked whether the existing "risen" mechanism (Node.risenBefore/ -// risenAfter, tree.go) produces that leading column for B/C automatically, -// and it does NOT, for a structural reason rather than a missing case: risen -// is set in exactly one place (BuildSlotTree's own empty-sibling collapse, -// draw.go), which records a REAL absent occupant. B and C have none -- both -// are full 8-occupant blocks -- so nothing marks them there, and the assembly -// step that pairs them with A/D into a semifinal (drawPlan.combine -> -// joinNodes) never sets a rise either: joinNodes only rises a side when its -// SIBLING is nil, never merely shallower. Marking B/C risen after the fact to -// force the alignment was tried and rejected: leafArrayWidth (draw.go) folds -// a node's OWN risen count into its reported width, and that width is what -// walkLeafOffsets/RegionSpans use to place every LATER region (C, D, the -// semis, the final) in the whole draw's leaf array -- inflating B's width to -// "fix" its own round count would shift every region after it onto the wrong -// leaf slots and mis-court their bouts. There is no region-local way to ask -// for this column shift without corrupting the whole-draw geometry. +// Rise marks (Node.risenBefore/risenAfter) play no part in this: they are slot +// geometry and never move a round, and B and C carry none (full blocks, and +// joinNodes never marks the semifinal join). Marking them anyway would widen +// B's slot footprint (leafArrayWidth) and shift every later region onto the +// wrong leaf slots and courts. // -// So regionRounds(draw.Regions[i]) for courts B and C reports exactly their -// own local round count (3 rounds: no leading empty one), and the rounds -// tables below were adjusted to that -- the leading `{}` this comment used to -// describe is gone from B and C's literals -- per this file's own rule that a -// disagreement between the sheet and a derived mechanism is resolved by -// fixing the call site, not by bending the mechanism (or asserting something -// it cannot produce) to match a guess. The sheet's printed column alignment -// may still be real; if so it is a page-layout fact for the Excel export to -// reproduce separately, not a claim this data-model test makes. Men -// Individual has no such mismatch: all four courts land on local depth 4 (13, +// Men Individual has no such offset: all four courts land on local depth 4 (13, // 11, 11 and 12 occupants all need ceil(log2) = 4), so no Men court skips a // round. // @@ -205,9 +186,9 @@ func TestEKC2026LadiesIndividualDrawShape(t *testing.T) { court: "B", poolFrom: 10, poolTo: 17, rounds: [][]string{ // No leading empty round here: a clean 8-occupant block with no - // bye of its own reports its own 3 local rounds, not a 4th - // column-alignment round the data model has no way to produce - // (see the file-level comment). + // bye of its own reports its own 3 local rounds. The column it + // shares with A's round 2 is a whole-draw fact (see the + // file-level comment). {"Pool 10-1st v Pool 11-1st", "Pool 12-1st v Pool 13-1st", "Pool 14-1st v Pool 15-1st", "Pool 16-1st v Pool 17-1st"}, // F10-F13 {"W v W", "W v W"}, // F14, F15 {"W v W"}, // F16 block final diff --git a/internal/helper/draw_ekc_test.go b/internal/helper/draw_ekc_test.go index 4d13a0c92..2e01fb796 100644 --- a/internal/helper/draw_ekc_test.go +++ b/internal/helper/draw_ekc_test.go @@ -14,8 +14,8 @@ import ( // (court blocks), R4 (crossing), R5 (separation), R6 (byes) and D4 (region // shape) are all pinned here by real reference data rather than by prose. // -// Each case states, per shiaijo region, the round-1 pairings and the named -// round-1 bye exactly as the sheet prints them. +// Each case states, per shiaijo region, the first-layer pairings (who meets +// whom first) and the named round-1 bye exactly as the sheet prints them. // ekcPool builds a named pool with size players. Sizes matter to R6 criterion 2 // (oversized pools), so every reference pool is given the SAME size: the sheets @@ -65,10 +65,12 @@ func courtsByRound(draw *KnockoutDraw) [][]string { return out } -// regionRound1 returns a region's round-1 layer exactly as D4 defines it: the -// slot array paired (2i, 2i+1), with both-real pairs reported as matches, a +// regionRound1 returns a region's first slot layer exactly as D4 lays it out: +// the slot array paired (2i, 2i+1), with both-real pairs reported as matches, a // one-real pair as a NAMED BYE, and both-empty pairs (phantoms) dropped -// entirely -- they are never printed or displayed. +// entirely -- they are never printed or displayed. It pins who meets whom, not +// when: a pair beside a phantom is fought a round later, at its distance from +// the final (regionRounds; 34th EKC Junior Individual Male, P4 v P5). func regionRound1(region *Node) (matches []string, byes []string) { slots := TreeToLeafArray(region) matches, byes = []string{}, []string{} @@ -84,7 +86,7 @@ func regionRound1(region *Node) (matches []string, byes []string) { } } if len(slots) == 1 && slots[0] != "" { - // A one-occupant region has no round-1 layer at all: its occupant byes + // A one-occupant region has no first slot layer at all: its occupant byes // straight into the region's parent (EKC Female court B). byes = append(byes, slots[0]) } @@ -122,8 +124,8 @@ func assertEKCRegions(t *testing.T, draw *KnockoutDraw, want []ekcRegion) { for i, w := range want { t.Run(w.court, func(t *testing.T) { matches, byes := regionRound1(draw.Regions[i]) - assert.Equal(t, w.matches, matches, "round-1 matches on shiaijo %s", w.court) - assert.Equal(t, w.byes, byes, "round-1 byes on shiaijo %s", w.court) + assert.Equal(t, w.matches, matches, "first-layer pairs on shiaijo %s", w.court) + assert.Equal(t, w.byes, byes, "first-layer byes on shiaijo %s", w.court) }) } } @@ -132,12 +134,12 @@ func assertEKCRegions(t *testing.T, draw *KnockoutDraw, want []ekcRegion) { // pool, four shiaijo A(1-5) B(6-10) C(11-14) D(15-18). // // It is the sharpest case in the spec. Courts A and B hold 5 pools each, and a -// 5-occupant region is where the two candidate region constructions disagree: -// the sheet prints ONE named bye and TWO round-1 matches (P1 byes, P2 v P3, -// P4 v P5), with the round-2 bye falling to W(P4 v P5) rather than to P1. -// Recursive halving would have put the bye on occupant 3, and -// pad-to-NextPow2-and-spread would have produced three named byes and one -// match. Only the greedy layout (D4) reproduces the sheet. +// 5-occupant region is where the candidate region constructions disagree. The +// sheet (page 4 of the 34th EKC 2026 drawings) pairs P2 v P3, P1 v W(P2 v P3) +// and P4 v P5, and prints them by distance from the court final: P2 v P3 alone +// in column 1, then P4 v P5 and P1 v Winner F1 together in column 2. The +// greedy layout (D4) builds exactly those pairings; recursive halving would +// have put the bye on occupant 3. // // It is also the case that refutes any blanket "round-1 opponents must come // from different courts" rule: at 1 qualifier nothing crosses (R4d) and P2 v P3 @@ -160,18 +162,15 @@ func TestEKCJuniorIndividualMale(t *testing.T) { {court: "D", matches: []string{"Pool 15-1st v Pool 16-1st", "Pool 17-1st v Pool 18-1st"}, byes: []string{}}, }) - // The worked ladder the spec calls out: court A is 5 -> 3 -> 2 -> 1, four - // matches in three rounds, and the round-2 bye belongs to the winner of - // P4 v P5, NOT to P1. + // Court A is 5 -> 4 -> 2 -> 1: four matches in three rounds, and P1's + // opponent is the winner of P2 v P3, not one of P4 and P5. regionA := draw.Regions[0] require.NotNil(t, regionA) assert.Equal(t, 4, len(BuildEliminationMatchRounds(regionA)[0])+len(BuildEliminationMatchRounds(regionA)[1])+len(BuildEliminationMatchRounds(regionA)[2]), "a 5-occupant region plays four matches") - assert.Equal(t, 4, CalculateDepth(regionA), "5 -> 3 -> 2 -> 1 is three rounds of matches") - // P1's second match is the round-2 bout, so P1 is NOT the round-2 bye; the - // bye sits on the branch holding P4 and P5. + assert.Equal(t, 4, CalculateDepth(regionA), "5 -> 4 -> 2 -> 1 is three rounds of matches") assert.Equal(t, []string{"Pool 4-1st", "Pool 5-1st"}, TreeLeafLabels(regionA.Right), - "W(P4 v P5) takes the round-2 bye straight into the region final") + "P4 v P5 is the other half of the court final") // R6: a region with no structural bye grants none whatever its precedence. for _, i := range []int{2, 3} { @@ -179,17 +178,17 @@ func TestEKCJuniorIndividualMale(t *testing.T) { assert.Empty(t, byes, "a 4-occupant region has no bye to give") } - // The sheet's COLUMNS, not just its pairings: F1 and F2 both sit in the - // round-1 column, so P4 v P5 is fought in round 1 even though its winner - // then byes round 2. The phantom collapse used to lift that bout into - // round 2 on every surface that reads rounds (Node.risen records the lift - // and TraverseRounds counts it back); the vacancy blocks of the 2025 Men - // Team sheet are the shape that must NOT be pulled forward the same way, - // and TestEKC2025MenTeamByes pins them. + // The sheet's COLUMNS, not just its pairings: F1 (P2 v P3) is alone in + // column 1, and F2 (P4 v P5) sits in column 2 beside F3 (P1 v Winner F1). + // Columns count back from the court final, so the pair beside the empty + // pair is fought in round 2, as the 2025 Men Team sheet's five-occupant + // courts also print (TestEKC2025MenTeamByes). An earlier reading put F2 in + // column 1 and pulled such pairs forward a round; the PDF does not. Within + // a column the order is the slot walk's, where the sheet stacks F2 above F3. assert.Equal(t, [][]string{ - {"Pool 2-1st v Pool 3-1st", "Pool 4-1st v Pool 5-1st"}, // F1, F2 - {"Pool 1-1st v W"}, // F3 - {"W v W"}, // F4 + {"Pool 2-1st v Pool 3-1st"}, // F1 + {"Pool 1-1st v W", "Pool 4-1st v Pool 5-1st"}, // F3, F2 + {"W v W"}, // F4 }, regionRounds(regionA), "the round each court-A bout is fought in, as the sheet prints it") } diff --git a/internal/helper/draw_leaf_offsets_test.go b/internal/helper/draw_leaf_offsets_test.go index 525841be1..4011792f2 100644 --- a/internal/helper/draw_leaf_offsets_test.go +++ b/internal/helper/draw_leaf_offsets_test.go @@ -10,12 +10,12 @@ import ( // walkLeafOffsets is the ONE traversal that turns a draw tree back into slot // positions, and two exported readers depend on it disagreeing with nobody: -// RegionSpans decides which slots a shiaijo's region owns, NodeCourts decides -// which shiaijo a bout prints under, and SlotRoundMatches locates a bout's -// first-round window. Its own doc comment says a disagreement puts the operator -// console and the printed running order on different courts with nothing to -// catch it, so the geometry is pinned here directly rather than only through -// whichever draws today's builders happen to produce. +// RegionSpans decides which slots a shiaijo's region owns, and NodeCourts +// decides which shiaijo a bout prints under. Its own doc comment says a +// disagreement puts the operator console and the printed running order on +// different courts with nothing to catch it, so the geometry is pinned here +// directly rather than only through whichever draws today's builders happen +// to produce. // // The property under test is the definition of the geometry, not a restatement // of the arithmetic: the BAND a node is reported to occupy must be exactly that @@ -61,7 +61,7 @@ func TestWalkLeafOffsetsBandIsExactlyTheNodesOwnSlots(t *testing.T) { rootSlots := SlotArray(root) seen := 0 - walkLeafOffsets(root, 0, func(n *Node, bandOffset, contentOffset, width int) { + walkLeafOffsets(root, 0, func(n *Node, bandOffset, width int) { seen++ label := nodeLabelForTest(n) @@ -70,31 +70,11 @@ func TestWalkLeafOffsetsBandIsExactlyTheNodesOwnSlots(t *testing.T) { label, bandOffset, bandOffset+width, len(rootSlots)) require.GreaterOrEqualf(t, bandOffset, 0, "node %s: negative band offset", label) + // Every node's band is checked, children included, so this + // also pins where the walk descends from: a child walked from + // the wrong offset reports a band that is not its own slots. assert.Equalf(t, SlotArray(n), rootSlots[bandOffset:bandOffset+width], "node %s: the band reported for it is not the slots it actually owns", label) - - // The content sub-range is where the node's OWN entrants sit, - // which SlotRoundMatches reads as a bout's first-round window. - // SlotArray pads a rise onto the FRONT of the band for a - // leading collapse and onto the BACK for a trailing one, so - // the content is the band with this node's own pad stripped - // from the corresponding end. (A CHILD's rise leaves empties - // inside the content; those belong to the child, not here, - // which is why this compares sub-arrays rather than asserting - // the first content slot is non-empty.) - content := width >> (n.risenAfter + n.risenBefore) - assert.GreaterOrEqualf(t, contentOffset, bandOffset, - "node %s: content offset %d is before its band start %d", label, contentOffset, bandOffset) - require.LessOrEqualf(t, contentOffset+content, bandOffset+width, - "node %s: content [%d,%d) runs past its band end %d", label, contentOffset, contentOffset+content, bandOffset+width) - - own := SlotArray(n) - wantContent := own[:content] - if n.risenBefore > 0 { - wantContent = own[width-content:] - } - assert.Equalf(t, wantContent, rootSlots[contentOffset:contentOffset+content], - "node %s: the content window reported for it is not where its own entrants sit", label) }) assert.Positive(t, seen, "the walk visited no nodes") }) diff --git a/internal/helper/draw_seed_bye_test.go b/internal/helper/draw_seed_bye_test.go index 7507c48f7..e13f0af08 100644 --- a/internal/helper/draw_seed_bye_test.go +++ b/internal/helper/draw_seed_bye_test.go @@ -106,11 +106,11 @@ func TestByeGoesToTheHighestPrecedenceOccupantSweep(t *testing.T) { // slot in round 2 or later: the competitor stands // alone in a later column awaiting a bout's // winner. A leaf-leaf pair sitting shallow is NOT - // a trigger -- at 1 qualifier it is a phantom-risen - // pair (Junior Male's P4 v P5, column 1 on the - // sheet), at 2+ it is a vacancy block's two byes - // meeting (2025 Men Team F16), and the tree cannot - // tell them apart (spec R6(c)). + // a trigger: it is two occupants whose first bout + // is against each other after round 1, beside a + // phantom pair at 1 qualifier (Junior Male's P4 v + // P5, column 2 on the sheet) or as a vacancy + // block's two byes at 2+ (2025 Men Team F16). // // Under the template a block byes SEVERAL // occupants by design (its sub-block heads, plus diff --git a/internal/helper/draw_seed_count_test.go b/internal/helper/draw_seed_count_test.go index cc7620ce1..889cac28b 100644 --- a/internal/helper/draw_seed_count_test.go +++ b/internal/helper/draw_seed_count_test.go @@ -142,11 +142,8 @@ func TestStructuralRulesHoldAtEverySeedCount(t *testing.T) { // Rounds must partition the matches: a draw of n // entrants plays n-1, and every one must classify - // into exactly one round. This is the guard on - // Node.risen's virtual depth -- a risen match whose - // virtual level exceeded the tree depth would fall - // out of every round and silently vanish from the - // sheet. + // into exactly one round, or it silently vanishes + // from the sheet. totalMatches := 0 for _, r := range BuildEliminationMatchRounds(draw.Root) { totalMatches += len(r) diff --git a/internal/helper/estimate.go b/internal/helper/estimate.go index e2bba71d8..f96d8fbc0 100644 --- a/internal/helper/estimate.go +++ b/internal/helper/estimate.go @@ -58,7 +58,7 @@ type EstimateMatchCountsInput struct { // Returned counts reflect court-time-consuming matches only. Auto-resolved // bracket byes (player-vs-bye leaf matches marked Completed at generation time) // are excluded because assignBracketMatchSlots does not advance the court -// cursor for them (scheduler_slots.go: 286-291). Pool-side byes are not +// cursor for them (its Status == Completed branch in scheduler_slots.go). Pool-side byes are not // applicable (pools have no bye mechanism). Negative PlayerCount or // zero-round Swiss are clamped to zero matches rather than erroring, // because the estimator may be called speculatively before validation is diff --git a/internal/helper/excel_tree.go b/internal/helper/excel_tree.go index b826cba55..f6811a8c6 100644 --- a/internal/helper/excel_tree.go +++ b/internal/helper/excel_tree.go @@ -107,9 +107,7 @@ func RenderTreePages(f *excelize.File, subtrees []*Node, plan CourtPlan, pools [ return fmt.Errorf("copy tree template to %s: %w", pageSheet, err) } - // Slot depth: a risen page subtree spans more printed columns than - // physical levels (its bout fights early and the winner byes across). - depth := slotDepth(subtree) + depth := CalculateDepth(subtree) startRow := TreeTitleRows + 1 // The title formula prepends data!$B$1 (the user-supplied title prefix), // so the page title itself is just the shiaijo label. @@ -336,7 +334,7 @@ func AddPoolsToTree(f *excelize.File, sheetName string, pools []Pool, poolCoords // node's matchNum field is set in-place. // // This is the authoritative numbering for the printed Excel Tree sheet. The web -// API has a SEPARATE implementation, engine.assignBracketMatchNumbers, which +// API has a SEPARATE implementation, state.Bracket.NumberMatches, which // operates on *state.Bracket (a different type) instead of []*Node, the two are // NOT a literally-shared function. They are kept equal-by-contract: skip the same // positions (a nil node here == a Hidden-or-both-sides-empty match there) and diff --git a/internal/helper/tree.go b/internal/helper/tree.go index 4ee6d3d8d..a407c371b 100644 --- a/internal/helper/tree.go +++ b/internal/helper/tree.go @@ -26,21 +26,17 @@ type Node struct { // risenAfter/risenBefore count the slot levels this node was lifted past // when BuildSlotTree collapsed an EMPTY sibling (a phantom pair, or a - // bye's gap) sitting after respectively before it. Root distance alone - // then mis-states the round a match is fought in: the 34th EKC Junior - // Individual Male sheet prints its phantom-risen pair (F2, P4 v P5) in the - // ROUND-1 column, because the pair could always have been fought there -- - // the rise is a fact about empty slots, not about the bout. TraverseRounds - // adds the rises back before classifying, which restores the slot-level - // round for the node and everything below it, and SlotArray uses the side - // split to reconstruct the exact slot array the tree was built from. - // Risen LEAVES (byes) are never classified, so the fields are inert on - // them there, but SlotArray still needs their sides. + // bye's gap) sitting after respectively before it. They are slot geometry + // only: SlotArray uses the side split to rebuild the exact slot array the + // tree came from, and leafArrayWidth/walkLeafOffsets count the rises in the + // slots a node spans, which the region spans and court stamping read. // - // This is exactly the phantom-vs-vacancy distinction the collapsed tree - // otherwise loses (spec R6(c)): a vacancy block's bye pair (2025 Men Team - // F16) is built at its own slot level and never rises, so it keeps its - // round-2 column, while a phantom-risen pair returns to round 1. + // They do not move a bout's round, which is its distance from the root: + // the reference sheets count their columns back from the final. The 34th + // EKC Junior Individual Male sheet (court A) prints P2 v P3 alone in + // column 1 and P4 v P5, beside an empty pair, in column 2 with P1 v + // Winner F1; the 33rd EKC 2025 Men Team sheet prints its five-occupant + // courts the same way. risenAfter int risenBefore int } @@ -90,30 +86,10 @@ func PrintLeafNodes(node *Node, f *excelize.File, sheetName string, startCol int return } - // A risen MATCH shifts itself left into the column its slots occupy: the - // reference sheets fight a phantom-risen pair in the round-1 column with a - // long winner line across the skipped round (34th EKC Junior Male F2), and - // FillInMatches numbers it as a round-1 bout, so drawing it a column late - // would print Match 1 in the round-2 column. A risen LEAF stays put: a - // bye's name box prints where the competitor first fights, which IS the - // collapsed position, exactly as the sheets print byed entrants beside - // "Winner F..". Handled at entry rather than in the recursion so a PAGE - // whose root is a risen block (splitIntoSubtrees can cut one out) shifts - // the same way. Row-wise the content keeps its band, halved once per rise, - // at the top of the band for a trailing empty sibling and at the bottom - // for a leading one -- the slot reading of the same collapse. - if !node.LeafNode { - for i := 0; i < node.risenAfter; i++ { - startCol -= 2 - depth-- - } - for i := 0; i < node.risenBefore; i++ { - startCol -= 2 - depth-- - startRow += int(math.Pow(2, float64(depth-1))) - } - } - + // Every node draws at its distance from the root, the column the reference + // sheets print it in (34th EKC Junior Individual Male, page 4: P4 v P5, + // whose neighbouring pair is empty, sits in column 2 beside P1 v Winner + // F1), and a byed entrant's name prints beside the bout they first fight. size := int(math.Pow(2, float64(depth-1))) if node.LeafNode { @@ -175,10 +151,6 @@ func TraverseRounds(node *Node, depth int, maxDepth int) []*Node { return []*Node{} } - // A risen node (and its whole subtree) classifies at the slot level it was - // BUILT at, not the level the collapse of an empty sibling lifted it to. - depth += node.risenAfter + node.risenBefore - var matches []*Node if depth == maxDepth { @@ -207,11 +179,10 @@ func TraverseRounds(node *Node, depth int, maxDepth int) []*Node { // generators - the loop used to be copied at each call site, like the // tree-page rendering loop before RenderTreePages. func BuildEliminationMatchRounds(tree *Node) [][]*Node { - // Slot depth, not physical: a tree whose top is risen (a split page - // holding one risen block) is deeper in slot levels than in nodes, and - // the physical count would leave its bouts above every classification - // target. - depth := slotDepth(tree) + // Rounds count back from the final, as the reference sheets print their + // columns: a bout's round is its distance from the root, whatever empty + // slots were collapsed beside it (see Node.risenAfter). + depth := CalculateDepth(tree) rounds := make([][]*Node, 0, max(depth-1, 0)) for i := depth; i > 1; i-- { rounds = append(rounds, TraverseRounds(tree, 1, i-1)) @@ -319,10 +290,10 @@ func regionPages(region *Node, want int) []*Node { // tree was built from, EMPTY POSITIONS INCLUDED. TreeToLeafArray cannot do // that -- it pads a narrow side at its tail, so a vacancy block's bye pair // ([H10,"",C6,""], 2025 Men Team court D) comes back as the adjacent -// [H10,C6,"",""], indistinguishable from a phantom-risen round-1 pair. The -// risen side counts recorded by BuildSlotTree put each collapse back where it -// was, which is what lets a pow2 bracket built from this array carry the SAME -// round geometry as the printed Excel columns (engine buildBracketFromDraw). +// [H10,C6,"",""], a pair followed by an empty pair. The risen side counts +// recorded by BuildSlotTree put each collapse back where it was, so a pow2 +// bracket built from this array (engine buildBracketFromDraw) holds every bout +// at the slots the draw tree gives it. // // Trees not built from slot arrays (CreateBalancedTree) carry no rises and // come back with TreeToLeafArray's tail-padded geometry, which for them is diff --git a/internal/helper/tree_test.go b/internal/helper/tree_test.go index 87f8e0180..02cc7ed61 100644 --- a/internal/helper/tree_test.go +++ b/internal/helper/tree_test.go @@ -1373,8 +1373,9 @@ func TestBlockByeNeverSkipsAHigherFinisher(t *testing.T) { // PURPOSE: the condition is the documented scope of the sheet evidence, so a // drift between the two is a real finding rather than test noise. // -// Greedy blocks grant q%2 named round-1 byes and play floor(q/2) round-1 -// matches; a 6-occupant template block grants 2 and plays 2. The q=5 template +// Greedy blocks grant q%2 named round-1 byes and hold floor(q/2) real pairs in +// the first slot layer (a pair beside a phantom pair is fought a round later); +// a 6-occupant template block grants 2 and plays 2 round-1 matches. The q=5 template // block (a VACANCY block) is reported skip=true: its bye pair collapses into a // shallow leaf-leaf match that a flat leaf array cannot reconstruct, so its // layout is pinned bout-for-bout by TestEKC2025MenTeamByes on the rounds view @@ -1427,8 +1428,9 @@ func blockLayoutArithmetic(leaves []string) (byes, matches int, skip bool) { } // TestBlockByeCountMatchesTheLayout pins D4's arithmetic directly, per block -// and per layout mode. Greedy: q mod 2 NAMED round-1 byes, floor(q/2) round-1 -// matches, every other empty slot phantom-paired and never printed. Template +// and per layout mode. Greedy: q mod 2 NAMED round-1 byes, floor(q/2) real +// pairs in the first slot layer, every other empty slot phantom-paired and +// never printed. Template // (R6(c), the Men Team sheets): every empty slot pairs with a real occupant, // so a 6-occupant block grants TWO named byes -- its sub-block heads -- and // plays two round-1 matches. Recursive halving disagrees with both from q=6 up. @@ -1461,7 +1463,7 @@ func TestBlockByeCountMatchesTheLayout(t *testing.T) { } } if q == 1 { - // A one-occupant block has no round-1 layer at all. + // A one-occupant block has no first slot layer at all. continue } wantByes, wantMatches, skip := blockLayoutArithmetic(TreeLeafLabels(block)) @@ -1469,7 +1471,7 @@ func TestBlockByeCountMatchesTheLayout(t *testing.T) { continue } assert.Equalf(t, wantByes, byes, "block %d: %d occupants must grant %d named byes", c, q, wantByes) - assert.Equalf(t, wantMatches, matches, "block %d: %d occupants must play %d round-1 matches", c, q, wantMatches) + assert.Equalf(t, wantMatches, matches, "block %d: %d occupants must hold %d real first-layer pairs", c, q, wantMatches) } }) } diff --git a/internal/mobileapp/decision_both_barred_hikiwake_test.go b/internal/mobileapp/decision_both_barred_hikiwake_test.go new file mode 100644 index 000000000..0106f61e4 --- /dev/null +++ b/internal/mobileapp/decision_both_barred_hikiwake_test.go @@ -0,0 +1,352 @@ +// Package mobileapp, decision_both_barred_hikiwake_test.go pins bc-cse item +// 10: POST /decision accepts {"decision":"hikiwake"} for a scheduled pool +// match whose both sides are already barred (a completed draw, no winner, +// no eligibility status written), refuses it exactly as before for every +// other shape, and a knockout match with both sides barred gets its own +// "Neither ... can fight" sentence instead of naming one side. +package mobileapp + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bothBarredPoolFixture builds a pool competition where Alice is barred by a +// kiken-voluntary on "Pool A-0" (Alice vs Bob) and Dave is barred by a +// SEPARATE kiken-voluntary on "Pool A-1" (Dave vs Carol). "Pool A-2" (Alice +// vs Dave) is left scheduled -- both of its competitors are already barred, +// each by an unrelated earlier match. "Pool A-3" (Bob vs Carol, neither ever +// barred) is left scheduled too, for the non-qualifying-hikiwake refusal +// tests. +func bothBarredPoolFixture(t *testing.T, compID string) (*gin.Engine, *state.Store, *engine.Engine) { + t.Helper() + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + aliceID, bobID, carolID, daveID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + {ID: daveID, Name: "Dave", Dojo: "D"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Dave", SideB: "Carol", SideAID: daveID, SideBID: carolID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-2", SideA: "Alice", SideB: "Dave", SideAID: aliceID, SideBID: daveID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-3", SideA: "Bob", SideB: "Carol", SideAID: bobID, SideBID: carolID, Status: state.MatchStatusScheduled}, + // Only Alice is barred here (via Pool A-0); Carol never is -- the + // ONE-side-barred refusal case. + {ID: "Pool A-4", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + + // Alice (aka/SideA) withdraws from Pool A-0; Bob credited. + resp := postDecisionJSON(t, r, compID, "Pool A-0", DecisionRequest{ + Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test", + }) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + // Dave (aka/SideA) withdraws from Pool A-1; Carol credited. A SEPARATE + // withdrawal from a DIFFERENT match, so BarredSides finds each barred + // competitor by their OWN match, not Alice's. + resp = postDecisionJSON(t, r, compID, "Pool A-1", DecisionRequest{ + Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test", + }) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + + return r, store, eng +} + +func TestDecisionHandler_BothSidesBarredHikiwake_Accepted(t *testing.T) { + compID := "both-barred-hikiwake-accept" + r, store, _ := bothBarredPoolFixture(t, compID) + + // decisionBy is deliberately OMITTED: this shape bypasses Validate() + // entirely (bc-cse item 10), so it must not be required. + resp := postDecisionJSON(t, r, compID, "Pool A-2", DecisionRequest{ + Decision: "hikiwake", DecisionReason: "both competitors already withdrawn", + }) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + var m2 *state.MatchResult + for i := range matches { + if matches[i].ID == "Pool A-2" { + m2 = &matches[i] + } + } + require.NotNil(t, m2) + assert.Equal(t, state.MatchStatusCompleted, m2.Status) + assert.Equal(t, "hikiwake", m2.Decision) + assert.Empty(t, m2.Winner, "a draw between two barred competitors names no winner") + assert.Empty(t, m2.WinnerID) + assert.Empty(t, m2.IpponsA, "no points on a hikiwake between two barred competitors") + assert.Empty(t, m2.IpponsB) + + // Writes NO eligibility status of its own: hikiwake is not a withdrawal + // decision, so both bars are untouched, each still attributed to its + // OWN original match, never to Pool A-2. + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + require.Len(t, statuses, 2, "hikiwake must not add or remove any eligibility record") + for _, st := range statuses { + assert.False(t, st.Eligible) + assert.NotEqual(t, "Pool A-2", st.MatchID, "the draw must not claim to have barred anyone itself") + } +} + +func TestDecisionHandler_HikiwakeNeitherBarred_Refused400SameAsToday(t *testing.T) { + compID := "hikiwake-neither-barred" + r, _, _ := bothBarredPoolFixture(t, compID) + + // Pool A-3 pairs Bob against Carol; NEITHER was ever barred. + resp := postDecisionJSON(t, r, compID, "Pool A-3", DecisionRequest{ + Decision: "hikiwake", DecisionReason: "test", + }) + require.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Contains(t, body["error"], "unsupported on /decision endpoint", + "a non-qualifying hikiwake must keep today's refusal, unchanged") +} + +func TestDecisionHandler_HikiwakeOneSideBarred_Refused400SameAsToday(t *testing.T) { + compID := "hikiwake-one-side-barred" + r, _, _ := bothBarredPoolFixture(t, compID) + + // Pool A-4 pairs Alice (barred, via Pool A-0) against Carol (never + // barred): only ONE side qualifies, so this must not be accepted either. + resp := postDecisionJSON(t, r, compID, "Pool A-4", DecisionRequest{ + Decision: "hikiwake", DecisionReason: "test", + }) + require.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Contains(t, body["error"], "unsupported on /decision endpoint") +} + +// TestScoreHandler_BothSidesBarredKnockout_NeitherCanFightSentence covers +// bc-cse item 10's knockout wording: a bracket match whose both competitors +// are already barred (each by a separate earlier pool match) is refused +// with a "Neither ... can fight ..." sentence, naming BOTH competitors, +// rather than the single-sided "X withdrew ..." sentence that used to name +// only one and silently drop the other's own bar. +func TestScoreHandler_BothSidesBarredKnockout_NeitherCanFightSentence(t *testing.T) { + compID := "both-barred-knockout" + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusKnockout, + })) + aliceID, bobID, carolID, daveID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + {ID: daveID, Name: "Dave", Dojo: "D"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Dave", SideB: "Carol", SideAID: daveID, SideBID: carolID, Status: state.MatchStatusScheduled}, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Alice", SideAID: aliceID, SideB: "Dave", SideBID: daveID, + Status: state.MatchStatusScheduled, DisplayRound: 1, MatchNumber: 1}}, + }})) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + + require.Equal(t, http.StatusOK, postDecisionJSON(t, r, compID, "Pool A-0", + DecisionRequest{Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test"}).Code) + require.Equal(t, http.StatusOK, postDecisionJSON(t, r, compID, "Pool A-1", + DecisionRequest{Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test"}).Code) + + // Starting the knockout match (Alice vs Dave, both barred) is refused. + resp := putScoreJSON(t, r, compID, "m-r1-0", state.MatchResult{ID: "m-r1-0", Status: state.MatchStatusRunning}) + require.Equal(t, http.StatusConflict, resp.Code, resp.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, "ineligible_competitor", body["error"]) + reasonHuman, _ := body["reasonHuman"].(string) + assert.Contains(t, reasonHuman, "Neither") + assert.Contains(t, reasonHuman, "Alice") + assert.Contains(t, reasonHuman, "Dave") + assert.Contains(t, reasonHuman, "correct the earlier withdrawal or the draw") +} + +// TestDecisionHandler_BothSidesBarredHikiwake_ClockSkewRefused pins bc-cse +// finding 3: the clock-skew refusal runs BEFORE the both-sides-barred +// hikiwake carve-out's own write, exactly as it does on the main /decision +// path and on PUT /score. A device clock implausibly far in the future gets +// the same {"applied":false,"reason":"clock_skew"} 200, and nothing is +// written -- Pool A-2 stays scheduled. +func TestDecisionHandler_BothSidesBarredHikiwake_ClockSkewRefused(t *testing.T) { + compID := "both-barred-hikiwake-clock-skew" + r, store, _ := bothBarredPoolFixture(t, compID) + + future := time.Now().Add(time.Hour).UnixMilli() + resp := postDecisionJSON(t, r, compID, "Pool A-2", DecisionRequest{ + Decision: "hikiwake", DecisionReason: "both competitors already withdrawn", + ModifiedAt: future, + }) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, false, body["applied"]) + assert.Equal(t, "clock_skew", body["reason"]) + + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + var m2 *state.MatchResult + for i := range matches { + if matches[i].ID == "Pool A-2" { + m2 = &matches[i] + } + } + require.NotNil(t, m2) + assert.Equal(t, state.MatchStatusScheduled, m2.Status, "nothing was written") +} + +// TestDecisionHandler_BothSidesBarredHikiwake_SupersededNot500 pins bc-cse +// finding 3's second half: the carve-out's own write competes on timestamps +// exactly like the main /decision path, and losing that race must answer +// 200 {"applied":false,"reason":"superseded"} -- never the 500 +// respondEngineError's default arm used to produce for +// engine.ErrMatchSuperseded, which the SPA's offline write queue would +// retry forever against a write that can never win. +func TestDecisionHandler_BothSidesBarredHikiwake_SupersededNot500(t *testing.T) { + compID := "both-barred-hikiwake-superseded" + r, store, _ := bothBarredPoolFixture(t, compID) + + // Stamp Pool A-2's STORED ModifiedAt into the future so the carve-out's + // own write (an older, still clock-valid stamp) loses the timestamp + // last-write-wins guard. + storedStamp := time.Now().Add(time.Hour).UnixMilli() + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range matches { + if matches[i].ID == "Pool A-2" { + matches[i].ModifiedAt = storedStamp + } + } + require.NoError(t, store.SavePoolMatches(compID, matches)) + + writeStamp := time.Now().Add(-time.Hour).UnixMilli() + resp := postDecisionJSON(t, r, compID, "Pool A-2", DecisionRequest{ + Decision: "hikiwake", DecisionReason: "both competitors already withdrawn", + ModifiedAt: writeStamp, + }) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, false, body["applied"]) + assert.Equal(t, "superseded", body["reason"]) + + after, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + var m2 *state.MatchResult + for i := range after { + if after[i].ID == "Pool A-2" { + m2 = &after[i] + } + } + require.NotNil(t, m2) + assert.Equal(t, state.MatchStatusScheduled, m2.Status, "the superseded write must not overwrite the stored match") + assert.Equal(t, storedStamp, m2.ModifiedAt, "the stored stamp is untouched") +} + +// TestDecisionHandler_BothSidesBarredHikiwake_SettlesAReasonlessReopen drives +// the one way a both-barred match comes to carry ReopenPending: Carol's default +// win over the barred Alice is reopened with no reason, which lands it +// SCHEDULED because Alice is still barred (engine.reopenTargetStatus), and +// Carol then withdraws from another match. A match can be reopened without a +// reason (operator ruling), so recording it as drawn is never refused for one: +// the draw closes it and clears the flag, keeping the request's reason, if it +// has one, as the correction reason. +func TestDecisionHandler_BothSidesBarredHikiwake_SettlesAReasonlessReopen(t *testing.T) { + for _, tc := range []struct { + name, reason string + }{ + {"no reason", ""}, + {"the one-tap button's reason", "auto: Alice and Carol withdrawn"}, + } { + t.Run(tc.name, func(t *testing.T) { + compID := "both-barred-hikiwake-reopen" + r, store, _ := bothBarredPoolFixture(t, compID) + + load := func() state.MatchResult { + t.Helper() + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range matches { + if m.ID == "Pool A-4" { + return m + } + } + t.Fatal("Pool A-4 not found") + return state.MatchResult{} + } + + // Pool A-4 is Alice (aka, barred by Pool A-0) v Carol: the default + // win goes to Carol, then it is reopened with a one-tap empty body. + resp := postDecisionJSON(t, r, compID, "Pool A-4", DecisionRequest{ + Decision: "fusensho", DecisionBy: "aka", DecisionReason: "Alice withdrew earlier", + }) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + resp = postReopenRaw(t, r, compID, "Pool A-4", nil) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + reopened := load() + require.Equal(t, state.MatchStatusScheduled, reopened.Status, "Alice is still barred, so the reopen lands scheduled") + require.True(t, reopened.ReopenPending, "fixture: the reopen gave no reason") + + // Carol (shiro on Pool A-3) withdraws: both sides of Pool A-4 are barred. + resp = postDecisionJSON(t, r, compID, "Pool A-3", DecisionRequest{ + Decision: "kiken-voluntary", DecisionBy: "shiro", DecisionReason: "test", + }) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + + resp = postDecisionJSON(t, r, compID, "Pool A-4", DecisionRequest{ + Decision: "hikiwake", DecisionReason: tc.reason, + }) + require.Equal(t, http.StatusOK, resp.Code, "a reopen without a reason never blocks ending the match: %s", resp.Body.String()) + drawn := load() + assert.Equal(t, state.MatchStatusCompleted, drawn.Status) + assert.Equal(t, "hikiwake", drawn.Decision) + assert.False(t, drawn.ReopenPending, "the draw settles the reopen") + assert.Equal(t, tc.reason, drawn.CorrectionReason, "the request's reason, if any, is kept with the result") + }) + } +} diff --git a/internal/mobileapp/deps.go b/internal/mobileapp/deps.go index a7c6e91e9..2e4bed31e 100644 --- a/internal/mobileapp/deps.go +++ b/internal/mobileapp/deps.go @@ -83,8 +83,9 @@ type ScoringEngine interface { // opts is variadic engine.ForceOptions (bc-kcdg): omit it for the // pre-existing behaviour (force=false, no reopen list wanted), or pass // one to bypass the downstream-knockout-correction guard - // (engine.DownstreamKnockoutPlayedError) and collect the IDs of any - // downstream bracket match reopened as a result. + // (engine.DownstreamKnockoutPlayedError, raised by a knockout correction + // or by a pool correction that moves a qualifier the knockout already + // played) and collect the IDs of any bracket match reopened as a result. RecordMatchResultWithIneligibility(compID string, matchID string, result *state.MatchResult, opts ...engine.ForceOptions) (*domain.CompetitorStatus, error) // RecordMatchResultWithIneligibilityTx is the tx-aware twin used by // the score handler under WithTransaction (T156). Same return shape @@ -142,6 +143,11 @@ type ScoringEngine interface { // score from the shiaijo view starts the competition just as // POST .../start does. Mirrors engine.Engine.MaybeAutoCompletePools. MaybeAutoCompletePools(compID string) (engine.AutoCompleteOutcome, error) + // MaybeAutoCompletePoolsAfterWrite is the same check for a door that has + // just written the given matches: once a mixed competition is in its + // knockout it skips the pool pass unless one of them is a completed pool + // match. Mirrors engine.Engine.MaybeAutoCompletePoolsAfterWrite. + MaybeAutoCompletePoolsAfterWrite(compID string, written ...state.MatchResult) (engine.AutoCompleteOutcome, error) // UpdateMatchCourt reassigns a match to a different court. Mirrors // engine.Engine.UpdateMatchCourt. UpdateMatchCourt(compID string, matchID string, newCourt string) error diff --git a/internal/mobileapp/deps_test.go b/internal/mobileapp/deps_test.go index 942607097..1c39aca42 100644 --- a/internal/mobileapp/deps_test.go +++ b/internal/mobileapp/deps_test.go @@ -82,6 +82,10 @@ func (stubScoringEngine) MaybeAutoCompletePools(string) (engine.AutoCompleteOutc return engine.AutoCompleteNoChange, nil } +func (stubScoringEngine) MaybeAutoCompletePoolsAfterWrite(string, ...state.MatchResult) (engine.AutoCompleteOutcome, error) { + return engine.AutoCompleteNoChange, nil +} + func (stubScoringEngine) UpdateMatchCourt(string, string, string) error { return nil } diff --git a/internal/mobileapp/errors.go b/internal/mobileapp/errors.go index f0663e340..6fab3da9c 100644 --- a/internal/mobileapp/errors.go +++ b/internal/mobileapp/errors.go @@ -2,11 +2,13 @@ package mobileapp import ( "errors" + "fmt" "log" "net/http" "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/gitrgoliveira/bracket-creator/internal/engine" "github.com/gitrgoliveira/bracket-creator/internal/state" ) @@ -74,6 +76,139 @@ func respondEngineError(c *gin.Context, err error) { } } +// reasonHumanForBarredCompetitor builds the ONE operator sentence for a 409 +// ineligible_competitor / already_ineligible refusal (bc-rawm acceptance: +// "None of the messages reaches the operator raw: each names the match in +// operator terms and says what to do"): the barred competitor's name, the +// match they were barred IN (its operator label), and the remedy -- record +// the default win for the OPPONENT of thisMatchID (the match the operator +// was trying to start/decide when the refusal fired), or reinstate for a +// kiken-injury. Falls back to domain.ResolveReasonHuman(reason) whenever any +// piece cannot be resolved, so the operator is never shown nothing. +// +// Both names are read off thisMatchID's OWN stored sides +// (store.MatchSidesByID) rather than a separate roster lookup: the barred +// competitor is provably one of thisMatchID's two sides in every producer of +// these two errors -- StartMatchTx/checkEligibilityExcludingMatch resolve +// playerID FROM this match's own ids (engine.BarredSides against +// matchSideParticipantIDs' return), and checkConcurrentIneligibility's loser +// is this match's own decisionBy side -- so playerID always equals one of +// sideAID/sideBID when it can be resolved at all. An id-unstamped row (a +// bye, an unresolved feeder, an unrepaired legacy row) degrades to the +// fallback rather than guessing which side the id names. +// +// barringMatchID/decision come from IneligibleCompetitorError.MatchID/ +// Decision or AlreadyIneligibleError.MatchID/Decision (engine.BarredSides' +// two producers, eligibility.go); thisMatchID is always the match id in the +// URL the handler is answering for. +func reasonHumanForBarredCompetitor(store CompetitionStore, compID, thisMatchID, playerID, reason, barringMatchID, decision string) string { + fallback := domain.ResolveReasonHuman(reason) + // No barring match to name at all (a status set directly via + // POST /competitor-status, with no matchId): OperatorMatchLabel would + // degrade an empty id to an empty label, producing a sentence naming no + // match ("withdrew in and cannot fight again"). Prefer the translated + // fallback; ResolveReasonHuman's own contract is to return "" rather + // than echo its input so the CALLER can decide whether to fall back to + // the raw reason (its doc comment), so an untranslatable reason here + // still surfaces SOMETHING rather than a silently empty reasonHuman. + if barringMatchID == "" { + if fallback != "" { + return fallback + } + return reason + } + sideA, sideB, sideAID, sideBID, found, err := store.MatchSidesByID(compID, thisMatchID) + if err != nil || !found { + return fallback + } + var name, opponent string + switch playerID { + case sideAID: + name, opponent = sideA, sideB + case sideBID: + name, opponent = sideB, sideA + default: + return fallback + } + if name == "" { + return fallback + } + // matchLabelOrID (== engine.OperatorMatchLabelFromStore) loads the + // competition and the bracket itself, degrading -- and LOGGING -- a + // LoadBracket failure to a pool-phase-only label rather than losing the + // sentence's name/remedy: the barring match is very unlikely to be a + // knockout one anyway (a withdrawal is almost always recorded in the + // pool phase), and even a bare id beats the generic fallback losing the + // name/remedy too. bc-cse finding 7: this used to hand-roll that same + // load-then-degrade sequence, with the LoadBracket error discarded via + // a blank identifier (an errcheck violation) and a second, unlogged copy + // of what OperatorMatchLabelFromStore already does. + label := matchLabelOrID(store, compID, barringMatchID) + return barredCompetitorSentence(name, label, decision, opponent) +} + +// bothSidesBarredReasonHuman builds the operator sentence for a match whose +// BOTH sides are already barred (IneligibleCompetitorError.BothSidesBarred, +// bc-cse item 10): unlike a one-sided bar, there is no single opponent to +// hand the default win to, so reasonHumanForBarredCompetitor's remedy +// clauses do not apply -- the fix is upstream of this match entirely +// (correct the earlier withdrawal that barred one of them, or the draw that +// paired two already-barred competitors together). +func bothSidesBarredReasonHuman(store CompetitionStore, compID, matchID string) string { + const fallback = "Neither competitor can fight: correct the earlier withdrawal or the draw." + sideA, sideB, _, _, found, err := store.MatchSidesByID(compID, matchID) + if err != nil || !found || sideA == "" || sideB == "" { + return fallback + } + label := matchLabelOrID(store, compID, matchID) + return fmt.Sprintf("Neither %s nor %s can fight %s: correct the earlier withdrawal or the draw.", sideA, sideB, label) +} + +// matchLabelOrID resolves matchID's operator label within compID, via +// engine.OperatorMatchLabelFromStore (bc-cse/11b: the one load-then-degrade +// body this and Engine.operatorMatchLabel both delegate to). Every call site +// here is building operator-facing prose, never gating logic, so a load +// failure degrades the label rather than the response. compID is the +// match's OWN competition, which for a cross-competition refusal (e.g. +// *engine.CourtBusyError.CompID, court occupancy is tournament-global) is +// NOT necessarily the competition the current request targets. +func matchLabelOrID(store CompetitionStore, compID, matchID string) string { + return engine.OperatorMatchLabelFromStore(store, compID, matchID) +} + +// barredCompetitorSentence is the one place the barred-competitor operator +// sentences are spelled, for each decision that can bar someone +// (kiken/kiken-voluntary, kiken-injury, fusenpai). opponent == "" (the +// current match's other side could not be resolved) drops the remedy clause +// rather than naming nobody; label names the BARRING match (barringMatchID +// in reasonHumanForBarredCompetitor, the one call site), never thisMatchID +// -- the sentence says WHERE the competitor was barred, and the remedy +// (record the default win for opponent) is what to do about THIS match. +func barredCompetitorSentence(name, label, decision, opponent string) string { + switch decision { + case string(domain.DecisionKikenInjury): + if opponent == "" { + return fmt.Sprintf("%s withdrew injured in %s. Reinstate %s if the doctor allows.", name, label, name) + } + return fmt.Sprintf("%s withdrew injured in %s. Reinstate %s if the doctor allows, or record the default win for %s.", name, label, name, opponent) + case string(domain.DecisionFusenpai): + if opponent == "" { + return fmt.Sprintf("%s did not appear for %s and cannot fight again.", name, label) + } + return fmt.Sprintf("%s did not appear for %s and cannot fight again. Record the default win for %s.", name, label, opponent) + default: + // kiken, kiken-voluntary, and any other/legacy barring decision this + // app never itself writes (fusensho/daihyosen do not bar anyone, so + // BarredSides never surfaces one in practice): the withdrawal + // sentence is the safe default, since every reachable barring + // decision besides the two cases above IS a kiken variant. + if opponent == "" { + return fmt.Sprintf("%s withdrew in %s and cannot fight again.", name, label) + } + return fmt.Sprintf("%s withdrew in %s and cannot fight again. Record the default win for %s.", name, label, opponent) + } +} + // respondIfEngineWriteError composes the sentinel checks a match-write // handler (score, decision, daihyosen) needs after its engine call: a // superseded write (200 {"applied":false}), a rejected precondition @@ -110,8 +245,8 @@ func respondIfEngineWriteError(c *gin.Context, err error) bool { // (Store.ResetOverridesForce, used by DELETE .../overrides) loads and // parses the existing file before saving, so a corrupt file makes them fail // identically -- and every engine call that reads standings -// (computeStandingsFrom, reached via the mp-e2k1 mixed-pool guard, -// LeagueTiebreakCandidates, ChusenCandidates) hits the same LoadOverrides +// (computeStandingsFrom, reached via the pool requalification check, +// LeagueTiebreakCandidates, ChusenStatus) hits the same LoadOverrides // call underneath. Left unmapped, that surfaces as an opaque 500, which the // SPA's offline write queue retries forever for the write endpoints // (mp-q8c6 poisoned-queue pattern) -- a genuinely corrupt file on disk @@ -170,15 +305,21 @@ func respondUnexportableCompetitionError(c *gin.Context, err error) bool { // respondIfDownstreamKnockoutPlayed answers engine.DownstreamKnockoutPlayedError // (bc-kcdg) with the ONE fixed wire contract every knockout-correction write // shares -- HTTP 409 {"error":"downstream_knockout_played","matchId", -// "blockingMatchId","blockingMatches","displaced","message"} -- and reports whether it -// answered, so the caller's switch can fall through to its own remaining -// arms exactly like the other respondIf* helpers in this file. +// "blockingMatchId","blockingMatches","displaced","qualifierChange","message"} +// -- and reports whether it answered, so the caller's switch can fall through +// to its own remaining arms exactly like the other respondIf* helpers in this +// file. // // Correcting a completed bracket match (via /score, /override-winner, // /decision, or /quick-score) can change a winner already propagated into a // downstream match that has since recorded its own result; the engine // refuses by default and the operator retries with forceDownstreamReopen -// once they've confirmed the override (see ForceOptions.Force on the +// once they've confirmed the override. Correcting a POOL match in a mixed +// competition answers the same way when it moves who holds a qualifying +// place and the old qualifier has already fought a knockout match: +// qualifierChange then names each place that moves (from, to), so the +// operator is told who moves, not only which match reopens; it is an empty +// list for a knockout correction (see ForceOptions.Force on the // matching request field of whichever endpoint they're using). Before this // existed, only /score and /override-winner had this mapping hand-copied // into their own error switches (identically, since both need the exact @@ -201,57 +342,81 @@ func respondIfDownstreamKnockoutPlayed(c *gin.Context, err error) bool { // rides along for addressing, never for display. "blockingMatches": blockedMatchesPayload(downstreamPlayedErr.Blocking), "displaced": downstreamPlayedErr.Displaced, + "qualifierChange": qualifierChangePayload(downstreamPlayedErr.QualifierChange), "message": downstreamPlayedErr.Error(), }) return true } -// blockedMatchesPayload renders the blocked matches for the wire: id for -// addressing, number for the operator. A number of 0 means the match never got -// one (a bye placeholder, or a pre-numbering bracket); the client falls back to -// the id there rather than printing "Match 0". -func blockedMatchesPayload(blocking []engine.ReopenedMatch) []map[string]any { - out := make([]map[string]any, 0, len(blocking)) - for _, b := range blocking { - out = append(out, map[string]any{"id": b.ID, "number": b.Number}) +// qualifierChangePayload renders a refusal's qualifier changes as a list, +// never null, so a client reads one shape whichever kind of correction it +// made. +func qualifierChangePayload(changes []engine.QualifierChange) []engine.QualifierChange { + if changes == nil { + return []engine.QualifierChange{} } - return out + return changes } -// respondIfDownstreamKnockoutScored answers engine.DownstreamKnockoutScoredError -// (mp-e2k1) with the ONE fixed wire contract -- HTTP 409 -// {"error":"downstream_knockout_scored","pool","finisher","matchId","message"} -// -- and reports whether it answered, so the caller's switch can fall through -// to its own remaining arms exactly like the other respondIf* helpers in this -// file. This is a DIFFERENT guard from respondIfDownstreamKnockoutPlayed -// above: mp-e2k1 fires on a POOL match re-score (in a Mixed competition) that -// would change which competitor holds a qualifying rank while a downstream -// bracket match already carries that finisher's own scored result, whereas -// bc-kcdg's DownstreamKnockoutPlayedError guards a bracket-match correction -// that would repaint an already-propagated winner. Both are reachable from -// every write endpoint that ends up inside RecordMatchResultWithIneligibility(Tx) -// for a pool match id (/score, /quick-score, bulk-score's per-entry -// transaction, and /decision via RecordDecisionTx(WithOptions)); before this -// helper existed, only /score mapped it and the rest fell through to a -// generic 500, which the SPA's offline write queue retries forever (mp-q8c6 -// poisoned-queue pattern) for a write that can never win. OverrideBracketWinner -// writes the bracket directly (UpdateBracket) and never reaches this guard, so -// it has no arm for this error. -func respondIfDownstreamKnockoutScored(c *gin.Context, err error) bool { - var downstreamScoredErr *engine.DownstreamKnockoutScoredError - if !errors.As(err, &downstreamScoredErr) { +// respondIfDownstreamKnockoutRunning answers engine.DownstreamKnockoutRunningError +// with HTTP 409 {"error":"downstream_knockout_running","matchId", +// "runningMatches","message"} and reports whether it answered. A pool +// correction in a mixed competition that would move a qualifier out of a +// knockout match somebody is fighting right now is refused outright: unlike +// downstream_knockout_played it is NOT confirmable (forceDownstreamReopen does +// not get past it), because reopening a match mid-bout would wipe what is +// being scored at the shiaijo. message is the operator's copy ("Match 9 (Quarterfinals) is +// being fought now. Finish it or send it back to the queue, then save +// again."). A 409, never a 5xx, so the offline write queue drops a replay +// that meets it instead of retrying it forever (mp-q8c6). +func respondIfDownstreamKnockoutRunning(c *gin.Context, err error) bool { + var runningErr *engine.DownstreamKnockoutRunningError + if !errors.As(err, &runningErr) { return false } c.JSON(http.StatusConflict, gin.H{ - "error": "downstream_knockout_scored", - "pool": downstreamScoredErr.Pool, - "finisher": downstreamScoredErr.Finisher, - "matchId": downstreamScoredErr.MatchID, - "message": downstreamScoredErr.Error(), + "error": "downstream_knockout_running", + "matchId": runningErr.MatchID, + "runningMatches": blockedMatchesPayload(runningErr.Running), + "message": runningErr.Error(), }) return true } +// blockedMatchesPayload renders knockout matches for the wire, for every +// payload that names them to the operator (blockingMatches, runningMatches, +// reopenedMatches): id for addressing, number, and label, the words the +// operator is shown ("Match 3 (Final)", engine.MatchLabel). The client prints +// the label as it is rather than composing its own from the number, so the +// dialog and this payload's message cannot name the same match two ways. A +// number of 0 means the match never got one (a bye placeholder, or a +// pre-numbering bracket); the label then falls back to the match's name or id +// rather than "Match 0". +func blockedMatchesPayload(blocking []engine.ReopenedMatch) []map[string]any { + out := make([]map[string]any, 0, len(blocking)) + for _, b := range blocking { + out = append(out, map[string]any{"id": b.ID, "number": b.Number, "label": engine.MatchLabel(b)}) + } + return out +} + +// broadcastReopenedDownstream announces the downstream matches a confirmed +// reopen or correction reopened: match_updated for each (each is a distinct +// match from the one the operator acted on, so a client watching only that +// court or match must hear its verdict was cleared), and +// competitor_status_updated for each one whose cleared verdict was a +// withdrawal, since the engine restored the competitor it barred +// (engine.ReopenedMatch.Restored). One helper for every door that can reopen +// downstream, so none of them can announce the reopen and miss the restore. +func broadcastReopenedDownstream(hub Broadcaster, compID string, reopened []engine.ReopenedMatch) { + for _, r := range reopened { + hub.Broadcast(EventMatchUpdated, gin.H{"competitionId": compID, "matchId": r.ID}) + if r.Restored != nil { + hub.Broadcast(EventCompetitorStatusUpdated, gin.H{"competitionId": compID, "status": r.Restored}) + } + } +} + // classifyRosterWriteError maps one of the participant-roster write sentinel // errors -- returned by Store.AddParticipant, Store.SaveParticipants, // Store.UpdateParticipant, Store.BulkCheckIn, and every other write that diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index 922db6f9c..6bbeea5c0 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -625,6 +625,34 @@ func checkUniqueCompFieldsTolerant(eng *engine.Engine, name, prefix, excludeID s return eng.CheckUniqueCompFields(name, prefix, excludeID, true) } +// validateRankOverrides checks a pool-rank override request's ranks (the +// group form, or the single form as a one-entry group) against what the +// request alone can show: every entry names a player and a positive rank, and +// no player or rank appears twice. The cap is an absolute overflow guard, +// defense-in-depth against weird stale-pool or LoadPools-error edge cases; +// the rank is checked against the pool's actual size, and the id against its +// roster (poolHasPlayerID), once the pool is loaded. +func validateRankOverrides(ranks []engine.RankOverride) error { + players := make(map[string]bool, len(ranks)) + places := make(map[int]bool, len(ranks)) + for _, r := range ranks { + switch { + case r.PlayerID == "": + return fmt.Errorf("playerId is required") + case r.Rank <= 0: + return fmt.Errorf("rank must be a positive integer") + case r.Rank > helper.MaxRankOverride: + return fmt.Errorf("rank must be a positive integer ≤ %d", helper.MaxRankOverride) + case players[r.PlayerID]: + return fmt.Errorf("playerId %q is given more than one rank", r.PlayerID) + case places[r.Rank]: + return fmt.Errorf("rank %d is given to more than one player", r.Rank) + } + players[r.PlayerID], places[r.Rank] = true, true + } + return nil +} + // poolHasPlayerID validates the pool-rank override request's target id // against the pool's OWN roster rather than trusting it verbatim from the // request. playerID is REQUIRED (operator ruling bc-pnum): a pool-rank @@ -632,7 +660,7 @@ func checkUniqueCompFieldsTolerant(eng *engine.Engine, name, prefix, excludeID s // playerName/playerDojo narrowing path left to fall back to. playerID must // name a player actually in this pool; a wrong/foreign/blank id is a 400, // never a silent name-based guess. The override is then saved keyed by -// playerID alone (SaveRankOverrideChanged), so no name/dojo lookup is +// playerID alone (SaveRankOverridesChanged), so no name/dojo lookup is // needed once this check passes. func poolHasPlayerID(players []domain.Player, playerID string) error { if playerID == "" { @@ -2671,25 +2699,44 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // to send. PlayerID string `json:"playerId"` Rank int `json:"rank"` + // Ranks is the group form, sent instead of playerId/rank: every + // rank of a chusen's order at once, recorded and answered for as + // ONE change (engine.OverridePoolRanks), so the knockout check + // sees only the order the operator entered. Sent one rank at a + // time, the order passed through states nobody chose, and a + // knockout match whose place the final order keeps was named and + // reopened. Each entry is resolved by id only, as the single form. + Ranks []struct { + PlayerID string `json:"playerId"` + Rank int `json:"rank"` + } `json:"ranks"` + // ForceDownstreamReopen is the operator's confirmation of a 409 + // downstream_knockout_played refusal: the new order moves a + // qualifier out of a knockout match the old one already fought, + // and confirming reopens that match with the new qualifier seated + // (engine.OverridePoolRanks). Never past downstream_knockout_running. + ForceDownstreamReopen bool `json:"forceDownstreamReopen"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - playerID := strings.TrimSpace(req.PlayerID) - if playerID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "playerId is required"}) - return - } - if req.Rank <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "rank must be a positive integer"}) - return + // The single form is a one-entry group, so both forms go through the + // same checks and the same engine call. + var ranks []engine.RankOverride + if len(req.Ranks) > 0 { + if strings.TrimSpace(req.PlayerID) != "" || req.Rank != 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "send either ranks or playerId and rank, not both"}) + return + } + for _, r := range req.Ranks { + ranks = append(ranks, engine.RankOverride{PlayerID: strings.TrimSpace(r.PlayerID), Rank: r.Rank}) + } + } else { + ranks = []engine.RankOverride{{PlayerID: strings.TrimSpace(req.PlayerID), Rank: req.Rank}} } - // Absolute overflow guard, defense-in-depth against weird - // stale-pool or LoadPools-error edge cases. The real semantic - // validation against the pool's actual size happens below. - if req.Rank > helper.MaxRankOverride { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("rank must be a positive integer ≤ %d", helper.MaxRankOverride)}) + if err := validateRankOverrides(ranks); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } comp, err := store.LoadCompetition(id) @@ -2701,8 +2748,12 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusNotFound, gin.H{"error": "competition not found"}) return } - if comp.Status != state.CompStatusPools { - c.JSON(http.StatusConflict, gin.H{"error": "rank overrides only accepted while competition is in pools stage"}) + // A pools + knockout competition takes one after its knockout has + // started too: a pool correction made then can leave a tie only a + // chusen settles, and a wrong chusen must stay fixable. What the new + // order does to the knockout is answered below, as for any override. + if !comp.AcceptsPoolRankOverride() { + c.JSON(http.StatusConflict, gin.H{"error": "rank overrides only accepted while competition is in pools stage, or in knockout stage for a pools + knockout competition"}) return } // Pool-size validation: rank within a pool is bounded by the @@ -2731,32 +2782,57 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en return } poolSize := len(targetPool.Players) - if req.Rank > poolSize { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("rank %d exceeds pool size %d", req.Rank, poolSize)}) - return - } - - // poolHasPlayerID's own doc comment has the id-only rationale. - if err := poolHasPlayerID(targetPool.Players, playerID); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return + for _, r := range ranks { + if r.Rank > poolSize { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("rank %d exceeds pool size %d", r.Rank, poolSize)}) + return + } + // poolHasPlayerID's own doc comment has the id-only rationale. + if err := poolHasPlayerID(targetPool.Players, r.PlayerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } } - changed, err := store.SaveRankOverrideChanged(id, poolId, playerID, req.Rank) + // A rank override moves the pool's order without a match write, so it + // answers for the knockout the pool feeds exactly as a pool result + // correction does: the same 409s, the same confirmation, the same + // reopen (engine.OverridePoolRanks), once, for the whole group. + var reopened []engine.ReopenedMatch + changed, err := eng.OverridePoolRanks(id, poolId, ranks, engine.ForceOptions{ + Force: req.ForceDownstreamReopen, + Reopened: &reopened, + }) if err != nil { - internalError(c, err) + switch { + case respondIfDownstreamKnockoutRunning(c, err): + case respondIfDownstreamKnockoutPlayed(c, err): + case respondIfEngineWriteError(c, err): + default: + internalError(c, err) + } return } if changed { hub.Broadcast(EventTournamentUpdated, nil) } - c.Status(http.StatusOK) + broadcastReopenedDownstream(hub, id, reopened) + if changed && comp.Format == state.CompFormatMixed { + // A new order can complete a pool (a chusen settles its last + // tie): seating a pool's slots for the first time is the + // auto-complete's job, which otherwise waited for the next score + // write. The full door, not the after-write one: no match was + // written, but the pool's standings moved. + tryAutoCompletePools(c, eng, hub, id) + } + c.JSON(http.StatusOK, gin.H{"reopenedMatches": blockedMatchesPayload(reopened)}) }) // GET /competitions/:id/chusen-candidates // Consequential team-pool ties the daihyosen left undetermined (a cycle / // all-drawn); the operator settles each by chusen (drawing lots), recorded via - // the override-rank endpoint. Admin-gated: RegisterCompetitionHandlers is + // the override-rank endpoint. "recorded" lists the ties a chusen already + // settled, with the recorded ranks, so a wrong order can be changed. Admin-gated: RegisterCompetitionHandlers is // mounted under the authenticated router group, so callers must send the // tournament password header (unlike the public league-tiebreak candidates). r.GET("/competitions/:id/chusen-candidates", func(c *gin.Context) { @@ -2764,9 +2840,9 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !ok { return } - candidates, err := eng.ChusenCandidates(id) + report, err := eng.ChusenStatus(id) if err != nil { - // ChusenCandidates loads overrides.json directly. A corrupt file + // ChusenStatus loads overrides.json directly. A corrupt file // is operator-recoverable state (repair via DELETE .../overrides), // not a server fault, so it gets the same terminal 422 every other // LoadOverrides-reaching endpoint answers with. @@ -2776,28 +2852,42 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en respondEngineError(c, err) return } - out := make([]gin.H, 0, len(candidates)) - for _, g := range candidates { - names := make([]string, len(g.Teams)) - for i, t := range g.Teams { - names[i] = t.Player.Name + groupsJSON := func(groups []engine.ChusenGroup) []gin.H { + out := make([]gin.H, 0, len(groups)) + for _, g := range groups { + names := make([]string, len(g.Teams)) + for i, t := range g.Teams { + names[i] = t.Player.Name + } + // teams carries id/dojo alongside each name (bc-cse), via the + // same competitorRef shape GET /league-tiebreak/candidates emits + // (PR #416 finding 6), so the SPA's chusen resolver can record + // the order through PUT .../override-rank's ranks group, each + // team by playerId, the same identity disambiguation the + // operator rule requires. Team names are supposed to be unique even + // across dojos (checkNewTeamNameCollisions), but that check has + // one documented hole -- an unreadable config.md disables it for + // that write, logged and allowed through (engine/chusen.go) -- + // so a same-name collision can exist on disk. teamNames alone + // (kept for older clients) cannot tell such a pair apart; this + // hardens the wire format for that hole and keeps it consistent + // with the individual override path. + group := gin.H{"poolName": g.PoolName, "teamNames": names, "teams": competitorRefsFrom(g.Teams), "minPosition": g.MinPosition} + if g.Ranks != nil { + // A recorded group carries each team's recorded rank, + // parallel to teams, so the SPA shows and pre-fills the + // order that was recorded without inferring it from the + // array position. + group["ranks"] = g.Ranks + } + out = append(out, group) } - // teams carries id/dojo alongside each name (bc-cse), via the - // same competitorRef shape GET /league-tiebreak/candidates emits - // (PR #416 finding 6), so the SPA's chusen resolver can call - // PUT .../override-rank with playerId (falling back to - // playerDojo), the same identity disambiguation the operator - // rule requires. Team names are supposed to be unique even - // across dojos (checkNewTeamNameCollisions), but that check has - // one documented hole -- an unreadable config.md disables it for - // that write, logged and allowed through (engine/chusen.go) -- - // so a same-name collision can exist on disk. teamNames alone - // (kept for older clients) cannot tell such a pair apart; this - // hardens the wire format for that hole and keeps it consistent - // with the individual override path. - out = append(out, gin.H{"poolName": g.PoolName, "teamNames": names, "teams": competitorRefsFrom(g.Teams), "minPosition": g.MinPosition}) - } - c.JSON(http.StatusOK, gin.H{"candidates": out}) + return out + } + // recorded lists the groups a chusen already settled, so a chusen + // recorded in the wrong order can be changed from the UI through the + // same override-rank door. + c.JSON(http.StatusOK, gin.H{"candidates": groupsJSON(report.Pending), "recorded": groupsJSON(report.Recorded)}) }) r.DELETE("/competitions/:id/overrides", RequireElevatedPassword(elevated), func(c *gin.Context) { diff --git a/internal/mobileapp/handlers_competition_test.go b/internal/mobileapp/handlers_competition_test.go index 96f9381e2..3427c05de 100644 --- a/internal/mobileapp/handlers_competition_test.go +++ b/internal/mobileapp/handlers_competition_test.go @@ -4767,10 +4767,12 @@ func TestChusenCandidates_NotFound(t *testing.T) { // (or dojo) to call PUT .../override-rank unambiguously: two teams CAN // legally share a display name from different dojos (operator identity // rule), and teamNames alone cannot tell them apart. -func TestChusenCandidates_IncludesTeamIdentity(t *testing.T) { - r, store, eng, _, _ := setupTestRouter(t) - compID := "chusen-identity" - +// seedChusenCycle stores a three-team league pool (Alpha, Beta, Gamma, ids +// "-id") whose every encounter was drawn, injects the daihyosen round and +// scores it into the cycle Alpha > Beta > Gamma > Alpha: a tie only a chusen +// settles, mirroring engine's TestChusenCandidates_CycleNeedsChusen. +func seedChusenCycle(t *testing.T, store *state.Store, eng *engine.Engine, compID string) { + t.Helper() require.NoError(t, store.SaveCompetition(&state.Competition{ ID: compID, Name: "Chusen Identity", Status: state.CompStatusPools, Kind: "team", TeamSize: 2, Format: state.CompFormatLeague, Courts: []string{"A"}, @@ -4782,8 +4784,6 @@ func TestChusenCandidates_IncludesTeamIdentity(t *testing.T) { {ID: "gamma-id", Name: "Gamma", Dojo: "Dojo C"}, }}, })) - // Fully drawn round robin puts all three teams in one tied group, - // mirroring engine's TestChusenCandidates_CycleNeedsChusen. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ {ID: "Pool A-0", SideA: "Alpha", SideB: "Beta", SideAID: "alpha-id", SideBID: "beta-id", Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), Court: "A"}, @@ -4796,8 +4796,6 @@ func TestChusenCandidates_IncludesTeamIdentity(t *testing.T) { _, err := eng.InjectPoolDaihyosenMatches(compID) require.NoError(t, err) - // Score the injected daihyosen bouts into a genuine cycle (no chusen - // override yet): Alpha>Beta, Beta>Gamma, Gamma>Alpha. all, err := store.LoadPoolMatches(compID) require.NoError(t, err) cycleBeats := map[string]string{"Alpha": "Beta", "Beta": "Gamma", "Gamma": "Alpha"} @@ -4813,6 +4811,62 @@ func TestChusenCandidates_IncludesTeamIdentity(t *testing.T) { } } require.NoError(t, store.SavePoolMatches(compID, all)) +} + +// TestChusenCandidates_ReportsTheRecordedChusen: once the operator records a +// chusen through PUT .../override-rank, GET .../chusen-candidates stops +// offering the tie and lists it under "recorded" instead, in the recorded +// order and with the recorded ranks, so a chusen recorded in the wrong order +// can be changed from the UI. +func TestChusenCandidates_ReportsTheRecordedChusen(t *testing.T) { + r, store, eng, _, _ := setupTestRouter(t) + compID := "chusen-recorded" + seedChusenCycle(t, store, eng, compID) + + get := func() (cands, recorded []any) { + t.Helper() + w := sendJSON(t, r, http.MethodGet, "/api/competitions/"+compID+"/chusen-candidates", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + cands, ok := body["candidates"].([]any) + require.True(t, ok, "candidates must be a JSON array") + recorded, ok = body["recorded"].([]any) + require.True(t, ok, "recorded must be a JSON array, never null") + return cands, recorded + } + cands, recorded := get() + require.Len(t, cands, 1) + assert.Empty(t, recorded, "a tie waiting for its chusen is not a recorded one") + _, hasRanks := cands[0].(map[string]any)["ranks"] + assert.False(t, hasRanks, "a pending group carries no recorded ranks") + + for rank, id := range []string{"gamma-id", "alpha-id", "beta-id"} { + w := sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/pools/Pool%20A/override-rank", + map[string]any{"playerId": id, "rank": rank + 1}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + + cands, recorded = get() + assert.Empty(t, cands, "the recorded chusen clears the candidate") + require.Len(t, recorded, 1, "the tie the chusen settled is listed as recorded") + group := recorded[0].(map[string]any) + assert.Equal(t, "Pool A", group["poolName"]) + assert.EqualValues(t, 1, group["minPosition"]) + teams := group["teams"].([]any) + require.Len(t, teams, 3) + ids := make([]string, len(teams)) + for i, raw := range teams { + ids[i] = raw.(map[string]any)["id"].(string) + } + assert.Equal(t, []string{"gamma-id", "alpha-id", "beta-id"}, ids, "teams arrive in the recorded order") + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, group["ranks"]) +} + +func TestChusenCandidates_IncludesTeamIdentity(t *testing.T) { + r, store, eng, _, _ := setupTestRouter(t) + compID := "chusen-identity" + seedChusenCycle(t, store, eng, compID) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/competitions/"+compID+"/chusen-candidates", nil) @@ -5804,7 +5858,7 @@ func TestDeleteOverridesHandler_CorruptOverrides_ForceRepairs(t *testing.T) { } // TestChusenCandidatesHandler_CorruptOverrides_TerminalError is bc-pnum gap 3: -// GET /api/competitions/:id/chusen-candidates calls engine.ChusenCandidates, +// GET /api/competitions/:id/chusen-candidates calls engine.ChusenStatus, // which loads overrides.json directly (and also indirectly via // CalculatePoolStandings). Before this fix an unmapped state.ErrCorruptOverrides // fell through to the handler's own opaque-500 branch; the fix maps it to the diff --git a/internal/mobileapp/handlers_daihyosen.go b/internal/mobileapp/handlers_daihyosen.go index d550b1e2c..9bd8cd770 100644 --- a/internal/mobileapp/handlers_daihyosen.go +++ b/internal/mobileapp/handlers_daihyosen.go @@ -99,7 +99,7 @@ func respondIfValidationError(c *gin.Context, err error) bool { // DaihyosenEngine is the consumer-boundary view of *engine.Engine used // by the daihyosen handler. Mirrors engine.Engine.AddDaihyosen + -// RecordMatchResultWithIneligibilityTx + MaybeAutoCompletePools. +// RecordMatchResultWithIneligibilityTx + MaybeAutoCompletePoolsAfterWrite. // // Defined as a named local interface (rather than reusing ScoringEngine) // because AddDaihyosen is not on the existing ScoringEngine interface, @@ -110,7 +110,7 @@ func respondIfValidationError(c *gin.Context, err error) bool { type DaihyosenEngine interface { AddDaihyosen(compID, matchID string, sideA, sideB engine.TeamSummary, isPool bool, sideAEligible, sideBEligible int) (*state.SubMatchResult, error) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, matchID string, result *state.MatchResult, opts ...engine.ForceOptions) (*domain.CompetitorStatus, error) - MaybeAutoCompletePools(compID string) (engine.AutoCompleteOutcome, error) + MaybeAutoCompletePoolsAfterWrite(compID string, written ...state.MatchResult) (engine.AutoCompleteOutcome, error) } // DaihyosenStore is the consumer-boundary view of *state.Store used by @@ -323,7 +323,17 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da return nil } - sideASummary, sideBSummary := engine.ComputeTeamSummary(match.SubResults, match.SideA, match.SideB) + // credit is state.DefaultWinCreditSide's answer for THIS match + // (bc-cse: ComputeTeamSummary's credit is no longer optional). It is + // always domain.MatchSideNone here in practice: AddDaihyosen only + // ever applies to a still-tied, still-RUNNING encounter (never a + // completed one), and DefaultWinCreditSide credits nobody unless the + // match is completed. Computed properly anyway rather than passed as + // a bare domain.MatchSideNone, so this call site goes through the + // same canonical derivation as every other TeamResult reader instead + // of hand-asserting the invariant. + credit := state.DefaultWinCreditSide(match.Status, match.Decision, match.DecisionBy, match.Attribution()) + sideASummary, sideBSummary := engine.ComputeTeamSummary(match.SubResults, match.SideA, match.SideB, credit) // Count eligible competitors per side under the same lock (CHK026). // Pre-Slice-7 there are no explicit rosters; eligibility is tracked @@ -419,8 +429,10 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da "result": matchForBroadcast(updated), }) - // Inline auto-complete check (same pattern as tryAutoCompletePools). - outcome, autoErr := eng.MaybeAutoCompletePools(id) + // Inline auto-complete check (same pattern as + // tryAutoCompletePoolsAfterWrite). A rep bout is only ever added to a + // knockout match, so in knockout status this skips the pool pass. + outcome, autoErr := eng.MaybeAutoCompletePoolsAfterWrite(id, updated) switch { case autoErr != nil: log.Printf("MaybeAutoCompletePools(%s) after daihyosen: %v", id, autoErr) diff --git a/internal/mobileapp/handlers_daihyosen_test.go b/internal/mobileapp/handlers_daihyosen_test.go index 1af8556ff..3f8f802e7 100644 --- a/internal/mobileapp/handlers_daihyosen_test.go +++ b/internal/mobileapp/handlers_daihyosen_test.go @@ -353,7 +353,7 @@ func TestDaihyosenHandler_HappyPath(t *testing.T) { // while every other call the endpoint makes stays production code. type startedAutoEngine struct{ *engine.Engine } -func (startedAutoEngine) MaybeAutoCompletePools(string) (engine.AutoCompleteOutcome, error) { +func (startedAutoEngine) MaybeAutoCompletePoolsAfterWrite(string, ...state.MatchResult) (engine.AutoCompleteOutcome, error) { return engine.AutoCompleteStarted, nil } @@ -626,18 +626,18 @@ func TestRemoveDaihyosen_PoolMatchWithStaleWinnerIDSucceeds(t *testing.T) { } } -// TestRemoveDaihyosen_CorruptOverrides_TerminalError is bc-pnum gap 3: DELETE -// .../daihyosen writes through RecordMatchResultWithIneligibilityTx, exactly -// as the previous test's pool-match removal does. When the parent match is a -// POOL match under a MIXED competition (reachable only via a legacy/hand- -// edited row carrying a Position=-1 sub, per that write's own doc comment -- -// AddDaihyosen itself rejects a pool id, so POST can never build this shape), -// the mp-e2k1 mixed-pool guard's computeStandingsFrom call reaches the same -// LoadOverrides call the score/decision endpoints already handle. Before this -// fix that fell through to internalError's 500; the fix adds the shared -// respondIfCorruptOverrides check between respondIfValidationError and the -// default 500 arm. -func TestRemoveDaihyosen_CorruptOverrides_TerminalError(t *testing.T) { +// TestRemoveDaihyosen_RunningWriteReadsNoStandings: DELETE .../daihyosen +// writes through RecordMatchResultWithIneligibilityTx with the match back to +// RUNNING. When the parent is a POOL match under a MIXED competition +// (reachable only via a legacy/hand-edited row carrying a Position=-1 sub, +// since AddDaihyosen rejects a pool id), that write used to run the old +// qualifier guard's standings read, so a corrupt overrides.json refused it +// (bc-pnum gap 3 mapped that to 422). A running write leaves its pool +// incomplete and so can move no qualifier: the requalification check skips +// it before reading anything, and the removal lands whatever state +// overrides.json is in. The 422 mapping stays for every write that does read +// standings. +func TestRemoveDaihyosen_RunningWriteReadsNoStandings(t *testing.T) { r, store, _, _, dir := setupDaihyosenTestRouter(t) compID := "corrupt-ov-rm-daihyosen" require.NoError(t, store.SaveCompetition(&state.Competition{ @@ -653,6 +653,8 @@ func TestRemoveDaihyosen_CorruptOverrides_TerminalError(t *testing.T) { }, })) + seatUnresolvedPoolAKnockout(t, store, compID) + overridesPath := filepath.Join(dir, "competitions", compID, "overrides.json") require.NoError(t, os.WriteFile(overridesPath, []byte("{not valid json"), 0o600)) @@ -660,12 +662,11 @@ func TestRemoveDaihyosen_CorruptOverrides_TerminalError(t *testing.T) { "/api/competitions/"+compID+"/matches/Pool%20A-0/daihyosen", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) - require.Equal(t, http.StatusUnprocessableEntity, w.Code, "body: %s", w.Body.String()) - assert.Contains(t, w.Body.String(), "corrupt_overrides") + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) matches, err := store.LoadPoolMatches(compID) require.NoError(t, err) require.Len(t, matches, 1) - require.Len(t, matches[0].SubResults, 1, "the rejected write must not have landed") + assert.Empty(t, matches[0].SubResults, "the daihyosen row is removed") assert.Equal(t, state.MatchStatusRunning, matches[0].Status) } diff --git a/internal/mobileapp/handlers_decision.go b/internal/mobileapp/handlers_decision.go index 42932b804..e43d65b8e 100644 --- a/internal/mobileapp/handlers_decision.go +++ b/internal/mobileapp/handlers_decision.go @@ -47,7 +47,9 @@ type DecisionRequest struct { // guard (engine.DownstreamKnockoutPlayedError, HTTP 409 // downstream_knockout_played): a decision that changes an already- // propagated bracket winner while a downstream match carries a result of - // its own is refused by default. Same field name and contract as every + // its own is refused by default, as is a decision on a mixed + // competition's POOL match that moves a qualifier the knockout already + // played. Same field name and contract as every // other knockout-correction write (see scoreRequestBody.ForceDownstreamReopen // in handlers_match.go); a SEPARATE field from Force above, which answers // a different question (T103's decision-lock override): this maps to @@ -120,6 +122,27 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // A stamp implausibly far in the server's future is refused outright, + // before either write path below opens a transaction, exactly as PUT + // /score refuses one: a decision competes on timestamps, so neither + // honouring nor zeroing such a stamp is safe (modifiedAtRefuseSkewMs). + // Nothing is written. It runs ahead of the both-barred carve-out, + // whose write competes on timestamps too. + if serverNowMs, aheadMs, refuse := clientClockSkew(req.ModifiedAt); refuse { + respondClockSkew(c, serverNowMs, aheadMs) + return + } + // A negative stamp is garbage; the clamp turns it into the unstamped + // bypass, which is always safe (mp-y3nk). + req.ModifiedAt = clampClientModifiedAt(req.ModifiedAt) + // bc-cse item 10: the ONE hikiwake shape this endpoint accepts, ahead + // of Validate() (which otherwise 400s every hikiwake -- "use /score + // for fought/hikiwake"). Any hikiwake that is not this exact + // both-barred pool/league shape falls through unchanged to that same + // 400. + if handleBothSidesBarredHikiwake(c, eng, store, tx, hub, id, mid, req) { + return + } if err := req.Validate(); err != nil { var verr *ValidationError if errors.As(err, &verr) { @@ -152,60 +175,30 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe // handle, so the per-comp lock is acquired exactly once for the // entire mutation. var ( - result *state.MatchResult - status *domain.CompetitorStatus - engErr error - reasonErr *ValidationError + result *state.MatchResult + status *domain.CompetitorStatus + engErr error // bc-kcdg: reopenedDownstream collects the IDs of any downstream // bracket match reopened by a forced correction, populated only // when req.ForceDownstreamReopen actually unblocked one. reopenedDownstream []engine.ReopenedMatch ) - // A stamp implausibly far in the server's future is refused outright, - // before the transaction opens, exactly as PUT /score refuses one: a - // decision now competes on timestamps, so neither honouring nor zeroing - // such a stamp is safe (modifiedAtRefuseSkewMs). Nothing is written. - if serverNowMs, aheadMs, refuse := clientClockSkew(req.ModifiedAt); refuse { - respondClockSkew(c, serverNowMs, aheadMs) - return - } - // A negative stamp is garbage; the clamp turns it into the unstamped - // bypass, which is always safe (mp-y3nk). - req.ModifiedAt = clampClientModifiedAt(req.ModifiedAt) reason := strings.TrimSpace(req.DecisionReason) txErr := tx.WithTransaction(id, func(stx state.StoreTx) error { - // mp-gmcg: a reason-less kachinuki reopen DEFERS its audit - // justification to whatever finalizes the match next. PUT /score - // collects it (applyCorrectionReasonUnderTx); this endpoint is the - // OTHER way to finalize a match, so it has to collect it too — a - // kiken recorded on a reopened encounter is still a result - // replacing one the operator had already declared final. + // A match reopened without a reason (ReopenPending) is ended here + // like any other: never refused for a missing reason (operator + // ruling 2026-09-25: a match can be reopened without any reason, + // and nothing is gated on that). The write below discharges the + // flag and keeps the decision's reason, if any, as the correction + // reason. The read is in-tx, so it is race-free against a + // concurrent finalization, and it fails CLOSED on a load error. // - // Checked in-tx, before the engine write, so the read is race-free - // against a concurrent finalization and a rejection costs no write. - // matchSnapshotOrErr fails CLOSED on a load error (unlike a - // best-effort error-swallowing read), so a dropped read can't - // finalize on an assumed-false ReopenPending and silently discard the - // mandatory reopen audit reason. - // - // The read is KACHINUKI-ONLY: ReopenPending is set exclusively by - // ReopenKachinukiMatch (which rejects non-kachinuki), so a - // non-kachinuki match can never carry it. Skip the whole (2-file) - // snapshot read for the common non-kachinuki decision — comp is - // already loaded above, so the gate itself costs no read (mp-gmcg - // review E3). snap stays zero-valued (ReopenPending false), so the - // checks below are correctly no-ops. - var snap matchSnapshot - if comp.IsKachinuki() { - var snapErr error - snap, _, snapErr = matchSnapshotOrErr(stx, id, mid, "reopen-pending") - if snapErr != nil { - return snapErr - } - } - if snap.ReopenPending && reason == "" { - reasonErr = &ValidationError{Field: "decisionReason", Message: ReopenNeedsReasonMessage} - return nil + // ReopenPending is set by engine.ReopenMatch, which since bc-tmfn + // reopens a match of any format that a withdrawal decided, so the + // read is not kachinuki-only. + snap, _, snapErr := matchSnapshotOrErr(stx, id, mid, "reopen-pending") + if snapErr != nil { + return snapErr } // Pass the TRIMMED reason (not req.DecisionReason): it is persisted // into DecisionReason, and dischargeReopenPendingUnderTx stores the @@ -240,90 +233,13 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe internalError(c, txErr) return } - if reasonErr != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": reasonErr.Error()}) - return - } if engErr != nil { - // Map engine.ValidationError → 400, NotFoundError → 404, - // IneligibleCompetitorError → 409 (FR-035), - // ErrDecisionLocked → 409 (T103/CHK024). - var alreadyIneligErr *engine.AlreadyIneligibleError - var ineligErr *engine.IneligibleCompetitorError - var engNotFoundErr *engine.NotFoundError - switch { - case errors.Is(engErr, engine.ErrMatchSuperseded): - // bc-lww1. REACHABLE since mp-jnvl: the SPA stamps decision - // writes (api_client.recordDecision) and RecordDecisionTx puts - // that stamp on its MatchResult, so ApplyByTimestamp no longer - // takes the unstamped bypass and a decision can lose to a newer - // stored result -- the same way a score write can. An unstamped - // decision (an older client, or an engine-internal caller) still - // takes the bypass and always applies. Mapping it was already - // right for the reason the two daihyosen paths are: this is the - // LAST arm a future writer would remember to add, and the - // default below is internalError -> 500. The SPA queues - // /decision as a terminal write (_enqueueTerminalWrite, kind - // 'decision') and retries 5xx indefinitely, so an unmapped - // supersede here would not merely mis-report a dropped write, - // it would poison the offline queue with one that can never - // succeed. - respondSuperseded(c) - case errors.As(engErr, &alreadyIneligErr): - // T105/CHK047: concurrent kiken, another operator already - // recorded ineligibility for this player on a different match. - // U1: reasonHuman carries the volunteer-readable gloss - // alongside the raw kendo-term reason. - c.JSON(http.StatusConflict, gin.H{ - "error": "already_ineligible", - "playerId": alreadyIneligErr.PlayerID, - "matchId": alreadyIneligErr.MatchID, - "reason": alreadyIneligErr.Reason, - "reasonHuman": domain.ResolveReasonHuman(alreadyIneligErr.Reason), - }) - case errors.As(engErr, &ineligErr): - c.JSON(http.StatusConflict, gin.H{ - "error": "ineligible_competitor", - "playerId": ineligErr.PlayerID, - "reason": ineligErr.Reason, - "reasonHuman": domain.ResolveReasonHuman(ineligErr.Reason), - }) - case errors.Is(engErr, engine.ErrDecisionLocked): - c.JSON(http.StatusConflict, gin.H{ - "error": "decision_locked", - "reason": engErr.Error(), - }) - case respondIfDownstreamKnockoutPlayed(c, engErr): - // bc-kcdg: this decision would change an already-propagated - // bracket winner while a downstream match carries a result of - // its own. Fixed wire contract, shared with every other - // knockout-correction write (see - // respondIfDownstreamKnockoutPlayed's doc comment); - // respondIfDownstreamKnockoutPlayed already wrote the response. - // Retry with forceDownstreamReopen:true once confirmed. - case respondIfDownstreamKnockoutScored(c, engErr): - // mp-e2k1: this decision writes a pool match (e.g. kiken/ - // fusenpai on a Mixed competition's pool phase) via - // RecordDecisionTx -> RecordMatchResultWithIneligibilityTx, which - // would displace a qualifying finisher already scored into a - // downstream bracket match. Same fixed wire contract as - // /score's mapping (see respondIfDownstreamKnockoutScored's - // doc comment); before this, /decision fell through to - // respondIfEngineWriteError/internalError, a generic 500 the - // offline write queue retries forever for a write that can - // never win. - case errors.As(engErr, &engNotFoundErr): - c.JSON(http.StatusNotFound, gin.H{"error": engNotFoundErr.Error()}) - default: - // engine.ValidationError → 400 and a corrupt overrides.json → 422 - // (computeStandingsFrom, reached via RecordDecisionTx -> - // RecordMatchResultWithIneligibilityTx's mp-e2k1 mixed-pool guard) - // both fall through respondIfEngineWriteError. - if respondIfEngineWriteError(c, engErr) { - return - } - internalError(c, engErr) - } + // bc-cse finding 3: shared with handleBothSidesBarredHikiwake's + // own write below, so both doors answer the sentinels they have + // in common (superseded, downstream_knockout_running/played, + // corrupt overrides) identically rather than the carve-out + // falling through respondEngineError's 500 default for them. + respondDecisionEngineError(c, store, id, mid, engErr) return } @@ -336,17 +252,233 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe // just corrected; broadcast it too so a client watching only that // court/match learns its verdict was cleared (mirrors /score, // /override-winner, and /quick-score). - for _, reopened := range reopenedDownstream { - hub.Broadcast(EventMatchUpdated, gin.H{"competitionId": id, "matchId": reopened.ID}) - } + broadcastReopenedDownstream(hub, id, reopenedDownstream) if status != nil { hub.Broadcast(EventCompetitorStatusUpdated, gin.H{ "competitionId": id, "status": status, }) } - tryAutoCompletePools(c, eng, hub, id) + // A decision always closes the match it rules on, so what the + // after-write check needs (which match, left completed) is known + // without reading the returned result. + tryAutoCompletePoolsAfterWrite(c, eng, hub, id, state.MatchResult{ID: mid, Status: state.MatchStatusCompleted}) c.JSON(http.StatusOK, result) }) } + +// respondDecisionEngineError maps engErr from a write on the /decision +// endpoint to its HTTP response. It is the ONE switch both the main decision +// flow above and handleBothSidesBarredHikiwake's own write below call (bc-cse +// finding 3): before this was extracted, the carve-out mapped its errors +// through the generic respondEngineError, which only classifies +// *engine.NotFoundError (404) and *engine.ValidationError (400) and defaults +// everything else -- including engine.ErrMatchSuperseded, +// *engine.DownstreamKnockoutPlayedError, *engine.DownstreamKnockoutRunningError, +// and state.ErrCorruptOverrides -- to a 500. The SPA's offline write queue +// retries 5xx forever (the mp-q8c6 poisoned-queue pattern), so a superseded +// carve-out write could never win a retry, and the other three are terminal, +// operator-actionable conflicts that must never look like a server fault. +func respondDecisionEngineError(c *gin.Context, store CompetitionStore, compID, matchID string, engErr error) { + // Map engine.ValidationError → 400, NotFoundError → 404, + // IneligibleCompetitorError → 409 (FR-035), + // ErrDecisionLocked → 409 (T103/CHK024). + var alreadyIneligErr *engine.AlreadyIneligibleError + var ineligErr *engine.IneligibleCompetitorError + var engNotFoundErr *engine.NotFoundError + switch { + case errors.Is(engErr, engine.ErrMatchSuperseded): + // bc-lww1. REACHABLE since mp-jnvl: the SPA stamps decision + // writes (api_client.recordDecision) and RecordDecisionTx puts + // that stamp on its MatchResult, so ApplyByTimestamp no longer + // takes the unstamped bypass and a decision can lose to a newer + // stored result -- the same way a score write can. An unstamped + // decision (an older client, or an engine-internal caller) still + // takes the bypass and always applies. Mapping it was already + // right for the reason the two daihyosen paths are: this is the + // LAST arm a future writer would remember to add, and the + // default below is internalError -> 500. The SPA queues + // /decision as a terminal write (_enqueueTerminalWrite, kind + // 'decision') and retries 5xx indefinitely, so an unmapped + // supersede here would not merely mis-report a dropped write, + // it would poison the offline queue with one that can never + // succeed. + respondSuperseded(c) + case errors.As(engErr, &alreadyIneligErr): + // T105/CHK047: concurrent kiken, another operator already + // recorded ineligibility for this player on a different + // match. bc-rawm/bc-cse: reasonHuman is ONE operator + // sentence naming the match and the remedy, not the raw + // kendo-term reason. + c.JSON(http.StatusConflict, gin.H{ + "error": "already_ineligible", + "playerId": alreadyIneligErr.PlayerID, + "matchId": alreadyIneligErr.MatchID, + "reason": alreadyIneligErr.Reason, + "reasonHuman": reasonHumanForBarredCompetitor(store, compID, matchID, alreadyIneligErr.PlayerID, alreadyIneligErr.Reason, alreadyIneligErr.MatchID, alreadyIneligErr.Decision), + }) + case errors.As(engErr, &ineligErr): + // Simultaneous tells the simultaneity gate's sentence + // (already complete in Reason) apart from a barred-status + // refusal -- even one whose own MatchID is empty (a status + // set directly via POST /competitor-status) -- exactly as + // the /score handler discriminates. + reasonHuman := ineligErr.Reason + switch { + case ineligErr.Simultaneous: + // reasonHuman stays ineligErr.Reason, already complete. + case ineligErr.BothSidesBarred: + // bc-cse item 10: neither side has an opponent to hand the + // default win to, so the single-sided builder's remedy + // clauses do not apply. + reasonHuman = bothSidesBarredReasonHuman(store, compID, matchID) + default: + reasonHuman = reasonHumanForBarredCompetitor(store, compID, matchID, ineligErr.PlayerID, ineligErr.Reason, ineligErr.MatchID, ineligErr.Decision) + } + c.JSON(http.StatusConflict, gin.H{ + "error": "ineligible_competitor", + "playerId": ineligErr.PlayerID, + "reason": ineligErr.Reason, + "reasonHuman": reasonHuman, + }) + case errors.Is(engErr, engine.ErrDecisionLocked): + c.JSON(http.StatusConflict, gin.H{ + "error": "decision_locked", + "reason": engErr.Error(), + }) + case respondIfDownstreamKnockoutRunning(c, engErr): + // A decision on a mixed competition's POOL match that would + // move a qualifier out of a knockout match being fought now: + // terminal, not confirmable (see + // respondIfDownstreamKnockoutRunning's doc comment). + case respondIfDownstreamKnockoutPlayed(c, engErr): + // bc-kcdg: this decision would change an already-propagated + // bracket winner while a downstream match carries a result of + // its own. Fixed wire contract, shared with every other + // knockout-correction write (see + // respondIfDownstreamKnockoutPlayed's doc comment); + // respondIfDownstreamKnockoutPlayed already wrote the response. + // Retry with forceDownstreamReopen:true once confirmed. A + // decision on a mixed competition's POOL match reaches this too + // when it moves a qualifier the knockout already played + // (qualifierChange names who moves). + case errors.As(engErr, &engNotFoundErr): + c.JSON(http.StatusNotFound, gin.H{"error": engNotFoundErr.Error()}) + default: + // engine.ValidationError → 400 and a corrupt overrides.json → 422 + // (computeStandingsFrom, reached via RecordDecisionTx -> + // RecordMatchResultWithIneligibilityTx's pool requalification + // check) + // both fall through respondIfEngineWriteError. + if respondIfEngineWriteError(c, engErr) { + return + } + internalError(c, engErr) + } +} + +// handleBothSidesBarredHikiwake handles bc-cse item 10's ONE accepted +// exception on POST /decision: {"decision":"hikiwake"} for a SCHEDULED pool +// or league match (engine.IsPoolMatchID covers both id shapes) whose BOTH +// sides are already barred elsewhere (engine.BarredSides). Neither +// competitor can fight, and there is no opponent to hand a default win to +// (both are equally unable to show up), so the encounter is recorded as a +// completed DRAW with no winner and no points. It writes no eligibility +// status of its own -- hikiwake is not a withdrawal decision +// (domain.IsWithdrawalDecisionStr excludes it), so +// RecordMatchResultWithIneligibilityTx's recordIneligibilityFromDecision +// never fires for it -- and it never goes through StartMatchTx, which +// would refuse it on either side's existing bar. +// +// A match can be reopened without a reason (operator ruling), and this door +// is never refused for one. It does settle the reopen, through +// dischargeReopenPendingUnderTx in the same transaction as the write: +// a match reopened without a reason carries ReopenPending (engine. +// reopenPoolMatch; a fusensho whose barred side is still barred reopens to +// scheduled, which is how a both-barred match can carry it), and the draw +// clears it and keeps the request's reason, if any, as the correction reason. +// +// Returns true when it fully answered the request (success, or a failure of +// ITS OWN write once the shape qualified); false when the shape does not +// qualify, so the caller falls through to the ordinary /decision flow -- +// which, for any OTHER hikiwake, is req.Validate()'s existing 400 ("use +// /score for fought/hikiwake"), unchanged from today. +func handleBothSidesBarredHikiwake(c *gin.Context, eng ScoringEngine, store CompetitionStore, txr CompetitionTransactor, hub Broadcaster, compID, matchID string, req DecisionRequest) bool { + if req.Decision != "hikiwake" || !engine.IsPoolMatchID(matchID) { + return false + } + reason := strings.TrimSpace(req.DecisionReason) + var ( + applied bool + result state.MatchResult + writeErr error + ) + txErr := txr.WithTransaction(compID, func(stx state.StoreTx) error { + poolMatches, err := stx.LoadPoolMatches(compID) + if err != nil { + return err + } + var m *state.MatchResult + for i := range poolMatches { + if poolMatches[i].ID == matchID { + m = &poolMatches[i] + break + } + } + if m == nil || m.Status != state.MatchStatusScheduled { + return nil + } + statuses, err := stx.LoadCompetitorStatus(compID) + if err != nil { + return err + } + a, b := engine.BarredSides(statuses, matchID, m.SideAID, m.SideBID) + if a == nil || b == nil { + return nil + } + write := &state.MatchResult{ + ID: matchID, SideA: m.SideA, SideB: m.SideB, SideAID: m.SideAID, SideBID: m.SideBID, + Status: state.MatchStatusCompleted, Decision: "hikiwake", DecisionReason: reason, + ModifiedAt: req.ModifiedAt, + } + if _, werr := eng.RecordMatchResultWithIneligibilityTx(stx, compID, matchID, write); werr != nil { + writeErr = werr + return nil + } + result = *write + applied = true + if m.ReopenPending { + return dischargeReopenPendingUnderTx(stx, compID, matchID, reason, false) + } + return nil + }) + if txErr != nil { + internalError(c, txErr) + return true + } + if !applied { + if writeErr != nil { + // The shape qualified (both barred, scheduled pool/league match) + // but the write itself failed on its own terms -- answered + // directly rather than silently dropped to the 400 fallback, + // which would misreport this as "unsupported decision". Routed + // through the same respondDecisionEngineError the main decision + // flow uses (bc-cse finding 3), not the generic respondEngineError: + // this write can lose the timestamp LWW race (engine.ErrMatchSuperseded) + // or trip a downstream-knockout guard exactly like the main flow, + // and respondEngineError's 500 default for those would poison the + // SPA's offline write queue instead of answering them terminally. + respondDecisionEngineError(c, store, compID, matchID, writeErr) + return true + } + return false + } + hub.Broadcast(EventMatchUpdated, gin.H{ + "competitionId": compID, + "results": matchesForBroadcast([]state.MatchResult{result}), + }) + tryAutoCompletePoolsAfterWrite(c, eng, hub, compID, result) + c.JSON(http.StatusOK, result) + return true +} diff --git a/internal/mobileapp/handlers_decision_test.go b/internal/mobileapp/handlers_decision_test.go index c56dd9d56..1ca772b8a 100644 --- a/internal/mobileapp/handlers_decision_test.go +++ b/internal/mobileapp/handlers_decision_test.go @@ -208,7 +208,7 @@ func TestDecisionHandler_NonEngiUnaffected(t *testing.T) { // decision-endpoint sibling of TestScoreHandler_CorruptOverrides_TerminalErrorThenRepairable // (handlers_match_test.go): RecordDecisionTx writes through // RecordMatchResultWithIneligibilityTx, which for a MIXED competition's pool -// match re-score runs the mp-e2k1 guard's computeStandingsFrom call -- the +// match write runs the pool requalification check's computeStandingsFrom call -- the // same LoadOverrides call the score handler's corrupt-overrides fix already // covered. Before this fix the decision handler's error switch had no branch // for state.ErrCorruptOverrides, so it fell through to the default @@ -233,6 +233,7 @@ func TestDecisionHandler_CorruptOverrides_TerminalError(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled}, })) + seatUnresolvedPoolAKnockout(t, store, compID) // Corrupt overrides.json directly: the competition directory must already // exist, which SaveCompetition above guaranteed. @@ -341,53 +342,3 @@ func TestDecisionHandler_DownstreamKnockoutPlayed_ForceReturns200(t *testing.T) "reopened in place and waiting to be fought again: running would hold the court, which deadlocked two reopened siblings") assert.Empty(t, b.Rounds[1][0].Winner, "the reopened match's stale verdict was cleared") } - -// TestDecisionHandler_DownstreamKnockoutScored_409Shape pins the FIXED wire -// contract (bc-cse finding 1): a kiken decision on a completed POOL match -// that would flip its qualifying finisher, while the knockout leaf that -// finisher's win already fed carries its own scored result, writes through -// RecordDecisionTx -> RecordMatchResultWithIneligibilityTx, which raises -// *engine.DownstreamKnockoutScoredError (mp-e2k1). Before this fix the -// decision handler's error switch had no arm for it, so it fell through to -// respondIfEngineWriteError/internalError, a generic HTTP 500 the SPA's -// offline write queue retries forever for a write that can never win -// (mp-q8c6 poisoned-queue pattern). Revert only the -// respondIfDownstreamKnockoutScored case in handlers_decision.go's switch to -// see this go red (500 instead of 409). -// -// decisionBy "aka" names sideA (A1) as the withdrawing loser, so A2 (sideB) -// becomes the winner -- flipping Pool A's finisher exactly like -// TestScoreHandler_DownstreamKnockoutScored_409Shape's re-score does. -func TestDecisionHandler_DownstreamKnockoutScored_409Shape(t *testing.T) { - r, store, _, _, tempDir := setupTestRouter(t) - defer os.RemoveAll(tempDir) - - compID := "e2k1-decision-409" - seedMixedCompWithScoredKnockoutFinisher(t, store, compID) - - body, _ := json.Marshal(DecisionRequest{Decision: "kiken-voluntary", DecisionBy: "aka"}) - req, err := http.NewRequest(http.MethodPost, - "/api/competitions/"+compID+"/matches/Pool A-0/decision", - bytes.NewBuffer(body)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) - var resp map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, "downstream_knockout_scored", resp["error"]) - assert.Equal(t, "Pool A", resp["pool"]) - assert.Equal(t, "A1", resp["finisher"]) - assert.Equal(t, "m-r1-0", resp["matchId"]) - assert.NotEmpty(t, resp["message"]) - - stored, err := store.LoadPoolMatches(compID) - require.NoError(t, err) - for _, m := range stored { - if m.ID == "Pool A-0" { - assert.Equal(t, "A1", m.Winner, "a refusal must leave the pool match untouched") - } - } -} diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index e4f808ad9..4324e9674 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -188,7 +188,10 @@ func annotateBracketQueuePositions(b *state.Bracket) { counter := 0 for _, e := range entries { - if e.m.Status == state.MatchStatusScheduled { + // bc-cse: mirrors state.DeriveQueuePositions' own IneligibleSides + // skip -- a scheduled match with a currently barred side is not + // queued (position 0), so "Next up" and on-deck alerts skip it. + if e.m.Status == state.MatchStatusScheduled && e.m.IneligibleSides == nil { counter++ e.m.QueuePosition = counter } else { @@ -198,6 +201,112 @@ func annotateBracketQueuePositions(b *state.Bracket) { } } +// anyScheduledMatchHasBothSides reports whether poolMatches or bracket carry +// at least one SCHEDULED match with both side ids stamped -- the ONLY shape +// annotateIneligibleSides can ever act on (engine.BarredSides needs an id to +// look anyone up). Gates the LoadCompetitorStatus read in the viewer +// handlers behind it, so a competition with no scheduled match yet (or one +// whose scheduled rows are all byes/unresolved feeders) never pays for a +// status-file read it cannot use. +func anyScheduledMatchHasBothSides(poolMatches []state.MatchResult, bracket *state.Bracket) bool { + hasBoth := func(status state.MatchStatus, idA, idB string) bool { + return status == state.MatchStatusScheduled && idA != "" && idB != "" + } + for i := range poolMatches { + if hasBoth(poolMatches[i].Status, poolMatches[i].SideAID, poolMatches[i].SideBID) { + return true + } + } + if bracket == nil { + return false + } + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + bm := &bracket.Rounds[ri][mi] + if hasBoth(bm.Status, bm.SideAID, bm.SideBID) { + return true + } + } + } + if bm := bracket.ThirdPlaceMatch; bm != nil && hasBoth(bm.Status, bm.SideAID, bm.SideBID) { + return true + } + return false +} + +// annotateIneligibleSides stamps state.IneligibleSidesAnnotation (bc-cse) on +// every SCHEDULED pool match and bracket match whose resolved side ids are +// currently barred by a withdrawal recorded on a DIFFERENT match +// (engine.BarredSides, the SAME check StartMatchTx gates a write on), so a +// match LIST can grey/skip the row without attempting the write first. +// +// Read-only, request-time only, exactly like annotateQueuePositions: called +// on the copy a viewer endpoint is about to serve, NEVER on a slice/bracket +// destined for a save, which is what keeps the annotation off +// pool-matches.csv and bracket.json (see the field's own doc comment, +// models.go). MUST run BEFORE annotateQueuePositions/ +// annotateBracketQueuePositions, which read the stamp back to decide +// whether a barred match counts toward the queue (position 0 for one). +func annotateIneligibleSides(poolMatches []state.MatchResult, bracket *state.Bracket, statuses map[string]domain.CompetitorStatus) { + if len(statuses) == 0 { + return + } + stamp := func(status state.MatchStatus, matchID, sideAID, sideBID string) *state.IneligibleSidesAnnotation { + if status != state.MatchStatusScheduled { + return nil + } + a, b := engine.BarredSides(statuses, matchID, sideAID, sideBID) + if a == nil && b == nil { + return nil + } + // A side is stamped ONLY when its barring reason actually parses + // (SplitStatusReason's ok); Go's IneligibleSidesAnnotation.A/B carry + // `json:"...,omitempty"`, so an unparsed side left at its zero value + // "" is indistinguishable on the wire from one never barred at all. + // A caller that checks the annotation by nil-ness alone (this + // match's row exists, so it must be barred) would then disagree + // with one that checks the field VALUE (no "a"/"b" key present, so + // not barred) -- bc-cse: stamping an all-unparsed, still-non-nil + // {} annotation is exactly that trap. When NEITHER side's reason + // parses, the whole annotation is left nil so both kinds of caller + // agree the match is not barred. + out := &state.IneligibleSidesAnnotation{} + parsed := false + if a != nil { + if decision, _, ok := domain.SplitStatusReason(a.Reason); ok { + out.A = decision + parsed = true + } + } + if b != nil { + if decision, _, ok := domain.SplitStatusReason(b.Reason); ok { + out.B = decision + parsed = true + } + } + if !parsed { + return nil + } + return out + } + for i := range poolMatches { + m := &poolMatches[i] + m.IneligibleSides = stamp(m.Status, m.ID, m.SideAID, m.SideBID) + } + if bracket == nil { + return + } + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + bm := &bracket.Rounds[ri][mi] + bm.IneligibleSides = stamp(bm.Status, bm.ID, bm.SideAID, bm.SideBID) + } + } + if bm := bracket.ThirdPlaceMatch; bm != nil { + bm.IneligibleSides = stamp(bm.Status, bm.ID, bm.SideAID, bm.SideBID) + } +} + // anyNumberedBoutHasEncho reports whether any NUMBERED sub-result (a real // team bout, not the position -1 daihyosen) carries an encho marker. Used // by the score endpoints to decide whether the kachinuki numbered-bout @@ -431,6 +540,22 @@ func backfillMatchIdentityForHantei(store CompetitionStore, compID, matchID stri // satisfy the interfaces by structural match. func tryAutoCompletePools(c *gin.Context, eng ScoringEngine, hub Broadcaster, compID string) { outcome, err := eng.MaybeAutoCompletePools(compID) + answerAutoComplete(c, hub, compID, outcome, err) +} + +// tryAutoCompletePoolsAfterWrite is tryAutoCompletePools for a door that has +// just written the given matches (a score, a decision, a bulk batch): it asks +// engine.MaybeAutoCompletePoolsAfterWrite, which skips the knockout-status +// pool pass for a write that cannot move a qualifier, and answers the outcome +// the same way. +func tryAutoCompletePoolsAfterWrite(c *gin.Context, eng ScoringEngine, hub Broadcaster, compID string, written ...state.MatchResult) { + outcome, err := eng.MaybeAutoCompletePoolsAfterWrite(compID, written...) + answerAutoComplete(c, hub, compID, outcome, err) +} + +// answerAutoComplete is both doors' shared tail: the sanitized error header, +// or the broadcasts the outcome calls for. +func answerAutoComplete(c *gin.Context, hub Broadcaster, compID string, outcome engine.AutoCompleteOutcome, err error) { if err != nil { log.Printf("MaybeAutoCompletePools(%s): %v", compID, err) c.Header(AutoCompleteErrorHeader, AutoCompleteErrorValue) @@ -524,15 +649,16 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // newer result for this match is already stored, or this entry's // modifiedAt is implausibly far in the future so it can be neither // trusted nor zeroed, see modifiedAtRefuseSkewMs); "corrupt_overrides" - // when a corrupt overrides.json blocks the standings read the - // mp-e2k1 guard below needs; "downstream_knockout_played" (bc-kcdg) - // when correcting this entry would repaint a downstream bracket - // match that has recorded its own result; and - // "downstream_knockout_scored" (mp-e2k1) when correcting this pool - // entry would displace a qualifying finisher already scored into a - // downstream bracket match. The latter two both resolve by retrying - // the SAME entry with forceDownstreamReopen:true once the operator - // confirms the override. + // when a corrupt overrides.json blocks the standings read the pool + // requalification check needs; "downstream_knockout_played" + // (bc-kcdg) when correcting this entry would repaint a downstream + // bracket match that has recorded its own result, or, for a pool + // entry in a mixed competition, would move a qualifier the knockout + // already played (resolved by retrying the SAME entry with + // forceDownstreamReopen:true once the operator confirms); and + // "downstream_knockout_running" when that pool entry would move a + // qualifier out of a knockout match being fought now (terminal: + // finish or requeue that match, then retry). // // It matters because the single-match endpoints answer those // conditions with a distinct body ({"applied": false, "reason": @@ -567,6 +693,17 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi } allowNumberedEncho := allowNumberedEnchoFromStore(store, id, batchHasNumberedEncho) + // bc-cse finding 13: the team finish gate's competition record is + // the SAME for every entry in this batch (one compID), so it is + // loaded ONCE here rather than once per entry the way a call + // through refuseUnfinishedTeamFinish would (mirrors the + // allowNumberedEncho resolve-once just above). A load failure is + // deterministic (same store, same id) so it would have failed + // identically for every entry under the old per-entry call too; + // compErr is propagated to each entry that reaches the gate below, + // exactly as an independent per-entry failure would have. + comp, compErr := store.LoadCompetition(id) + for i := range results { // A stamp implausibly far in the future is refused per-entry, before // this entry reaches the engine (see modifiedAtRefuseSkewMs). The @@ -605,6 +742,26 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi results[i].RepPlayerB = "" } + // bc-tmfn: the team finish gate, per entry, exactly as on PUT + // /score (see refuseUnfinishedTeamFinish for its scope): computed + // here, applied under the entry's transaction below. A refused + // entry is a validation failure like validateBulkScoreLengths': + // it lands in errs naming the bouts, nothing is written for it, + // and the rest of the batch is unaffected (this endpoint is + // always 200, partial success). + // + // bc-cse finding 13: routed through refuseUnfinishedTeamFinishForComp + // against the ONE comp loaded above the loop, rather than a call + // through refuseUnfinishedTeamFinish that would reload it for + // every entry. compErr mirrors what an independent per-entry + // LoadCompetition failure would have done (same store, same id, + // so a deterministic failure recurs identically for every entry). + if compErr != nil { + errs = append(errs, scoreError{MatchID: results[i].ID, Error: compErr.Error()}) + continue + } + finishRefusal := refuseUnfinishedTeamFinishForComp(comp, results[i].ID, &results[i].MatchResult) + // mp-ic5b: the correction-reason gate and the write run under the // same per-comp lock so the status read is race-free against a // concurrent PUT /score. Per-result transactions preserve the @@ -617,11 +774,10 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi var capturedStatus *domain.CompetitorStatus var reopenedThisItem []engine.ReopenedMatch if err := tx.WithTransaction(id, func(stx state.StoreTx) error { - // Correction-reason audit policy (require a reason when the write - // rewrites a result the operator already declared final — a - // completed -> completed overwrite, or the re-End of a match - // reopened without one — otherwise carry the STORED reason - // forward). The rule itself lives in + // Correction-reason audit policy (require a reason for a + // completed -> completed overwrite, otherwise carry the STORED + // reason forward; ending a match reopened without a reason is + // never refused). The rule itself lives in // applyCorrectionReasonUnderTx, shared with the single-score path // so the two cannot drift; only the error SHAPE differs here // (partial-success entries carry a plain message). @@ -632,6 +788,9 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi if check.Reject != nil { return errors.New(check.Reject.Message) } + if refusal := teamFinishRefusalUnderTx(finishRefusal, check, &results[i].MatchResult); refusal != nil { + return errors.New(refusal.Error()) + } status, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, results[i].ID, &results[i].MatchResult, engine.ForceOptions{ Force: results[i].ForceDownstreamReopen, Reopened: &reopenedThisItem, @@ -649,12 +808,29 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi }); err != nil { bulkErr := scoreError{MatchID: results[i].ID, Error: err.Error()} var downstreamPlayedErr *engine.DownstreamKnockoutPlayedError - var downstreamScoredErr *engine.DownstreamKnockoutScoredError + var downstreamRunningErr *engine.DownstreamKnockoutRunningError + var alreadyIneligErr *engine.AlreadyIneligibleError switch { case errors.Is(err, engine.ErrMatchSuperseded): bulkErr.Reason = "superseded" + case errors.As(err, &alreadyIneligErr): + // bc-rawm/bc-cse: this batch shape has no dedicated 409 to + // answer with, so the operator sentence rides in Error + // itself (same class of fix as the single-match 409 body): + // without this, Error was err.Error()'s raw + // `competitor "" already ineligible (match ...)`, + // naming an internal id and no remedy. Only + // *AlreadyIneligibleError is reachable here, never + // *IneligibleCompetitorError: bulk-score never calls + // StartMatchTx/checkEligibilityExcludingMatch (the only + // producers of that type besides the simultaneity gate, + // which this write path also never reaches), only the K2/K3 + // concurrent-withdrawal checks inside + // RecordMatchResultWithIneligibilityTx, which return this + // type alone. + bulkErr.Error = reasonHumanForBarredCompetitor(store, id, results[i].ID, alreadyIneligErr.PlayerID, alreadyIneligErr.Reason, alreadyIneligErr.MatchID, alreadyIneligErr.Decision) case errors.Is(err, state.ErrCorruptOverrides): - // mp-e2k1's mixed-pool guard reaches computeStandingsFrom, + // The pool requalification check reaches computeStandingsFrom, // which loads overrides.json -- reachable per-entry here the // same way it is on the single-match write endpoints. This // endpoint never fails the whole request (always 200, partial @@ -674,16 +850,13 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // forceDownstreamReopen:true once they've confirmed the // override, exactly as /score's 409 body prompts them to. bulkErr.Reason = "downstream_knockout_played" - case errors.As(err, &downstreamScoredErr): - // mp-e2k1: re-scoring THIS entry (a pool match) would - // displace a qualifying finisher already scored into a - // downstream bracket match. Same batch-Reason treatment as - // downstream_knockout_played above, and for the identical - // reason: this endpoint has no dedicated 409 to answer - // with, so the discriminator rides in Reason so the caller - // can distinguish it from an arbitrary rejection instead of - // only seeing the raw error text. - bulkErr.Reason = "downstream_knockout_scored" + case errors.As(err, &downstreamRunningErr): + // Re-scoring THIS entry (a pool match) would move a + // qualifier out of a knockout match being fought now. Same + // batch-Reason treatment as downstream_knockout_played + // above, but NOT resolvable with forceDownstreamReopen: + // the operator finishes or requeues that match first. + bulkErr.Reason = "downstream_knockout_running" } errs = append(errs, bulkErr) continue @@ -696,9 +869,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // corrected; broadcast it too so a client watching only that // court/match learns its verdict was cleared (mirrors /score and // /override-winner). - for _, reopened := range reopenedThisItem { - hub.Broadcast(EventMatchUpdated, gin.H{"competitionId": id, "matchId": reopened.ID}) - } + broadcastReopenedDownstream(hub, id, reopenedThisItem) } if len(successful) > 0 { @@ -706,7 +877,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi "competitionId": id, "results": matchesForBroadcast(successful), }) - tryAutoCompletePools(c, eng, hub, id) + tryAutoCompletePoolsAfterWrite(c, eng, hub, id, successful...) } for _, status := range eligibilityUpdates { hub.Broadcast(EventCompetitorStatusUpdated, gin.H{ @@ -825,8 +996,12 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi subResults = append(subResults, state.SubMatchResult{Position: pos, Winner: req.SideB}) pos++ } + // A draw row is a Tie: it carries the hikiwake decision, so it has a + // result (state.SubMatchResult.HasResult). Without one it read as an + // unfought bout, which the standings do not count as IT and the team + // finish gate refuses on a later Save correction. for range req.Draws { - subResults = append(subResults, state.SubMatchResult{Position: pos}) + subResults = append(subResults, state.SubMatchResult{Position: pos, Decision: state.DecisionDraw}) pos++ } @@ -839,19 +1014,50 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi Status: state.MatchStatusCompleted, SubResults: subResults, } + // The team finish gate (every bout of a non-kachinuki team match has a + // result or a decision) sees the rows this endpoint is about to store, + // as it does for /score and bulk-score, with the same exemption for a + // correction that keeps a recorded withdrawal (teamFinishRefusalUnderTx, + // read under the lock the write holds). + finishRefusal := refuseUnfinishedTeamFinishForComp(comp, mid, &result) // bc-kcdg: reopenedDownstream collects the IDs of any downstream // bracket match reopened by a forced correction, populated only when // req.ForceDownstreamReopen actually unblocked one. Switched from the // plain RecordMatchResult (which hard-refuses the downstream-knockout- // correction guard with no override, see writeMatchResult's doc - // comment) to RecordMatchResultWithIneligibility, the same primitive + // comment) to RecordMatchResultWithIneligibilityTx, the same primitive // /score and bulk-score already use, so this endpoint can honour the // force flag at all. - var reopenedDownstream []engine.ReopenedMatch - engStatus, err := eng.RecordMatchResultWithIneligibility(id, mid, &result, engine.ForceOptions{ - Force: req.ForceDownstreamReopen, - Reopened: &reopenedDownstream, + var ( + reopenedDownstream []engine.ReopenedMatch + engStatus *domain.CompetitorStatus + refusal *ValidationError + ) + txErr := tx.WithTransaction(id, func(stx state.StoreTx) error { + if finishRefusal != nil { + snap, _, snapErr := matchSnapshotOrErr(stx, id, mid, "team-finish") + if snapErr != nil { + return snapErr + } + check := correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} + if refusal = teamFinishRefusalUnderTx(finishRefusal, check, &result); refusal != nil { + return nil + } + } + engStatus, err = eng.RecordMatchResultWithIneligibilityTx(stx, id, mid, &result, engine.ForceOptions{ + Force: req.ForceDownstreamReopen, + Reopened: &reopenedDownstream, + }) + return nil }) + if txErr != nil { + internalError(c, txErr) + return + } + if refusal != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": refusal.Error()}) + return + } if err != nil { if errors.Is(err, engine.ErrMatchSuperseded) { // bc-lww1. Unreachable from today's SPA (this endpoint's payload @@ -876,14 +1082,11 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi if respondIfDownstreamKnockoutPlayed(c, err) { return } - // mp-e2k1: re-scoring this pool match would displace a qualifying - // finisher already scored into a downstream bracket match. Same - // fixed wire contract as /score's mapping (see - // respondIfDownstreamKnockoutScored's doc comment); before this, - // quick-score fell through to a generic 500 for this error, which - // the offline write queue retries forever for a write that can - // never win. - if respondIfDownstreamKnockoutScored(c, err) { + // Re-scoring this pool match would move a qualifier out of a + // knockout match being fought now: terminal, not confirmable (see + // respondIfDownstreamKnockoutRunning's doc comment). A generic 500 + // here would be retried forever by the offline write queue. + if respondIfDownstreamKnockoutRunning(c, err) { return } // A tied quick-score on a bracket team match produces a @@ -903,16 +1106,14 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // bc-kcdg: each reopened match is distinct from the one just corrected; // broadcast it too so a client watching only that court/match learns // its verdict was cleared (mirrors /score and /override-winner). - for _, reopened := range reopenedDownstream { - hub.Broadcast(EventMatchUpdated, gin.H{"competitionId": id, "matchId": reopened.ID}) - } + broadcastReopenedDownstream(hub, id, reopenedDownstream) if engStatus != nil { hub.Broadcast(EventCompetitorStatusUpdated, gin.H{ "competitionId": id, "status": engStatus, }) } - tryAutoCompletePools(c, eng, hub, id) + tryAutoCompletePoolsAfterWrite(c, eng, hub, id, result) c.JSON(http.StatusOK, scoreResponseWithReopened(&result, reopenedDownstream)) }) @@ -1017,27 +1218,41 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // POST /competitions/:id/matches/:mid/reopen // Sanctioned "Reopen match" for a COMPLETED kachinuki team match - // (mp-gmcg, spec 006 decision 4): status back to running, match-level - // winner/decision cleared, bout log kept, so the operator can add more - // bouts and later End match again. A dedicated endpoint rather than a - // score-write flag so the score path's stale-write guard (a plain - // running write against a completed match silently no-ops) stays fully - // intact. + // (mp-gmcg, spec 006 decision 4), and since bc-tmfn (widened to a + // match-level fusensho by bc-cse) for any completed match decided by a + // withdrawal or default win (domain.IsDefaultWinDecisionStr: kiken, + // kiken-injury, fusenpai, or fusensho), team or individual: the way to + // clear one recorded by mistake (engine.ReopenMatch). + // Status back to running, match-level winner/decision cleared, fought + // bouts kept, so the operator scores what is left and finishes again. A + // dedicated endpoint rather than a score-write flag so the score path's + // stale-write guard (a plain running write against a completed match + // silently no-ops) stays fully intact. // - // Body: {"reason": ""} — OPTIONAL, and an absent body is equally + // Body: {"reason": "", "forceDownstreamReopen": bool} — both + // OPTIONAL. forceDownstreamReopen is the operator's confirmation after a + // 409 downstream_knockout_played (the same wire contract as a knockout + // correction's, respondIfDownstreamKnockoutPlayed): the knockout match + // this one fed already has a result of its own, and confirming reopens it + // too, to be fought again. The response then names every match that was + // reopened that way ({"reopenedMatches": [{id, number, label}]}). + // + // The reason is OPTIONAL, and an absent body is equally // valid, so reopening is ONE TAP. Requiring the justification here was // too much friction for the case it is most needed in: an operator who // ended a match by mistake, at a shiaijo, mid-session, had to compose a - // reason before they could get back in. Rewriting a finalized result is - // still audited — a reason-less reopen sets state.MatchResult.ReopenPending - // and the score path then refuses to complete the match again without a - // correctionReason (see applyCorrectionReasonUnderTx). The record is - // written later than the action it justifies; it is not lost. + // reason before they could get back in. Ending the match again asks for + // no reason either (operator ruling 2026-09-25: a match can be reopened + // without any reason, and nothing is gated on that). A reason-less reopen + // sets state.MatchResult.ReopenPending, which only lets a reason sent with + // the next completion be kept (see applyCorrectionReasonUnderTx). // - // Non-kachinuki competitions get 400 (their only sanctioned edit of a - // finished result remains the correction path), as does an over-long - // reason; a non-completed match, a bracket match whose winner already - // feeds a fought downstream match, or a BUSY COURT gets 409. + // A completed match that is neither kachinuki nor decided by a withdrawal + // gets 400 (its sanctioned edit of a finished result remains the + // correction path), as does an over-long reason; a non-completed match, a + // match whose result feeds a downstream match in progress (or resolved by + // a bye), a downstream match with its own result and no confirmation, or + // a BUSY COURT gets 409. r.POST("/competitions/:id/matches/:mid/reopen", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { @@ -1049,7 +1264,8 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi return } var body struct { - Reason string `json:"reason"` + Reason string `json:"reason"` + ForceDownstreamReopen bool `json:"forceDownstreamReopen"` } // io.EOF is an EMPTY body, which is the one-tap case: the client sends // no JSON at all. Only a malformed body is a 400 — an operator tapping @@ -1078,23 +1294,44 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // empty-stored-password fail-closed branch. Pinned by // TestSelfRun_ReopenRequiresMainPassword. - // ReopenKachinukiMatch holds the court-exclusivity lock itself now + // ReopenMatch holds the court-exclusivity lock itself now // (mp-gmcg review A2), so its cross-competition pre-check and in-tx // same-comp check are atomic by construction — the handler no longer // has to remember to wrap the call. - err := eng.ReopenKachinukiMatch(id, mid, reason) + var reopenedDownstream []engine.ReopenedMatch + restored, err := eng.ReopenMatch(id, mid, reason, engine.ForceOptions{ + Force: body.ForceDownstreamReopen, + Reopened: &reopenedDownstream, + }) if err != nil { var notFoundErr *engine.NotFoundError var validationErr *engine.ValidationError var courtBusyErr *engine.CourtBusyError switch { - case errors.Is(err, engine.ErrReopenNotCompleted), - errors.Is(err, engine.ErrReopenDownstreamFought): + case errors.Is(err, engine.ErrReopenNotCompleted): + // bc-rawm/bc-cse: operator sentence naming the TARGET match + // by its label, not the raw sentinel text. + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s is not completed yet, so there is nothing to reopen.", engine.SentenceCase(matchLabelOrID(store, id, mid)))}) + case respondIfDownstreamKnockoutRunning(c, err): + // bc-cse: someone is fighting the downstream match right + // now -- 409 downstream_knockout_running, the SAME shape + // the pool-requalification refusal already uses. + case respondIfDownstreamKnockoutPlayed(c, err): + // A downstream knockout match has its own result: the + // operator is told and may retry with forceDownstreamReopen. + case errors.Is(err, engine.ErrReopenDownstreamFought): + // Defensive backstop only (retractPropagatedWinner's + // re-check at the mutation point); the two typed errors + // above already raise for every case reachable through this + // door in practice. Kept mapped to a 409, not internalError, + // so a genuine internal inconsistency still fails the write + // rather than surfacing as a 500 the offline queue retries + // forever (mp-q8c6). c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) case errors.As(err, &courtBusyErr): // Same 409 shape as the score path so clients have one // court_busy branch to handle (mp-gmcg). - respondCourtBusy(c, courtBusyErr, "reopening this one") + respondCourtBusy(c, store, courtBusyErr, courtBusyRemedyReopen) case errors.As(err, ¬FoundErr): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.As(err, &validationErr): @@ -1113,8 +1350,20 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi "competitionId": id, "matchId": mid, }) + // A confirmed reopen also reopened the downstream match(es) it named; + // each is a distinct match whose verdict was cleared (mirrors /score). + broadcastReopenedDownstream(hub, id, reopenedDownstream) + // Reopening a match a withdrawal ended removes the withdrawal, and + // the engine restored the competitor it barred; broadcast that + // exactly as /score does. + if restored != nil { + hub.Broadcast(EventCompetitorStatusUpdated, gin.H{ + "competitionId": id, + "status": restored, + }) + } - c.Status(http.StatusOK) + c.JSON(http.StatusOK, gin.H{"reopenedMatches": blockedMatchesPayload(reopenedDownstream)}) }) // POST /competitions/:id/matches/:mid/requeue-blocker-and-reopen @@ -1129,8 +1378,10 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // carries whichever competition owns it. Destructive (the blocker loses any // partial score), so it is main-password-gated in self-run mode via the // central allowlist, the same class as reopen/override-winner. - // Body: {blockerCompId, blockerMatchId, reason?} — reason optional, exactly - // like reopen (an absent reason leaves the target ReopenPending). + // Body: {blockerCompId, blockerMatchId, reason?, forceDownstreamReopen?} — + // reason and forceDownstreamReopen optional, exactly like reopen (an + // absent reason leaves the target ReopenPending). The target may be any + // match reopen accepts (engine.ReopenMatch). r.POST("/competitions/:id/matches/:mid/requeue-blocker-and-reopen", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { @@ -1142,9 +1393,10 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi return } var body struct { - BlockerCompID string `json:"blockerCompId"` - BlockerMatchID string `json:"blockerMatchId"` - Reason string `json:"reason"` + BlockerCompID string `json:"blockerCompId"` + BlockerMatchID string `json:"blockerMatchId"` + Reason string `json:"reason"` + ForceDownstreamReopen bool `json:"forceDownstreamReopen"` } if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -1170,7 +1422,11 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi } } - err := eng.RequeueBlockerAndReopenKachinuki(id, mid, body.BlockerCompID, body.BlockerMatchID, reason) + var reopenedDownstream []engine.ReopenedMatch + restored, err := eng.RequeueBlockerAndReopen(id, mid, body.BlockerCompID, body.BlockerMatchID, reason, engine.ForceOptions{ + Force: body.ForceDownstreamReopen, + Reopened: &reopenedDownstream, + }) if err != nil { var notFoundErr *engine.NotFoundError var validationErr *engine.ValidationError @@ -1189,22 +1445,37 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // sentinel's "correct a completed bout" (which names no match and // points at the wrong fix). Still a 409 — a raw fall-through would // 500, as the sentinel is not a ValidationError/NotFound/CourtBusy. - c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("the blocking match %q finished on its own, so its court is now free — retry the reopen", body.BlockerMatchID)}) - case errors.Is(err, engine.ErrReopenNotCompleted), - errors.Is(err, engine.ErrReopenDownstreamFought): - // These name a bad TARGET (not completed, or its result already - // fed a fought knockout). The requeue-and-reopen path pre-checks - // both read-only BEFORE the destructive revert - // (checkTargetReopenable), so in the common path the blocker keeps - // its live score. A /decision or /bulk-score write racing between - // that pre-check and the reopen's own in-tx re-check can still - // surface these post-revert (see the NARROWS comment in engine's - // RequeueBlockerAndReopenKachinuki) — i.e. with the blocker already - // requeued; rare, and bracket integrity still holds via the re-check. + // bc-rawm/bc-cse: operator sentence naming the blocker by + // its label, no internal id, no em-dash. + blockerLabel := engine.SentenceCase(matchLabelOrID(store, body.BlockerCompID, body.BlockerMatchID)) + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s finished on its own, so its court is now free. Retry the reopen.", blockerLabel)}) + case errors.Is(err, engine.ErrReopenNotCompleted): + // Names a bad TARGET (not completed). The requeue-and-reopen + // path pre-checks this read-only BEFORE the destructive + // revert (checkTargetReopenable), so in the common path the + // blocker keeps its live score. A /decision or /bulk-score + // write racing between that pre-check and the reopen's own + // in-tx re-check can still surface this post-revert (see the + // NARROWS comment in engine's RequeueBlockerAndReopen) -- + // i.e. with the blocker already requeued; rare, and bracket + // integrity still holds via the re-check. bc-rawm/bc-cse: + // operator sentence naming the target by its label. + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s is not completed yet, so there is nothing to reopen.", engine.SentenceCase(matchLabelOrID(store, id, mid)))}) + case respondIfDownstreamKnockoutRunning(c, err): + // bc-cse: same pre-check timing note as ErrReopenNotCompleted + // above; someone is fighting the downstream match right now. + case errors.Is(err, engine.ErrReopenDownstreamFought): + // Defensive backstop only; see the plain-reopen handler's + // identical case for why this should not be reachable + // through this door in practice. c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case respondIfDownstreamKnockoutPlayed(c, err): + // Refused BEFORE the revert (checkTargetReopenable runs first), + // so the blocker still holds its score; the operator may retry + // with forceDownstreamReopen once they have confirmed. case errors.As(err, &courtBusyErr): // A match OTHER than the one we requeued still holds the court. - respondCourtBusy(c, courtBusyErr, "reopening this one") + respondCourtBusy(c, store, courtBusyErr, courtBusyRemedyReopen) case errors.As(err, ¬FoundErr): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.As(err, &validationErr): @@ -1222,8 +1493,15 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi runningRevStore.Delete(body.BlockerCompID + ":" + body.BlockerMatchID) hub.Broadcast(EventMatchUpdated, gin.H{"competitionId": id, "matchId": mid}) hub.Broadcast(EventMatchUpdated, gin.H{"competitionId": body.BlockerCompID, "matchId": body.BlockerMatchID}) + broadcastReopenedDownstream(hub, id, reopenedDownstream) + if restored != nil { + hub.Broadcast(EventCompetitorStatusUpdated, gin.H{ + "competitionId": id, + "status": restored, + }) + } - c.Status(http.StatusOK) + c.JSON(http.StatusOK, gin.H{"reopenedMatches": blockedMatchesPayload(reopenedDownstream)}) }) // DELETE /competitions/:id/matches/:mid/kachinuki-bout @@ -1259,8 +1537,12 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi var notFoundErr *engine.NotFoundError var validationErr *engine.ValidationError switch { - case errors.Is(err, engine.ErrNoRemovableBout), errors.Is(err, engine.ErrRemoveBoutNotRunning): - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, engine.ErrNoRemovableBout): + // bc-rawm/bc-cse: operator sentence naming the match by its + // label, not the raw sentinel text. + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s has no unscored bout to remove; only an empty appended bout can be removed.", engine.SentenceCase(matchLabelOrID(store, id, mid)))}) + case errors.Is(err, engine.ErrRemoveBoutNotRunning): + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s is not running; reopen it before removing a bout.", engine.SentenceCase(matchLabelOrID(store, id, mid)))}) case errors.As(err, ¬FoundErr): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.As(err, &validationErr): @@ -1383,9 +1665,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // bc-kcdg: each reopened match is distinct from the one the // operator just overrode; broadcast it too so a client watching // only that court/match learns its verdict was cleared. - for _, reopened := range reopenedDownstream { - hub.Broadcast(EventMatchUpdated, gin.H{"competitionId": id, "matchId": reopened.ID}) - } + broadcastReopenedDownstream(hub, id, reopenedDownstream) } c.JSON(http.StatusOK, gin.H{"applied": applied}) }) @@ -1519,7 +1799,7 @@ type matchStores interface { // it: Status drives the correction / stale-write / finalized gates, // CorrectionReason carries the kachinuki reopen justification forward (see // applyCorrectionReasonUnderTx), and ReopenPending says a reason-less reopen -// still owes one. +// has not been ended again. // // It carries only the fields the guards actually read: the post-advance // kachinuki bout log now rides back to the editor on MaybeAdvanceKachinuki's @@ -1540,6 +1820,10 @@ type matchSnapshot struct { CorrectionReason string ReopenPending bool InBracket bool + // Decision is the stored match-level decision, which the team finish + // gate's exemption reads under the same lock as the write it gates + // (engine.KeepsWithdrawalRuling, via teamFinishRefusalUnderTx). + Decision string } // lookupMatchSnapshot is mobileapp's READ-side traversal of the three homes a @@ -1577,6 +1861,7 @@ func lookupMatchSnapshot(s matchStores, compID, matchID string) (matchSnapshot, Status: poolMatches[i].Status, CorrectionReason: poolMatches[i].CorrectionReason, ReopenPending: poolMatches[i].ReopenPending, + Decision: poolMatches[i].Decision, }, true, loadErr } } @@ -1607,6 +1892,7 @@ func bracketMatchSnapshot(bm *state.BracketMatch) matchSnapshot { CorrectionReason: bm.CorrectionReason, ReopenPending: bm.ReopenPending, InBracket: true, + Decision: bm.Decision, } } @@ -1719,19 +2005,40 @@ func respondClockSkew(c *gin.Context, serverNowMs, aheadMs int64) { }) } +// courtBusyRemedyStart / courtBusyRemedyReopen (bc-cse item 11e) are +// respondCourtBusy's two `remedy` strings, each used at two call sites +// (score start's two doors, the reopen family's two doors); named once so +// the pair cannot drift apart the way a hand-copied literal eventually does. +const ( + courtBusyRemedyStart = "Finish that match before starting a new one." + courtBusyRemedyReopen = "Clear its score, queue it, and reopen this one instead." +) + // respondCourtBusy writes the shared 409 court_busy body. The court- // exclusivity gate fires on three paths (score start, score reopen, and -// kachinuki reopen); `action` names what the operator was attempting (e.g. -// "starting a new one") so the sentence reads naturally. Keeping the -// {error,court,matchId,compId,message} shape in ONE place means every client -// court_busy branch stays in lockstep when the wire contract moves. -func respondCourtBusy(c *gin.Context, err *engine.CourtBusyError, action string) { +// kachinuki reopen); `remedy` names the one-step fix for THIS caller +// (courtBusyRemedyStart or courtBusyRemedyReopen above) so the message reads +// naturally for whichever action was attempted. Keeping the +// {error,court,matchId,compId,label,message} shape in ONE place means every +// client court_busy branch stays in lockstep when the wire contract moves. +func respondCourtBusy(c *gin.Context, store CompetitionStore, err *engine.CourtBusyError, remedy string) { + // bc-cse: err.CompID is the BLOCKING match's own competition, which for + // a cross-competition conflict (courts are tournament-global) is not + // the competition this request targets -- the label must be resolved + // against it, never the target's. + label := matchLabelOrID(store, err.CompID, err.MatchID) c.JSON(http.StatusConflict, gin.H{ "error": "court_busy", "court": err.Court, "matchId": err.MatchID, "compId": err.CompID, - "message": fmt.Sprintf("Court %s already has a running match (%s). Finish that match before %s.", err.Court, err.MatchID, action), + "label": label, + // bc-rawm/bc-cse: the message names no internal id (the label + // stands in for it) and states the actual remedy for THIS caller -- + // "reopening this one" used to say "finish that match", which is + // wrong for a reopen: the panel's own remedy is "Clear its score, + // queue it, and reopen", not finishing the blocker. + "message": fmt.Sprintf("Shiaijo %s already has %s running now. %s", err.Court, label, remedy), }) } @@ -1766,7 +2073,10 @@ func matchStatusFromStore(store CompetitionStore, compID, matchID string) state. // matches need it: the pool write is a whole-struct overwrite that carries // r.ReopenPending straight through. type correctionCheck struct { - StoredStatus state.MatchStatus + StoredStatus state.MatchStatus + // StoredDecision is the stored match-level decision, read in the same + // snapshot as StoredStatus (see teamFinishRefusalUnderTx). + StoredDecision string Reject *ValidationError ClearBracketReopenPending bool } @@ -1781,24 +2091,20 @@ type correctionCheck struct { // by the locked and tx scoring paths); each caller wraps the Reject verdict // in its own error shape. // -// A completion must be JUSTIFIED in two cases, and both demand exactly the -// same thing (a non-empty CorrectionReason), because both rewrite a result -// the operator had already declared final: -// -// - completed -> completed is a CORRECTION: overwriting an already-finalized -// result requires a reason for traceability. This applies to ANY decision -// type, including a withdrawal (kiken/fusenpai) submitted via /score; -// exempting those would let a finalized result be overwritten with no -// audit reason. -// - the match carries ReopenPending: it was reopened with no reason -// (mp-gmcg), so its stored status is `running` and the completion looks -// like a first finalization — but the finalized result it replaces was -// discarded by that reopen. The justification reopen no longer asks for is -// collected HERE, on a step the operator was already taking. +// completed -> completed is a CORRECTION: overwriting an already-finalized +// result requires a non-empty CorrectionReason for traceability. This applies +// to ANY decision type, including a withdrawal (kiken/fusenpai) submitted via +// /score; exempting those would let a finalized result be overwritten with no +// audit reason. Reject reports a missing reason and r is left otherwise +// untouched; on acceptance the supplied reason is kept. // -// In both cases Reject reports a missing reason and r is left otherwise -// untouched; on acceptance the supplied reason is kept and ReopenPending is -// discharged (nothing is outstanding once the record exists). +// A match reopened without a reason (ReopenPending) is NOT asked for one +// when it is ended again: a match can be reopened without any reason, and +// nothing is gated on that (operator ruling 2026-09-25, which retired the +// mp-gmcg rule that collected the reopen's reason here). Ending it works like +// ending any match; the one difference is that a reason the client does send +// is kept, since the completion replaces a result the reopen discarded, and +// ReopenPending is discharged. // // Anything else (a genuine first finalization, or a running/scheduled write) // is not a rewrite. A client-supplied reason is meaningless there and is @@ -1812,10 +2118,9 @@ type correctionCheck struct { // ReopenPending is SERVER-OWNED and is re-stamped from the stored value on // every write, before any of the above. state.MatchResult binds straight from // the request body, so a client could otherwise plant the flag on an unrelated -// match or — the damaging direction — clear its own outstanding justification -// by sending `reopenPending: false`. Re-stamping is also what keeps the flag -// alive across a pool match's running writes, which would otherwise blank it -// through the same whole-struct overwrite. +// match or clear it by sending `reopenPending: false`. Re-stamping is also +// what keeps the flag alive across a pool match's running writes, which would +// otherwise blank it through the same whole-struct overwrite. // // Returning the stored status keeps this the transaction's ONLY match lookup: // StoreTx loads deliberately bypass the file cache (state.LoadPoolMatchesLocked @@ -1825,23 +2130,27 @@ type correctionCheck struct { // stale-after-complete guard reads the returned status instead. func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult) (correctionCheck, error) { // matchSnapshotOrErr fails CLOSED on a load error: the correction-reason - // gate below must not pass on an assumed-false ReopenPending, silently - // finalizing without the mandatory reason (or dropping a client-supplied - // CorrectionReason) — the audit hole this gate exists to close (mp-gmcg). + // gate below must not pass on an assumed-running status, silently + // overwriting a finalized result without the mandatory reason. snap, _, err := matchSnapshotOrErr(stx, compID, matchID, "correction-reason") if err != nil { return correctionCheck{}, err } r.ReopenPending = snap.ReopenPending - if r.Status == state.MatchStatusCompleted && (snap.Status == state.MatchStatusCompleted || snap.ReopenPending) { + if r.Status == state.MatchStatusCompleted && snap.Status == state.MatchStatusCompleted { if r.CorrectionReason == "" { - return correctionCheck{StoredStatus: snap.Status, Reject: missingCorrectionReasonError(snap)}, nil + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision, Reject: missingCorrectionReasonError()}, nil } - // The justification has landed: the reopen is no longer outstanding. + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision}, nil + } + if r.Status == state.MatchStatusCompleted && snap.ReopenPending { + // Ended again after a reason-less reopen: never refused, the + // client's reason (if any) kept, the flag discharged. r.ReopenPending = false return correctionCheck{ StoredStatus: snap.Status, - ClearBracketReopenPending: snap.InBracket && snap.ReopenPending, + StoredDecision: snap.Decision, + ClearBracketReopenPending: snap.InBracket, }, nil } // Non-completing write: pin the reason to the STORED one. This is not just a @@ -1856,36 +2165,18 @@ func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r * // without re-checking that a running write still cannot rewrite the note // (mp-gmcg review F5). r.CorrectionReason = snap.CorrectionReason - return correctionCheck{StoredStatus: snap.Status}, nil + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision}, nil } -// missingCorrectionReasonError names the CAUSE, not just the rule. Both -// branches reject on the same field with the same HTTP shape, but an operator -// re-ending a match they reopened one tap ago has no idea they are performing -// a "correction" — telling them the reopen is what asks for the reason is the -// difference between a fixable prompt and a dead end. -func missingCorrectionReasonError(snap matchSnapshot) *ValidationError { - msg := "correcting a completed match result requires a non-empty correctionReason" - if snap.Status != state.MatchStatusCompleted && snap.ReopenPending { - msg = ReopenNeedsReasonMessage - } - return &ValidationError{Field: "correctionReason", Message: msg} +// missingCorrectionReasonError is the refusal for overwriting a completed +// result without a correction reason. +func missingCorrectionReasonError() *ValidationError { + return &ValidationError{Field: "correctionReason", Message: "correcting a completed match result requires a non-empty correctionReason"} } -// ReopenNeedsReasonMessage is the operator-facing text for "you reopened this -// match, so finalizing it again has to say why". It lives here as a constant -// because a match can be finalized through EITHER endpoint — PUT /score -// (applyCorrectionReasonUnderTx, above) or POST /decision (kiken/fusenpai, -// handlers_decision.go) — and the two report it on DIFFERENT fields -// (correctionReason vs decisionReason, each endpoint's own audit field). The -// FIELD differs, the rule does not, so the wording is shared rather than -// duplicated: an operator who meets this on one endpoint should not be told -// something subtly different on the other. -const ReopenNeedsReasonMessage = "this match was reopened; ending it again requires a reason" - // dischargeReopenPendingUnderTx clears a match's stored ReopenPending flag -// after a completion that carried its justification, and — when reason is -// non-empty — records that justification in CorrectionReason. Runs inside the +// after a completion, and, when reason is non-empty, records it in +// CorrectionReason. Runs inside the // caller's WithTransaction, AFTER the engine write has succeeded: the // finalizing handlers commit the transaction even when the engine rejects the // write (engErr is surfaced afterwards), so discharging beforehand would @@ -2029,8 +2320,12 @@ type scoreRequestBody struct { // downstream_knockout_played): correcting a completed bracket match whose // already-propagated winner fed a downstream match that has since // recorded its own result is refused by default. Setting this applies - // the correction and requeues the one downstream match that - // carries its own result (see forceReopenDownstreamChain). + // the correction and reopens the one downstream match that + // carries its own result (see forceReopenDownstreamChain). The same + // refusal and the same confirmation cover a POOL correction in a mixed + // competition that moves a qualifier the knockout already played + // (engine.requalifyAfterPoolWrite): confirmed, those knockout matches are + // reopened and the new qualifier is seated. ForceDownstreamReopen bool `json:"forceDownstreamReopen"` } @@ -2076,11 +2371,7 @@ func scoreResponseWithReopened(result *state.MatchResult, reopened []engine.Reop // along for anything addressing the match, but nothing shows it to a // person (operator ruling 2026-09-19: "that's how the matches should be // identified to the user"). - out := make([]map[string]any, 0, len(reopened)) - for _, r := range reopened { - out = append(out, map[string]any{"id": r.ID, "number": r.Number}) - } - merged["reopenedMatches"] = out + merged["reopenedMatches"] = blockedMatchesPayload(reopened) return merged } @@ -2143,6 +2434,17 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // bc-tmfn: a team match cannot be finished while a numbered bout has + // no result (every bout is fought). This handler is the door every + // score editor finishes through; see refuseUnfinishedTeamFinish for + // what is out of scope and why. The refusal is computed here and + // applied inside the transaction below (teamFinishRefusalUnderTx), + // where the stored decision its one exemption reads is race-free. + finishRefusal, finishErr := refuseUnfinishedTeamFinish(store, id, mid, (*state.MatchResult)(&req)) + if finishErr != nil { + internalError(c, finishErr) + return + } // mp-ba3: self-run decision allowlist + result provenance. Runs // after Validate() so the request is structurally valid. @@ -2308,12 +2610,11 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti return nil } } - // Correction audit: a write that rewrites a result the operator - // already declared final requires a non-empty CorrectionReason for - // traceability — either a completed -> completed overwrite, or the - // re-End of a match reopened without a reason (mp-gmcg). A genuine - // first finalization needs no reason but must carry the STORED one - // forward. All of that rule lives in + // Correction audit: a completed -> completed overwrite requires a + // non-empty CorrectionReason for traceability. Ending a match + // reopened without a reason is never refused (operator ruling + // 2026-09-25). A genuine first finalization needs no reason but + // must carry the STORED one forward. All of that rule lives in // applyCorrectionReasonUnderTx, shared with the bulk-score path. It // runs inside the tx so the is-completed read is race-free (same // lock), and the status it returns is reused by the stale-write @@ -2327,6 +2628,12 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti engErr = check.Reject return nil } + // bc-tmfn: the team finish gate's in-tx half (see the + // refusal computed before the court lock above). + if refusal := teamFinishRefusalUnderTx(finishRefusal, check, result); refusal != nil { + engErr = refusal + return nil + } // Bracket integrity: a running- OR scheduled-status write must // never revert an already-completed match (e.g. a stale autosave // queued before Finish and flushed afterward, or a requeue write @@ -2378,8 +2685,8 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // settled on. if engErr == nil && check.ClearBracketReopenPending { // Only after the write actually landed: this branch commits - // even when engErr is set, so discharging the outstanding - // justification any earlier would clear it for a rejected write. + // even when engErr is set, so discharging any earlier would + // clear the flag for a rejected write. // ClearBracketReopenPending implies InBracket, so skip the pool probe. return dischargeReopenPendingUnderTx(stx, id, mid, "", true) } @@ -2392,7 +2699,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // infrastructure itself (WAL commit failure, etc.). var courtBusyErr *engine.CourtBusyError if errors.As(txErr, &courtBusyErr) { - respondCourtBusy(c, courtBusyErr, "starting a new one") + respondCourtBusy(c, store, courtBusyErr, courtBusyRemedyStart) return } var notFoundErr *engine.NotFoundError @@ -2463,14 +2770,33 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti } var ineligErr *engine.IneligibleCompetitorError if errors.As(engErr, &ineligErr) { - // U1: reasonHuman alongside the raw kendo-term reason - // so operator UIs can show "withdrew from match m_12" - // instead of "kiken at m_12". + // bc-rawm/bc-cse: reasonHuman is ONE operator sentence, never + // the raw kendo-term reason. Simultaneous is how this error + // type tells its two producers apart: the simultaneity gate + // (checkSimultaneousMatchTx) already builds a complete + // operator sentence into Reason itself, while a barred-status + // refusal (engine.BarredSides) needs the shared builder to + // name the match and the remedy -- even when that refusal's + // own MatchID is empty (a status set directly via + // POST /competitor-status, with no match tied to it), which + // is why MatchID emptiness alone can no longer be the test. + reasonHuman := ineligErr.Reason + switch { + case ineligErr.Simultaneous: + // reasonHuman stays ineligErr.Reason, already complete. + case ineligErr.BothSidesBarred: + // bc-cse item 10: neither side has an opponent to hand the + // default win to, so the single-sided builder's remedy + // clauses do not apply. + reasonHuman = bothSidesBarredReasonHuman(store, id, mid) + default: + reasonHuman = reasonHumanForBarredCompetitor(store, id, mid, ineligErr.PlayerID, ineligErr.Reason, ineligErr.MatchID, ineligErr.Decision) + } c.JSON(http.StatusConflict, gin.H{ "error": "ineligible_competitor", "playerId": ineligErr.PlayerID, "reason": ineligErr.Reason, - "reasonHuman": domain.ResolveReasonHuman(ineligErr.Reason), + "reasonHuman": reasonHuman, }) return } @@ -2481,16 +2807,18 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti "playerId": alreadyIneligErr.PlayerID, "matchId": alreadyIneligErr.MatchID, "reason": alreadyIneligErr.Reason, - "reasonHuman": domain.ResolveReasonHuman(alreadyIneligErr.Reason), + "reasonHuman": reasonHumanForBarredCompetitor(store, id, mid, alreadyIneligErr.PlayerID, alreadyIneligErr.Reason, alreadyIneligErr.MatchID, alreadyIneligErr.Decision), }) return } var courtBusyErr *engine.CourtBusyError if errors.As(engErr, &courtBusyErr) { - respondCourtBusy(c, courtBusyErr, "starting a new one") + respondCourtBusy(c, store, courtBusyErr, courtBusyRemedyStart) return } - if respondIfDownstreamKnockoutScored(c, engErr) { + // A pool correction that would move a qualifier out of a knockout + // match being fought now: terminal, not confirmable. + if respondIfDownstreamKnockoutRunning(c, engErr) { return } // bc-kcdg: correcting this match would change an already-propagated @@ -2521,7 +2849,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // which the client write-queue treats as transient and retries // (mp-q8c6 poisoned-queue pattern) instead of showing the message. // A corrupt overrides.json (computeStandingsFrom, reached here via - // the mp-e2k1 mixed-pool guard) is the other case + // the pool requalification check) is the other case // respondIfEngineWriteError maps, to the same terminal 422 the // decision/daihyosen/league-tiebreak/chusen-candidates handlers use; // DELETE .../overrides (handlers_competition.go) is the repair door. @@ -2558,12 +2886,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // from the one the operator corrected, so each gets its own // match_updated broadcast; a client watching only that court/match // would otherwise never learn its verdict was cleared. - for _, reopened := range reopenedDownstream { - hub.Broadcast(EventMatchUpdated, gin.H{ - "competitionId": id, - "matchId": reopened.ID, - }) - } + broadcastReopenedDownstream(hub, id, reopenedDownstream) // T085/T092, when a kiken or fusenpai is recorded, the engine // persisted a CompetitorStatus for the losing player; surface // it so admin clients can invalidate cached match lists. @@ -2606,7 +2929,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti }) } } - tryAutoCompletePools(c, eng, hub, id) + tryAutoCompletePoolsAfterWrite(c, eng, hub, id, *result) // Don't echo internal write-ordering metadata back in the response. result.Rev = 0 diff --git a/internal/mobileapp/handlers_match_kachinuki_test.go b/internal/mobileapp/handlers_match_kachinuki_test.go index d2509d036..c8bcbadef 100644 --- a/internal/mobileapp/handlers_match_kachinuki_test.go +++ b/internal/mobileapp/handlers_match_kachinuki_test.go @@ -38,6 +38,14 @@ import ( // with two team participants (Ryu, Tora) whose lineups are keyed by the // team PARTICIPANT ID, mirroring how the lineup editor saves them. func setupKachinukiScoreServer(t *testing.T, compID string) (*gin.Engine, *state.Store) { + t.Helper() + r, store, _ := setupKachinukiScoreServerWithHub(t, compID) + return r, store +} + +// setupKachinukiScoreServerWithHub is setupKachinukiScoreServer that also +// hands back the hub, for a test that asserts what was broadcast. +func setupKachinukiScoreServerWithHub(t *testing.T, compID string) (*gin.Engine, *state.Store, *Hub) { t.Helper() store, err := state.NewStore(t.TempDir()) require.NoError(t, err) @@ -84,7 +92,7 @@ func setupKachinukiScoreServer(t *testing.T, compID string) (*gin.Engine, *state // point is that an obligation created on one endpoint cannot be walked // around by picking the other. RegisterDecisionHandlers(admin, eng, store, store, hub) - return r, store + return r, store, hub } // postDecision POSTs a decision payload to the decision endpoint. @@ -807,37 +815,24 @@ func completedKachinukiPoolMatch() state.MatchResult { } } -// TestDecisionHandler_KachinukiReopenPendingRequiresReason pins E3's kachinuki -// branch (mp-gmcg review): the /decision handler keeps the reopen-pending -// snapshot read ONLY for kachinuki, and a decision that completes a REOPENED -// kachinuki match must still carry the audit reason. If E3 wrongly skipped the -// read for kachinuki too, the obligation would silently vanish and the kiken -// would land unaudited instead of 400-ing on the missing reason. -func TestDecisionHandler_KachinukiReopenPendingRequiresReason(t *testing.T) { +// TestDecisionHandler_KachinukiReopenEndsWithoutReason: a match can be reopened +// without any reason, and nothing is gated on that (operator ruling +// 2026-09-25). A kiken recorded on a kachinuki match reopened with no reason +// lands without one, and the flag the reopen set is cleared. +func TestDecisionHandler_KachinukiReopenEndsWithoutReason(t *testing.T) { compID := "kachinuki-decision-reopen" r, store := setupKachinukiScoreServer(t, compID) require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedKachinukiPoolMatch()})) - // Reason-less reopen: the match goes back to running with ReopenPending set, - // the audit obligation now owed by whatever finalizes it next. require.Equal(t, http.StatusOK, postReopen(t, r, compID, "P1-0", "").Code) require.True(t, loadPoolMatch(t, store, compID, "P1-0").ReopenPending) - // A kiken decision with NO reason must be refused: /decision is the OTHER - // way to finalize, so it collects the reopen reason too. w := postDecision(t, r, compID, "P1-0", map[string]any{"decision": "kiken", "decisionBy": "aka"}) - require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.Contains(t, w.Body.String(), "decisionReason") - m := loadPoolMatch(t, store, compID, "P1-0") - assert.True(t, m.ReopenPending, "a blocked decision leaves the obligation outstanding") - assert.Equal(t, state.MatchStatusRunning, m.Status) - - // With a reason it lands and the obligation is discharged. - w = postDecision(t, r, compID, "P1-0", map[string]any{"decision": "kiken", "decisionBy": "aka", "decisionReason": "Ryu withdrew, injured"}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - m = loadPoolMatch(t, store, compID, "P1-0") - assert.False(t, m.ReopenPending, "the reason discharged the obligation") + m := loadPoolMatch(t, store, compID, "P1-0") assert.Equal(t, state.MatchStatusCompleted, m.Status) + assert.False(t, m.ReopenPending, "ending the match clears the flag") + assert.Empty(t, m.CorrectionReason, "no reason was given, and none is invented") } // TestReopenHandler_KachinukiPoolMatch pins the sanctioned reopen path @@ -891,6 +886,121 @@ func TestReopenHandler_KachinukiPoolMatch(t *testing.T) { assert.Len(t, matches[0].SubResults, 2) } +// broadcastsDuring returns every hub payload broadcast while fn runs. +func broadcastsDuring(t *testing.T, hub *Hub, fn func()) []string { + t.Helper() + ch := hub.Subscribe() + require.NotNil(t, ch) + var events []string + done := make(chan struct{}) + go func() { + defer close(done) + for e := range ch { + events = append(events, e.payload) + } + }() + fn() + hub.Unsubscribe(ch) + <-done + return events +} + +// TestReopenHandler_KachinukiWithdrawalRemovedBroadcastsRestore: reopening a +// kachinuki match a withdrawal ended removes the withdrawal (operator ruling +// 2026-09-24: "Everything should be able to be fixed, in case of a wrong +// entry"), so the team it barred is eligible again and both reopen doors +// broadcast that restore as competitor_status_updated, exactly as /score +// does. A reopen of a match no withdrawal ended broadcasts no status. +func TestReopenHandler_KachinukiWithdrawalRemovedBroadcastsRestore(t *testing.T) { + const compID = "kachinuki-reopen-kiken" + statusUpdateFor := func(events []string, playerID string) bool { + for _, e := range events { + if strings.Contains(e, `"type":"competitor_status_updated"`) && + strings.Contains(e, `"playerId":"`+playerID+`"`) && strings.Contains(e, `"eligible":true`) { + return true + } + } + return false + } + // seed puts Ryu v Tora on court A, bout 1 fought, and ends it with + // Ryu's (aka) kiken through the real /decision endpoint. + seed := func(t *testing.T) (*gin.Engine, *state.Store, *Hub, string, string) { + r, store, hub := setupKachinukiScoreServerWithHub(t, compID) + players, err := store.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range players { + ids[p.Name] = p.ID + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "P1-0", SideA: "Ryu", SideAID: ids["Ryu"], SideB: "Tora", SideBID: ids["Tora"], + Court: "A", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M"}, Winner: "R-1", Decision: "fought"}, + }, + }})) + w := postDecision(t, r, compID, "P1-0", map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + require.False(t, statuses[ids["Ryu"]].Eligible, "precondition: the kiken barred Ryu") + return r, store, hub, ids["Ryu"], ids["Tora"] + } + + t.Run("reopen", func(t *testing.T) { + r, store, hub, ryuID, _ := seed(t) + events := broadcastsDuring(t, hub, func() { + w := postReopen(t, r, compID, "P1-0", "Wrong entry: nobody withdrew") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + }) + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[ryuID].Eligible, "the team the withdrawal barred is eligible again") + assert.True(t, statusUpdateFor(events, ryuID), "the restore is broadcast; got %v", events) + }) + + t.Run("requeue-blocker-and-reopen", func(t *testing.T) { + r, store, hub, ryuID, toraID := seed(t) + // The court's next match, started after the kiken. It cannot involve + // Ryu, whom the kiken barred (the app refuses to start an ineligible + // team), so a third team is on court with Tora. + players, err := store.LoadParticipants(compID, false) + require.NoError(t, err) + kumaID := helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, append(players, domain.Player{ID: kumaID, Name: "Kuma", Dojo: "DojoK"}))) + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + ms = append(ms, state.MatchResult{ + ID: "P1-1", SideA: "Tora", SideAID: toraID, SideB: "Kuma", SideBID: kumaID, + Court: "A", Status: state.MatchStatusRunning, + }) + require.NoError(t, store.SavePoolMatches(compID, ms)) + events := broadcastsDuring(t, hub, func() { + w := postRequeueAndReopen(t, r, compID, "P1-0", map[string]any{"blockerCompId": compID, "blockerMatchId": "P1-1"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + }) + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[ryuID].Eligible) + assert.True(t, statusUpdateFor(events, ryuID), "the restore is broadcast; got %v", events) + }) + + t.Run("no withdrawal: no status broadcast", func(t *testing.T) { + r, store, hub := setupKachinukiScoreServerWithHub(t, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedKachinukiPoolMatch()})) + events := broadcastsDuring(t, hub, func() { + w := postReopen(t, r, compID, "P1-0", "wrong winner recorded") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + }) + var sawMatchUpdate bool + for _, e := range events { + assert.NotContains(t, e, `"type":"competitor_status_updated"`, "nothing was withdrawn, so nothing is restored") + sawMatchUpdate = sawMatchUpdate || strings.Contains(e, `"type":"match_updated"`) + } + assert.True(t, sawMatchUpdate, "the capture saw the reopen's own broadcast; got %v", events) + }) +} + // TestReopenHandler_NonKachinukiRejected: reopen exists ONLY for // kachinuki. Any other competition keeps the correction path // (completed -> completed + correctionReason) as its sole sanctioned edit. @@ -1213,16 +1323,11 @@ func endKachinukiPoolMatch(reason string) map[string]any { return payload } -// TestReopenHandler_PendingReasonEnforcedOnReEnd is the audit regression that -// makes the one-tap reopen safe (mp-gmcg). -// -// Reopen no longer collects a justification, so the ONLY thing standing -// between a discarded finalized result and no audit record at all is this -// gate: a match flagged ReopenPending cannot be completed again without a -// correctionReason. Losing it would mean a finished result could be rewritten -// with no record of who changed it or why — the exact hole the correction gate -// exists to close. -func TestReopenHandler_PendingReasonEnforcedOnReEnd(t *testing.T) { +// TestReopenHandler_ReEndNeedsNoReason: ending a match reopened without a +// reason works like ending any match (operator ruling 2026-09-25). The flag +// the reopen set is still server-owned while the match runs: a running write +// keeps it, and a client cannot clear it by sending it; the End clears it. +func TestReopenHandler_ReEndNeedsNoReason(t *testing.T) { compID := "kachinuki-reopen-pending" r, store := setupKachinukiScoreServer(t, compID) require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedKachinukiPoolMatch()})) @@ -1230,8 +1335,8 @@ func TestReopenHandler_PendingReasonEnforcedOnReEnd(t *testing.T) { require.Equal(t, http.StatusOK, postReopenRaw(t, r, compID, "P1-0", nil).Code) require.True(t, loadPoolMatch(t, store, compID, "P1-0").ReopenPending) - // A running write in between must NOT discharge the obligation: the whole - // -struct pool overwrite would otherwise blank the flag. + // A running write in between keeps the flag: the whole-struct pool + // overwrite would otherwise blank it. w := putScore(t, r, compID, "P1-0", map[string]any{ "sideA": "Ryu", "sideB": "Tora", "status": "running", "subResults": []map[string]any{ @@ -1240,9 +1345,9 @@ func TestReopenHandler_PendingReasonEnforcedOnReEnd(t *testing.T) { }) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) assert.True(t, loadPoolMatch(t, store, compID, "P1-0").ReopenPending, - "a running write must not discharge the outstanding justification") + "a running write must not clear the flag") - // A client cannot clear its own obligation by sending the flag. + // A client cannot clear the flag by sending it. w = putScore(t, r, compID, "P1-0", map[string]any{ "sideA": "Ryu", "sideB": "Tora", "status": "running", "reopenPending": false, @@ -1254,93 +1359,73 @@ func TestReopenHandler_PendingReasonEnforcedOnReEnd(t *testing.T) { assert.True(t, loadPoolMatch(t, store, compID, "P1-0").ReopenPending, "reopenPending is server-owned: a client-supplied false must be ignored") - // Ending it again without a reason is refused, and the match stays open so - // the operator can retry with one. + // Ending it again needs no reason. w = putScore(t, r, compID, "P1-0", endKachinukiPoolMatch("")) - require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.Contains(t, w.Body.String(), "correctionReason") - assert.Contains(t, w.Body.String(), "reopened", - "the message must name the cause; the operator did not knowingly perform a 'correction'") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) m := loadPoolMatch(t, store, compID, "P1-0") - assert.Equal(t, state.MatchStatusRunning, m.Status, "a refused End must leave the match open") - assert.True(t, m.ReopenPending, "the obligation survives a refused End") + assert.Equal(t, state.MatchStatusCompleted, m.Status) + assert.False(t, m.ReopenPending, "ending the match clears the flag") + assert.Empty(t, m.CorrectionReason) +} + +// TestReopenHandler_ReEndKeepsAReasonSent: a reason sent with the write that +// ends a reopened match is kept as its correction reason, since that +// completion replaces a result the reopen discarded. +func TestReopenHandler_ReEndKeepsAReasonSent(t *testing.T) { + compID := "kachinuki-reopen-reason-kept" + r, store := setupKachinukiScoreServer(t, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedKachinukiPoolMatch()})) + require.Equal(t, http.StatusOK, postReopenRaw(t, r, compID, "P1-0", nil).Code) - // With a reason it completes, the reason is recorded, and nothing is - // outstanding any more. const reason = "Ended by mistake: bout 2 was still to be fought" - w = putScore(t, r, compID, "P1-0", endKachinukiPoolMatch(reason)) + w := putScore(t, r, compID, "P1-0", endKachinukiPoolMatch(reason)) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - m = loadPoolMatch(t, store, compID, "P1-0") + m := loadPoolMatch(t, store, compID, "P1-0") assert.Equal(t, state.MatchStatusCompleted, m.Status) - assert.Equal(t, reason, m.CorrectionReason, "the justification must be persisted") - assert.False(t, m.ReopenPending, "the flag must be cleared once the record exists") + assert.Equal(t, reason, m.CorrectionReason) + assert.False(t, m.ReopenPending) } -// TestReopenHandler_PendingReasonEnforcedOnBulkScore pins the SAME rule on the -// bulk-score path. Both paths share applyCorrectionReasonUnderTx precisely so -// the audit gate cannot be walked around by picking the other endpoint. -func TestReopenHandler_PendingReasonEnforcedOnBulkScore(t *testing.T) { +// TestReopenHandler_BulkScoreEndsAReopenWithoutReason pins the same rule on the +// bulk-score path, which shares applyCorrectionReasonUnderTx with the single +// score path: a reopened match is ended there without a reason too. +func TestReopenHandler_BulkScoreEndsAReopenWithoutReason(t *testing.T) { compID := "kachinuki-reopen-bulk" r, store := setupKachinukiScoreServer(t, compID) require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedKachinukiPoolMatch()})) require.Equal(t, http.StatusOK, postReopenRaw(t, r, compID, "P1-0", nil).Code) - bulk := func(reason string) *httptest.ResponseRecorder { - t.Helper() - body, err := json.Marshal([]state.MatchResult{{ - ID: "P1-0", SideA: "Ryu", SideB: "Tora", Winner: "Ryu", - Status: state.MatchStatusCompleted, Decision: "kachinuki-exhaustion", - CorrectionReason: reason, - SubResults: []state.SubMatchResult{ - {Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M", "K"}, Winner: "R-1", Decision: "fought"}, - }, - }}) - require.NoError(t, err) - w := httptest.NewRecorder() - req, err := http.NewRequest("POST", "/api/competitions/"+compID+"/matches/bulk-score", bytes.NewBuffer(body)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - return w - } - - w := bulk("") + body, err := json.Marshal([]state.MatchResult{{ + ID: "P1-0", SideA: "Ryu", SideB: "Tora", Winner: "Ryu", + Status: state.MatchStatusCompleted, Decision: "kachinuki-exhaustion", + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M", "K"}, Winner: "R-1", Decision: "fought"}, + }, + }}) + require.NoError(t, err) + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/competitions/"+compID+"/matches/bulk-score", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var noReason struct { + var res struct { Succeeded int `json:"succeeded"` - Errors []struct { - MatchID string `json:"matchId"` - Error string `json:"error"` - } `json:"errors"` } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &noReason)) - assert.Zero(t, noReason.Succeeded, "a reason-less completion of a reopened match must not land") - require.Len(t, noReason.Errors, 1) - assert.Contains(t, noReason.Errors[0].Error, "reopened") + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + assert.Equal(t, 1, res.Succeeded, "a reopened match is ended without a reason") m := loadPoolMatch(t, store, compID, "P1-0") - assert.Equal(t, state.MatchStatusRunning, m.Status) - assert.True(t, m.ReopenPending) - - w = bulk("Ended by mistake: bulk correction") - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var withReason struct { - Succeeded int `json:"succeeded"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &withReason)) - assert.Equal(t, 1, withReason.Succeeded) - m = loadPoolMatch(t, store, compID, "P1-0") assert.Equal(t, state.MatchStatusCompleted, m.Status) - assert.Equal(t, "Ended by mistake: bulk correction", m.CorrectionReason) - assert.False(t, m.ReopenPending, "the flag must be cleared once the record exists") + assert.False(t, m.ReopenPending) } -// TestReopenHandler_PendingReasonEnforcedOnBracketMatch is the bracket half of -// the same contract. It is a genuinely separate path: the bracket score write -// copies the payload into the stored BracketMatch field by field and -// deliberately never reads ReopenPending off the client-supplied body, so -// without dischargeReopenPendingUnderTx the flag would survive the justified End -// and demand a fresh reason on every subsequent write. -func TestReopenHandler_PendingReasonEnforcedOnBracketMatch(t *testing.T) { +// TestReopenHandler_BracketReEndNeedsNoReason is the bracket half of the same +// rule. It is a genuinely separate path: the bracket score write copies the +// payload into the stored BracketMatch field by field and never reads +// ReopenPending off the client-supplied body, so dischargeReopenPendingUnderTx +// is what clears the flag. After that, a later correction is gated only by the +// ordinary completed -> completed rule. +func TestReopenHandler_BracketReEndNeedsNoReason(t *testing.T) { compID := "kachinuki-reopen-bracket-pending" r, store := setupKachinukiScoreServer(t, compID) require.NoError(t, store.SaveBracket(compID, &state.Bracket{ @@ -1368,7 +1453,7 @@ func TestReopenHandler_PendingReasonEnforcedOnBracketMatch(t *testing.T) { require.Equal(t, http.StatusOK, postReopenRaw(t, r, compID, "R1M0", nil).Code) bm := loadBM() require.Equal(t, state.MatchStatusRunning, bm.Status) - assert.True(t, bm.ReopenPending, "a reason-less bracket reopen must flag the justification as outstanding") + assert.True(t, bm.ReopenPending, "a reason-less bracket reopen sets the flag") end := func(reason string) map[string]any { payload := map[string]any{ @@ -1385,95 +1470,59 @@ func TestReopenHandler_PendingReasonEnforcedOnBracketMatch(t *testing.T) { } w := putScore(t, r, compID, "R1M0", end("")) - require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.Contains(t, w.Body.String(), "reopened") - assert.Equal(t, state.MatchStatusRunning, loadBM().Status, "a refused End must leave the match open") - - const reason = "Ended by mistake: semifinal was still live" - w = putScore(t, r, compID, "R1M0", end(reason)) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) bm = loadBM() assert.Equal(t, state.MatchStatusCompleted, bm.Status) - assert.Equal(t, reason, bm.CorrectionReason) - assert.False(t, bm.ReopenPending, "the bracket flag must be cleared once the record exists") + assert.False(t, bm.ReopenPending, "the bracket flag is cleared by the End") - // And the next ordinary correction is gated only by the normal - // completed -> completed rule, not by a stale reopen obligation. + // The next write is an ordinary correction of a completed result, which + // still needs a reason. + w = putScore(t, r, compID, "R1M0", end("")) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) w = putScore(t, r, compID, "R1M0", end("Scoring error: wrong waza")) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) assert.False(t, loadBM().ReopenPending) } -// TestReopenHandler_PendingReasonEnforcedOnDecisionPoolMatch closes the hole -// the one-tap reopen opened: POST /decision is the OTHER way to finalize a -// match, so an outstanding justification has to be collected there too. -// -// The POOL failure was a SILENT DISCHARGE. RecordDecisionTx builds its own -// MatchResult and the pool write is a whole-struct overwrite (`*r = *result`), -// so ReopenPending was blanked as a side effect: the match completed, the flag -// vanished, and no audit record was ever written. An operator could reopen a -// finalized encounter with one tap, record a kiken, and leave no trace that -// the earlier result had been discarded. -func TestReopenHandler_PendingReasonEnforcedOnDecisionPoolMatch(t *testing.T) { - compID := "kachinuki-reopen-decision-pool" - r, store := setupKachinukiScoreServer(t, compID) - require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedKachinukiPoolMatch()})) - - require.Equal(t, http.StatusOK, postReopenRaw(t, r, compID, "P1-0", nil).Code) - require.True(t, loadPoolMatch(t, store, compID, "P1-0").ReopenPending) - - // Reason-less decision is refused, and the match stays open to retry. - w := postDecision(t, r, compID, "P1-0", map[string]any{ - "decision": "kiken-voluntary", "decisionBy": "shiro", - }) - require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.Contains(t, w.Body.String(), "decisionReason", - "the endpoint must name its OWN audit field, not /score's correctionReason") - assert.Contains(t, w.Body.String(), "reopened", "the message must name the cause") - m := loadPoolMatch(t, store, compID, "P1-0") - assert.Equal(t, state.MatchStatusRunning, m.Status, "a refused decision must leave the match open") - assert.True(t, m.ReopenPending, "the obligation survives a refused decision") - - // Whitespace is not a justification. - w = postDecision(t, r, compID, "P1-0", map[string]any{ - "decision": "kiken-voluntary", "decisionBy": "shiro", "decisionReason": " ", - }) - require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.True(t, loadPoolMatch(t, store, compID, "P1-0").ReopenPending) +// TestReopenHandler_DecisionEndsAReopenedPoolMatch: POST /decision is the +// other way to end a match, and it needs no reason after a reason-less reopen +// either. The pool write is a whole-struct overwrite, so the flag is cleared +// by the write itself; a reason given is kept as both the decision reason and +// the correction reason, and the bout log survives the decision. +func TestReopenHandler_DecisionEndsAReopenedPoolMatch(t *testing.T) { + for _, tc := range []struct{ name, reason string }{ + {"no reason", ""}, + {"with a reason", "Ended by mistake: Ryu withdrew before bout 2"}, + } { + t.Run(tc.name, func(t *testing.T) { + compID := "kachinuki-reopen-decision-pool" + r, store := setupKachinukiScoreServer(t, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{completedKachinukiPoolMatch()})) + require.Equal(t, http.StatusOK, postReopenRaw(t, r, compID, "P1-0", nil).Code) + require.True(t, loadPoolMatch(t, store, compID, "P1-0").ReopenPending) - // With a reason it lands, and the reason reaches CorrectionReason so the - // audit field means the same thing whichever endpoint finalized the match. - const reason = "Ended by mistake: Ryu withdrew before bout 2" - w = postDecision(t, r, compID, "P1-0", map[string]any{ - "decision": "kiken-voluntary", "decisionBy": "shiro", "decisionReason": reason, - }) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - m = loadPoolMatch(t, store, compID, "P1-0") - assert.Equal(t, state.MatchStatusCompleted, m.Status) - assert.Equal(t, reason, m.CorrectionReason, "the justification must be persisted, not just accepted") - assert.False(t, m.ReopenPending, "the flag must be cleared once the record exists") - // The CorrectionReason copy above stays either way, as this assertion's - // earlier form instructed: it is the audit field the reopen contract reads, - // and it must mean the same thing whichever endpoint finalized the match. - // - // What HAS changed is the other half. Pool storage now has DecisionBy and - // DecisionReason columns, so the justification is durable in its own right - // rather than surviving only as the correction note. This assertion is the - // flipped version the previous one asked for: the decision audit trail must - // now round-trip, not merely be accepted and forgotten on the next load. - assert.Equal(t, reason, m.DecisionReason, - "the decision audit trail is persisted for a pool match, as it always was for a bracket match") - assert.Equal(t, "shiro", m.DecisionBy, "and so is who recorded it") - assert.NotEmpty(t, m.SubResults, "the bout log survives the decision (FIK Art. 32)") + body := map[string]any{"decision": "kiken-voluntary", "decisionBy": "shiro"} + if tc.reason != "" { + body["decisionReason"] = tc.reason + } + w := postDecision(t, r, compID, "P1-0", body) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + m := loadPoolMatch(t, store, compID, "P1-0") + assert.Equal(t, state.MatchStatusCompleted, m.Status) + assert.False(t, m.ReopenPending, "ending the match clears the flag") + assert.Equal(t, tc.reason, m.CorrectionReason) + assert.Equal(t, tc.reason, m.DecisionReason, "the decision's own audit trail is persisted for a pool match") + assert.Equal(t, "shiro", m.DecisionBy) + assert.NotEmpty(t, m.SubResults, "the bout log survives the decision (FIK Art. 32)") + }) + } } -// TestReopenHandler_PendingReasonEnforcedOnDecisionBracketMatch is the same -// contract on the bracket, where the pre-fix bug was the OPPOSITE one. The -// bracket write is field-by-field and never copies ReopenPending, so a -// decision left the flag set on a COMPLETED match: no audit record, and every -// later write refused for an obligation that could no longer be discharged. -// One shared discharge pass fixes both, which is why both are pinned. -func TestReopenHandler_PendingReasonEnforcedOnDecisionBracketMatch(t *testing.T) { +// TestReopenHandler_DecisionEndsAReopenedBracketMatch is the same rule on the +// bracket, whose write is field-by-field and never copies ReopenPending: the +// shared discharge pass is what clears the flag there, so a completed match +// never keeps it. +func TestReopenHandler_DecisionEndsAReopenedBracketMatch(t *testing.T) { compID := "kachinuki-reopen-decision-bracket" r, store := setupKachinukiScoreServer(t, compID) require.NoError(t, store.SaveBracket(compID, &state.Bracket{ @@ -1503,26 +1552,16 @@ func TestReopenHandler_PendingReasonEnforcedOnDecisionBracketMatch(t *testing.T) w := postDecision(t, r, compID, "R1M0", map[string]any{ "decision": "fusenpai", "decisionBy": "aka", }) - require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.Equal(t, state.MatchStatusRunning, loadBM().Status) - - const reason = "Ended by mistake: Tora did not answer the call" - w = postDecision(t, r, compID, "R1M0", map[string]any{ - "decision": "fusenpai", "decisionBy": "aka", "decisionReason": reason, - }) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) bm := loadBM() assert.Equal(t, state.MatchStatusCompleted, bm.Status) - assert.Equal(t, reason, bm.CorrectionReason) - assert.False(t, bm.ReopenPending, "a completed match must never keep an undischargeable obligation") + assert.False(t, bm.ReopenPending, "a completed match never keeps the flag") assert.NotEmpty(t, bm.SubResults, "the bout log survives the decision") } -// TestDecisionHandler_UnreopenedMatchNeedsNoReason guards the blast radius: the -// gate keys on ReopenPending ALONE, so the ordinary kiken/fusenpai flow (by far -// the common case, and the one RemainingMatchesPanel drives in bulk) must stay -// reasonless. A gate that fired on every decision would be a worse bug than the -// hole it closed. +// TestDecisionHandler_UnreopenedMatchNeedsNoReason: the ordinary kiken/fusenpai +// flow (by far the common case, and the one RemainingMatchesPanel drives in +// bulk) needs no reason, and a first finalization records no correction. func TestDecisionHandler_UnreopenedMatchNeedsNoReason(t *testing.T) { compID := "kachinuki-decision-no-reopen" r, store := setupKachinukiScoreServer(t, compID) @@ -1540,13 +1579,12 @@ func TestDecisionHandler_UnreopenedMatchNeedsNoReason(t *testing.T) { assert.Empty(t, stored.CorrectionReason, "a first finalization is not a correction") } -// TestReopenHandler_PendingReasonEnforcedOnBronzeMatch runs the same contract -// against the bronze (3rd-place) match, which is a SIBLING of bracket.Rounds -// rather than an element of it. A rounds-only loop never reaches it, so a -// bronze that was reopened would keep its outstanding justification forever -// and every later write would be refused — the exact branch-omission bug -// lookupMatchSnapshot's single traversal exists to prevent. -func TestReopenHandler_PendingReasonEnforcedOnBronzeMatch(t *testing.T) { +// TestReopenHandler_BronzeReEndNeedsNoReason runs the same rule against the +// bronze (3rd-place) match, which is a SIBLING of bracket.Rounds rather than an +// element of it. A rounds-only loop never reaches it, so a reopened bronze +// would keep its flag forever; lookupMatchSnapshot's single traversal and the +// discharge pass both reach it. +func TestReopenHandler_BronzeReEndNeedsNoReason(t *testing.T) { compID := "kachinuki-reopen-bronze-pending" r, store := setupKachinukiScoreServer(t, compID) require.NoError(t, store.SaveBracket(compID, &state.Bracket{ @@ -1574,32 +1612,17 @@ func TestReopenHandler_PendingReasonEnforcedOnBronzeMatch(t *testing.T) { require.Equal(t, http.StatusOK, postReopenRaw(t, r, compID, "B0", nil).Code) assert.True(t, loadBronze().ReopenPending) - end := func(reason string) map[string]any { - payload := map[string]any{ - "sideA": "Ryu", "sideB": "Tora", "winner": "Ryu", - "status": "completed", "decision": "kachinuki-exhaustion", - "subResults": []map[string]any{ - kachinukiSub(1, "R-1", "W-1", []string{"M"}, "R-1", "fought"), - }, - } - if reason != "" { - payload["correctionReason"] = reason - } - return payload - } - - w := putScore(t, r, compID, "B0", end("")) - require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.Contains(t, w.Body.String(), "reopened") - assert.Equal(t, state.MatchStatusRunning, loadBronze().Status) - - const reason = "Ended by mistake: bronze was still live" - w = putScore(t, r, compID, "B0", end(reason)) + w := putScore(t, r, compID, "B0", map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "winner": "Ryu", + "status": "completed", "decision": "kachinuki-exhaustion", + "subResults": []map[string]any{ + kachinukiSub(1, "R-1", "W-1", []string{"M"}, "R-1", "fought"), + }, + }) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) bronze := loadBronze() assert.Equal(t, state.MatchStatusCompleted, bronze.Status) - assert.Equal(t, reason, bronze.CorrectionReason) - assert.False(t, bronze.ReopenPending, "the bronze flag must be cleared once the record exists") + assert.False(t, bronze.ReopenPending, "the bronze flag is cleared by the End") } // TestReopenHandler_ReasonSurvivesTheReEnd is the audit-trail regression diff --git a/internal/mobileapp/handlers_match_test.go b/internal/mobileapp/handlers_match_test.go index ebe56de59..88d501cf5 100644 --- a/internal/mobileapp/handlers_match_test.go +++ b/internal/mobileapp/handlers_match_test.go @@ -1029,7 +1029,9 @@ func TestScoreHandler_KikenSameNameNamesakesNoWinnerIDRejected(t *testing.T) { // TestScoreHandler_CorruptOverrides_TerminalErrorThenRepairable is PR #416 // finding 10: a corrupt overrides.json makes computeStandingsFrom (reached -// during a mixed-format pool score write via the mp-e2k1 guard) fail closed, +// during a mixed-format pool score write via the pool requalification check, +// which reads standings once the written pool is complete and its knockout is +// drawn) fail closed, // correctly -- but before this fix the error was unmapped and fell through // to internalError's 500, which the SPA's offline write queue retries // forever (mp-q8c6 poisoned-queue pattern), with no way to repair the file @@ -1039,6 +1041,20 @@ func TestScoreHandler_KikenSameNameNamesakesNoWinnerIDRejected(t *testing.T) { // naming the file, and Store.ResetOverridesForce (called here directly, // since the reset-overrides HTTP handler in handlers_competition.go is // outside this change's scope) is the load-free repair door. +// seatUnresolvedPoolAKnockout gives a mixed test competition the knockout its +// Pool A feeds, drawn but not yet seated ("Pool A-1st" still in the slot). +// The pool requalification check reads standings only once the written pool +// is complete and a drawn knockout exists, so a corrupt-overrides test needs +// one for that read to happen at all. +func seatUnresolvedPoolAKnockout(t *testing.T, store *state.Store, compID string) { + t.Helper() + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{ + ID: "m-r1-0", MatchNumber: 1, + PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-1st", + SideA: "Pool A-1st", SideB: "Pool B-1st", + }}}})) +} + func TestScoreHandler_CorruptOverrides_TerminalErrorThenRepairable(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) defer os.RemoveAll(tempDir) @@ -1062,6 +1078,7 @@ func TestScoreHandler_CorruptOverrides_TerminalErrorThenRepairable(t *testing.T) require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ {ID: "Pool A-0", SideA: "Alice", SideAID: aliceID, SideB: "Bob", SideBID: bobID, Status: state.MatchStatusScheduled}, })) + seatUnresolvedPoolAKnockout(t, store, compID) // Corrupt overrides.json directly (mirrors engine's corruptOverridesFile // test helper, package-private there): the competition directory must @@ -1131,6 +1148,7 @@ func TestBulkScoreHandler_CorruptOverrides_ReasonEnriched(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled}, })) + seatUnresolvedPoolAKnockout(t, store, compID) overridesPath := filepath.Join(store.GetFolder(), "competitions", compID, "overrides.json") require.NoError(t, os.WriteFile(overridesPath, []byte("{not valid json"), 0o600)) @@ -1374,6 +1392,13 @@ func TestQuickScoreHandler(t *testing.T) { for _, sub := range result.SubResults { assert.Empty(t, sub.SideA, "sub-bout SideA must be empty, not team name") assert.Empty(t, sub.SideB, "sub-bout SideB must be empty, not team name") + // Every row carries a result, so a later Save correction echoing + // these rows is not refused as unfought, and a draw row counts as + // the IT it stands for. + assert.True(t, sub.HasResult(), "bout %d must carry a result", sub.Position) + if sub.Winner == "" { + assert.Equal(t, state.DecisionDraw, sub.Decision, "a quick-scored draw is a Tie") + } } standings, err := eng.CalculatePoolStandings("c1") @@ -1505,6 +1530,88 @@ func TestQuickScoreHandler(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "exceeds maximum") }) + + // bc-cse finding 8: quick-score must run the SAME team finish gate as + // /score and bulk-score (refuseUnfinishedTeamFinish, operator ruling + // 2026-09-24: every bout of a non-kachinuki team match has a result or a + // decision), so a count that does not sum to teamSize is refused, not + // silently stored with missing rows a later Save correction would then + // itself refuse. + t.Run("bc-cse finding 8: 2+1+0 of 5 refused, nothing written", func(t *testing.T) { + fg := state.Competition{ID: "c1-finish-gate", TeamSize: 5} + require.NoError(t, store.SaveCompetition(&fg)) + require.NoError(t, store.SavePoolMatches("c1-finish-gate", []state.MatchResult{ + {ID: "PoolA-1", SideA: "TeamA", SideB: "TeamB"}, + })) + body, _ := json.Marshal(map[string]any{ + "sideA": "TeamA", "sideB": "TeamB", + "teamAWins": 2, "teamBWins": 1, "draws": 0, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/competitions/c1-finish-gate/matches/PoolA-1/quick-score", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + // Same message shape as /score's own refusal (unfinishedTeamBoutsMessage). + assert.Contains(t, w.Body.String(), "no result") + assert.Contains(t, w.Body.String(), "Record a score, a Tie, or a Fusensho before finishing.") + + matches, err := store.LoadPoolMatches("c1-finish-gate") + require.NoError(t, err) + require.Len(t, matches, 1) + assert.NotEqual(t, state.MatchStatusCompleted, matches[0].Status, "nothing written") + assert.Empty(t, matches[0].SubResults, "nothing written") + }) + + t.Run("bc-cse finding 8: 2+1+2 of 5 stored", func(t *testing.T) { + fg := state.Competition{ID: "c1-finish-gate-2", TeamSize: 5} + require.NoError(t, store.SaveCompetition(&fg)) + require.NoError(t, store.SavePoolMatches("c1-finish-gate-2", []state.MatchResult{ + {ID: "PoolA-1", SideA: "TeamA", SideB: "TeamB"}, + })) + body, _ := json.Marshal(map[string]any{ + "sideA": "TeamA", "sideB": "TeamB", + "teamAWins": 2, "teamBWins": 1, "draws": 2, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/competitions/c1-finish-gate-2/matches/PoolA-1/quick-score", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + matches, err := store.LoadPoolMatches("c1-finish-gate-2") + require.NoError(t, err) + require.Len(t, matches, 1) + assert.Equal(t, state.MatchStatusCompleted, matches[0].Status) + assert.Len(t, matches[0].SubResults, 5) + }) + + // The one exemption /score and bulk-score make: a correction over a + // recorded withdrawal keeps the ruling, which credits the bouts nobody + // fought, so a short count is not a finish it has to answer for. + t.Run("a short count over a recorded kiken keeps the kiken", func(t *testing.T) { + fg := state.Competition{ID: "c1-finish-gate-kiken", TeamSize: 5} + require.NoError(t, store.SaveCompetition(&fg)) + require.NoError(t, store.SavePoolMatches("c1-finish-gate-kiken", []state.MatchResult{{ + ID: "PoolA-1", SideA: "TeamA", SideB: "TeamB", Winner: "TeamA", + Status: state.MatchStatusCompleted, Decision: "kiken-voluntary", DecisionBy: "shiro", + }})) + body, _ := json.Marshal(map[string]any{ + "sideA": "TeamA", "sideB": "TeamB", + "teamAWins": 2, "teamBWins": 1, "draws": 0, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/competitions/c1-finish-gate-kiken/matches/PoolA-1/quick-score", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + matches, err := store.LoadPoolMatches("c1-finish-gate-kiken") + require.NoError(t, err) + require.Len(t, matches, 1) + assert.Equal(t, "kiken-voluntary", matches[0].Decision) + assert.Len(t, matches[0].SubResults, 5, "the unfought bouts are padded for the default-win credit") + }) } // TestScoreHandler_RevGuard validates the C2 monotonic-revision guard for @@ -3821,206 +3928,6 @@ func TestBulkScoreHandler_DownstreamKnockoutPlayed_ReasonCode(t *testing.T) { assert.Empty(t, b2.Rounds[1][0].Winner, "the reopened match's stale verdict was cleared") } -// seedMixedCompWithScoredKnockoutFinisher builds a minimal Mixed competition -// (bc-cse finding 1) with two 2-player pools (PoolWinners=1), both pool -// matches already scored, and a knockout bracket whose single round-0 match -// (the two pool winners, A1 vs B1) is COMPLETED with A1 as winner. -// Re-scoring "Pool A-0" to flip its finisher (A1 -> A2) then hits the mp-e2k1 -// guard inside RecordMatchResultWithIneligibility(Tx): the knockout leaf -// already carries A1's own scored result, so displacing A1 from the pool is -// refused with *engine.DownstreamKnockoutScoredError. -// -// Mirrors internal/engine's saveMixedCompForGuardTest (scoring_tx_test.go), -// rebuilt here rather than reused because that helper's bracket construction -// goes through *Engine's unexported buildBracketFromDraw, not reachable from -// this package; the completed bracket is built by hand instead, which is -// sufficient since hasStartedKnockoutMatchTx (the guard's own downstream -// lookup) matches by name/id directly against bracket.Rounds, not by -// re-deriving the bracket from the pools. -func seedMixedCompWithScoredKnockoutFinisher(t *testing.T, store *state.Store, compID string) { - t.Helper() - require.NoError(t, store.SaveCompetition(&state.Competition{ - ID: compID, - Name: compID, - Format: state.CompFormatMixed, - Status: state.CompStatusPools, - Courts: []string{"A"}, - PoolWinners: 1, - HasParticipantIDs: true, - })) - require.NoError(t, store.SavePools(compID, []helper.Pool{ - {PoolName: "Pool A", Players: []helper.Player{ - {ID: "a1-id", Name: "A1", Dojo: "Dojo A1"}, {ID: "a2-id", Name: "A2", Dojo: "Dojo A2"}, - }}, - {PoolName: "Pool B", Players: []helper.Player{ - {ID: "b1-id", Name: "B1", Dojo: "Dojo B1"}, {ID: "b2-id", Name: "B2", Dojo: "Dojo B2"}, - }}, - })) - require.NoError(t, store.SaveParticipants(compID, []domain.Player{ - {ID: "a1-id", Name: "A1", Dojo: "Dojo A1"}, - {ID: "a2-id", Name: "A2", Dojo: "Dojo A2"}, - {ID: "b1-id", Name: "B1", Dojo: "Dojo B1"}, - {ID: "b2-id", Name: "B2", Dojo: "Dojo B2"}, - })) - require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ - {ID: "Pool A-0", SideA: "A1", SideB: "A2", SideAID: "a1-id", SideBID: "a2-id", - Winner: "A1", WinnerID: "a1-id", IpponsA: []string{"M", "M"}, Status: state.MatchStatusCompleted}, - {ID: "Pool B-0", SideA: "B1", SideB: "B2", SideAID: "b1-id", SideBID: "b2-id", - Winner: "B1", WinnerID: "b1-id", IpponsA: []string{"M", "M"}, Status: state.MatchStatusCompleted}, - })) - require.NoError(t, store.SaveBracket(compID, &state.Bracket{ - Rounds: [][]state.BracketMatch{ - { - {ID: "m-r1-0", SideA: "A1", SideB: "B1", SideAID: "a1-id", SideBID: "b1-id", - Winner: "A1", WinnerID: "a1-id", IpponsA: []string{"M"}, Status: state.MatchStatusCompleted}, - }, - }, - })) -} - -// TestScoreHandler_DownstreamKnockoutScored_409Shape pins the mp-e2k1 wire -// contract for /score, now routed through the shared -// respondIfDownstreamKnockoutScored helper (bc-cse finding 1) rather than -// hand-copied inline: re-scoring a completed pool match to flip its -// qualifying finisher, while the knockout leaf that finisher's win already -// fed carries its own scored result, is refused with HTTP 409 -// {"error":"downstream_knockout_scored","pool","finisher","matchId","message"}. -// Revert the respondIfDownstreamKnockoutScored extraction back to its old -// inline body (or drop the call) to see this go red only if the mapping is -// removed entirely; it otherwise pins the extraction preserved behaviour. -func TestScoreHandler_DownstreamKnockoutScored_409Shape(t *testing.T) { - r, store, _, _, tempDir := setupTestRouter(t) - defer os.RemoveAll(tempDir) - - compID := "e2k1-score-409" - seedMixedCompWithScoredKnockoutFinisher(t, store, compID) - - body, _ := json.Marshal(map[string]any{ - "sideA": "A1", "sideB": "A2", - "winner": "A2", "ipponsB": []string{"M"}, - "status": "completed", "correctionReason": "scoresheet was misread", - }) - w := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/competitions/"+compID+"/matches/Pool A-0/score", bytes.NewBuffer(body)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) - var resp map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, "downstream_knockout_scored", resp["error"]) - assert.Equal(t, "Pool A", resp["pool"]) - assert.Equal(t, "A1", resp["finisher"]) - assert.Equal(t, "m-r1-0", resp["matchId"]) - assert.NotEmpty(t, resp["message"]) - - stored, err := store.LoadPoolMatches(compID) - require.NoError(t, err) - for _, m := range stored { - if m.ID == "Pool A-0" { - assert.Equal(t, "A1", m.Winner, "a refusal must leave the pool match untouched") - } - } -} - -// TestQuickScoreHandler_DownstreamKnockoutScored_409Shape pins the FIXED wire -// contract (bc-cse finding 1): before this fix, quick-score wrote through -// RecordMatchResultWithIneligibility (added to carry the bc-kcdg force -// option) with no arm for *engine.DownstreamKnockoutScoredError in its error -// switch, so a re-score that displaces a pool finisher already scored into a -// downstream knockout match fell through to a generic HTTP 500 -- which the -// SPA's offline write queue retries forever for a write that can never win -// (mp-q8c6 poisoned-queue pattern). Revert only the -// respondIfDownstreamKnockoutScored arm in quick-score's error switch -// (handlers_match.go) to see this go red (500 instead of 409). -func TestQuickScoreHandler_DownstreamKnockoutScored_409Shape(t *testing.T) { - r, store, _, _, tempDir := setupTestRouter(t) - defer os.RemoveAll(tempDir) - - compID := "e2k1-qs-409" - seedMixedCompWithScoredKnockoutFinisher(t, store, compID) - - body, _ := json.Marshal(map[string]any{ - "sideA": "A1", "sideB": "A2", - "teamAWins": 0, "teamBWins": 1, - }) - w := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/competitions/"+compID+"/matches/Pool A-0/quick-score", bytes.NewBuffer(body)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) - var resp map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, "downstream_knockout_scored", resp["error"]) - assert.Equal(t, "Pool A", resp["pool"]) - assert.Equal(t, "A1", resp["finisher"]) - assert.Equal(t, "m-r1-0", resp["matchId"]) - assert.NotEmpty(t, resp["message"]) - - stored, err := store.LoadPoolMatches(compID) - require.NoError(t, err) - for _, m := range stored { - if m.ID == "Pool A-0" { - assert.Equal(t, "A1", m.Winner, "a refusal must leave the pool match untouched") - } - } -} - -// TestBulkScoreHandler_DownstreamKnockoutScored_ReasonCode pins bulk-score's -// per-entry mirror of the same mp-e2k1 mapping (bc-cse finding 1 audit): -// unlike the single-match endpoints, bulk-score is always HTTP 200 with -// per-entry partial-success results, so the refusal surfaces as a -// machine-readable Reason ("downstream_knockout_scored") on that entry, -// exactly like the existing "downstream_knockout_played" reason. Before this -// fix the entry's Reason was left empty (only the free-text Error was set), -// so a client could not distinguish it from an arbitrary rejection or learn -// that retrying with forceDownstreamReopen:true would resolve it. -func TestBulkScoreHandler_DownstreamKnockoutScored_ReasonCode(t *testing.T) { - r, store, _, _, tempDir := setupTestRouter(t) - defer os.RemoveAll(tempDir) - - compID := "e2k1-bulk-409" - seedMixedCompWithScoredKnockoutFinisher(t, store, compID) - - entry := map[string]any{ - "id": "Pool A-0", "sideA": "A1", "sideB": "A2", - "winner": "A2", "ipponsB": []string{"M"}, - "status": "completed", "correctionReason": "scoresheet was misread", - } - body, _ := json.Marshal([]map[string]any{entry}) - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/competitions/"+compID+"/matches/bulk-score", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var resp struct { - Succeeded int `json:"succeeded"` - Errors []struct { - MatchID string `json:"matchId"` - Error string `json:"error"` - Reason string `json:"reason"` - } `json:"errors"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, 0, resp.Succeeded) - require.Len(t, resp.Errors, 1) - assert.Equal(t, "Pool A-0", resp.Errors[0].MatchID) - assert.Equal(t, "downstream_knockout_scored", resp.Errors[0].Reason) - assert.NotEmpty(t, resp.Errors[0].Error) - - stored, err := store.LoadPoolMatches(compID) - require.NoError(t, err) - for _, m := range stored { - if m.ID == "Pool A-0" { - assert.Equal(t, "A1", m.Winner, "a refusal must leave the pool match untouched") - } - } -} - // TestScoreHandler_ReopenedBroadcastCarriesTheMatchID pins the SHAPE of the // match_updated event a forced correction sends for each match it reopened. // diff --git a/internal/mobileapp/handlers_participants.go b/internal/mobileapp/handlers_participants.go index 60d2e4497..0384e043c 100644 --- a/internal/mobileapp/handlers_participants.go +++ b/internal/mobileapp/handlers_participants.go @@ -195,10 +195,15 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en // PUT /competitions/:id roster path (the SPA's primary import flow); // this endpoint stays a plain array response to keep one shape. - // Load existing participants so we can preserve check-in state for - // players that survive the edit (matched by normalizedName+normalizedDojo). - // A full roster replacement via this endpoint must not silently clear - // check-ins that were already recorded. + // Load existing participants so we can preserve check-in state, AND + // identity (the participant id), for rows that survive the edit + // (matched by normalizedName+normalizedDojo). A full roster + // replacement via this endpoint must not silently clear check-ins + // that were already recorded, and -- bc-tmfn -- must not silently + // mint a fresh id for a row that already has one: a team's id is + // what team-members.yaml and lineups.yaml are keyed on, so a + // surviving row without its old id back comes back with an empty + // squad even though the team itself never left the roster. existing, err := store.LoadParticipants(id, comp.EffectiveWithZekkenName()) if err != nil { internalError(c, err, "failed to load participants") @@ -206,13 +211,16 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en } // Key by (normalizedName, normalizedDojo), NOT name alone. Tier-1 // dedup allows two same-named competitors from different dojos, so a - // name-only key would transfer check-in state between distinct - // people. helper.CompetitorKey("", name, dojo) is exactly that - // composite (with an "nd:" prefix, harmless here: the key never - // leaves this local map). - checkedInByKey := make(map[string]bool, len(existing)) + // name-only key would transfer check-in state (and identity) between + // distinct people. helper.CompetitorKey("", name, dojo) is exactly + // that composite (with an "nd:" prefix, harmless here: the key never + // leaves this local map). Keyed on the whole existing Player, not + // just CheckedIn, so this one lookup carries both fields forward; + // two maps built from the same existing slice could only ever agree + // by construction, not by anything enforcing it. + existingByKey := make(map[string]domain.Player, len(existing)) for _, ep := range existing { - checkedInByKey[helper.CompetitorKey("", ep.Name, ep.Dojo)] = ep.CheckedIn + existingByKey[helper.CompetitorKey("", ep.Name, ep.Dojo)] = ep } players := make([]domain.Player, 0, len(req.Players)) @@ -226,14 +234,21 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !comp.EffectiveWithZekkenName() { displayName = "" } + match := existingByKey[helper.CompetitorKey("", p.Name, p.Dojo)] players = append(players, domain.Player{ + // ID is carried forward for a row that matches an existing + // participant; a genuinely new row leaves this empty and + // saveParticipantsNoLock mints a fresh id for it, same as + // today. match is the zero Player for a new row, whose ID is + // already "". + ID: match.ID, Name: p.Name, DisplayName: displayName, Dojo: p.Dojo, Metadata: p.Metadata, Source: helper.CanonicalRegistrationSource(p.Source), PoolPosition: int64(i), - CheckedIn: checkedInByKey[helper.CompetitorKey("", p.Name, p.Dojo)], + CheckedIn: match.CheckedIn, }) } diff --git a/internal/mobileapp/handlers_participants_test.go b/internal/mobileapp/handlers_participants_test.go index f5830a98c..50595be74 100644 --- a/internal/mobileapp/handlers_participants_test.go +++ b/internal/mobileapp/handlers_participants_test.go @@ -547,6 +547,74 @@ func TestBatchPostPureRemovalDropsOrphanedSeed(t *testing.T) { assert.Equal(t, 3, byName["Ghost Competitor"].SeedRank) } +// bc-tmfn: the batch POST replace carries no id in its wire DTO, so a +// surviving row must have its id resolved by (name, dojo) against the +// existing roster -- the same match the handler already computes for +// CheckedIn -- rather than left to state.saveParticipantsNoLock's mint-if- +// missing fallback, which would hand EVERY row a fresh id on every replace. +// For a team competition that identity IS what team-members.yaml is keyed +// on, so losing it on a replace that keeps the team orphans its members; a +// team dropped from the roster must have its members pruned instead of +// accumulating forever. +func TestBatchPostKeepsSurvivingTeamIDAndPrunesRemovedTeamMembers(t *testing.T) { + r, store, _, _, tempDir := setupTestRouter(t) + defer os.RemoveAll(tempDir) + + compID := "comp-batch-team-id-carry" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, + Name: "Batch Team ID Carry Test", + Kind: "team", + TeamSize: 3, + Status: state.CompStatusSetup, + })) + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {Name: "Tora A", Dojo: "Tora Dojo"}, + {Name: "Retsu B", Dojo: "Retsu Dojo"}, + })) + + before := mustLoad(t, store, compID) + require.Len(t, before, 2) + var toraID, retsuID string + for _, p := range before { + switch p.Name { + case "Tora A": + toraID = p.ID + case "Retsu B": + retsuID = p.ID + } + } + require.NotEmpty(t, toraID) + require.NotEmpty(t, retsuID) + + squadsBefore, err := store.LoadSquads(compID) + require.NoError(t, err) + require.NotEmpty(t, squadsBefore[toraID], "Tora A must have been seeded team members") + require.NotEmpty(t, squadsBefore[retsuID], "Retsu B must have been seeded team members") + + // Replace the roster keeping only Tora A, under the wire DTO's shape, + // which carries no id field at all. + body, _ := json.Marshal(map[string]any{"players": []map[string]string{ + {"name": "Tora A", "dojo": "Tora Dojo"}, + }}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/competitions/"+compID+"/participants", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var saved []domain.Player + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &saved)) + require.Len(t, saved, 1) + assert.Equal(t, toraID, saved[0].ID, "a surviving (name, dojo) match must keep its existing id, not mint a fresh one") + + squadsAfter, err := store.LoadSquads(compID) + require.NoError(t, err) + assert.Equal(t, squadsBefore[toraID], squadsAfter[toraID], "Tora A's team members must be untouched") + _, retsuStillPresent := squadsAfter[retsuID] + assert.False(t, retsuStillPresent, "Retsu B's team members must be pruned once the team leaves the roster") +} + // TestBatchPostPreservingSeedIdentityAccepted is the companion case: a bulk // replace that leaves every seeded participant's (name, dojo) untouched must // still succeed, even though other, unseeded rows in the same batch change. diff --git a/internal/mobileapp/handlers_viewer.go b/internal/mobileapp/handlers_viewer.go index 5dd5542ca..6b0d2fbf9 100644 --- a/internal/mobileapp/handlers_viewer.go +++ b/internal/mobileapp/handlers_viewer.go @@ -367,6 +367,20 @@ func buildViewerCompetitionPayload(store *state.Store, compID, courtFilter strin bracket = nil } + // bc-cse: stamp ineligibleSides BEFORE the queue-position derivation + // below, which reads the stamp back to skip a barred scheduled match. + // Gated on anyScheduledMatchHasBothSides so a competition with nothing + // this annotation could ever act on never pays for the + // LoadCompetitorStatus read. + if anyScheduledMatchHasBothSides(poolMatches, bracket) { + statuses, stErr := store.LoadCompetitorStatus(compID) + if stErr != nil { + log.Printf("mobileapp: viewer payload %s: load competitor status: %v", compID, stErr) + } else { + annotateIneligibleSides(poolMatches, bracket, statuses) + } + } + // FR-025, T036: derive per-court queue position at serve time. annotateQueuePositions(poolMatches) annotateBracketQueuePositions(bracket) @@ -774,6 +788,18 @@ func RegisterViewerHandlers(r *gin.RouterGroup, store *state.Store, eng *engine. return nil, dr.err } + // bc-cse: same gate and ordering as buildViewerCompetitionPayload's + // own ineligibleSides stamp (this endpoint's sibling aggregate); + // see anyScheduledMatchHasBothSides for why the read is gated. + if anyScheduledMatchHasBothSides(poolMatches, bracket) { + statuses, stErr := store.LoadCompetitorStatus(id) + if stErr != nil { + log.Printf("mobileapp: viewer payload %s: load competitor status: %v", id, stErr) + } else { + annotateIneligibleSides(poolMatches, bracket, statuses) + } + } + // FR-025, T036: derive per-court queue position at serve time, // see annotateQueuePositions for rationale. annotateQueuePositions(poolMatches) diff --git a/internal/mobileapp/ineligible_sides_annotation_test.go b/internal/mobileapp/ineligible_sides_annotation_test.go new file mode 100644 index 000000000..5ea15fc28 --- /dev/null +++ b/internal/mobileapp/ineligible_sides_annotation_test.go @@ -0,0 +1,246 @@ +// Package mobileapp, ineligible_sides_annotation_test.go pins bc-cse's +// read-only ineligibleSides annotation: which SCHEDULED matches carry a +// barred side, and how that feeds the queue-position derivation ("Next up" +// skips a match nobody can start). +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnyScheduledMatchHasBothSides(t *testing.T) { + t.Run("no matches", func(t *testing.T) { + assert.False(t, anyScheduledMatchHasBothSides(nil, nil)) + }) + t.Run("scheduled pool match with both ids", func(t *testing.T) { + ms := []state.MatchResult{{Status: state.MatchStatusScheduled, SideAID: "a", SideBID: "b"}} + assert.True(t, anyScheduledMatchHasBothSides(ms, nil)) + }) + t.Run("scheduled pool match missing one id", func(t *testing.T) { + ms := []state.MatchResult{{Status: state.MatchStatusScheduled, SideAID: "a"}} + assert.False(t, anyScheduledMatchHasBothSides(ms, nil)) + }) + t.Run("running match with both ids does not count", func(t *testing.T) { + ms := []state.MatchResult{{Status: state.MatchStatusRunning, SideAID: "a", SideBID: "b"}} + assert.False(t, anyScheduledMatchHasBothSides(ms, nil)) + }) + t.Run("bracket round match", func(t *testing.T) { + b := &state.Bracket{Rounds: [][]state.BracketMatch{{ + {Status: state.MatchStatusScheduled, SideAID: "a", SideBID: "b"}, + }}} + assert.True(t, anyScheduledMatchHasBothSides(nil, b)) + }) + t.Run("bronze match", func(t *testing.T) { + b := &state.Bracket{ThirdPlaceMatch: &state.BracketMatch{ + Status: state.MatchStatusScheduled, SideAID: "a", SideBID: "b", + }} + assert.True(t, anyScheduledMatchHasBothSides(nil, b)) + }) +} + +func TestAnnotateIneligibleSides(t *testing.T) { + statuses := map[string]domain.CompetitorStatus{ + "alice": {PlayerID: "alice", Eligible: false, MatchID: "Pool A-0", Reason: "kiken-voluntary at Pool A-0"}, + } + + t.Run("no statuses: no-op, whatever the matches", func(t *testing.T) { + ms := []state.MatchResult{{ID: "Pool A-1", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol"}} + annotateIneligibleSides(ms, nil, nil) + assert.Nil(t, ms[0].IneligibleSides) + }) + + t.Run("scheduled match with a barred side A is stamped", func(t *testing.T) { + ms := []state.MatchResult{{ID: "Pool A-1", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol"}} + annotateIneligibleSides(ms, nil, statuses) + require.NotNil(t, ms[0].IneligibleSides) + assert.Equal(t, "kiken-voluntary", ms[0].IneligibleSides.A) + assert.Empty(t, ms[0].IneligibleSides.B) + }) + + t.Run("scheduled match with a barred side B is stamped", func(t *testing.T) { + ms := []state.MatchResult{{ID: "Pool A-1", Status: state.MatchStatusScheduled, SideAID: "carol", SideBID: "alice"}} + annotateIneligibleSides(ms, nil, statuses) + require.NotNil(t, ms[0].IneligibleSides) + assert.Empty(t, ms[0].IneligibleSides.A) + assert.Equal(t, "kiken-voluntary", ms[0].IneligibleSides.B) + }) + + t.Run("the undo path: a match barred by ITS OWN prior decision is not stamped", func(t *testing.T) { + ms := []state.MatchResult{{ID: "Pool A-0", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "bob"}} + annotateIneligibleSides(ms, nil, statuses) + assert.Nil(t, ms[0].IneligibleSides, "Pool A-0 recorded alice's own status; it must not bar itself") + }) + + t.Run("running/completed matches are never stamped", func(t *testing.T) { + ms := []state.MatchResult{ + {ID: "Pool A-2", Status: state.MatchStatusRunning, SideAID: "alice", SideBID: "carol"}, + {ID: "Pool A-3", Status: state.MatchStatusCompleted, SideAID: "alice", SideBID: "carol"}, + } + annotateIneligibleSides(ms, nil, statuses) + assert.Nil(t, ms[0].IneligibleSides) + assert.Nil(t, ms[1].IneligibleSides) + }) + + t.Run("no barred side: not stamped", func(t *testing.T) { + ms := []state.MatchResult{{ID: "Pool A-4", Status: state.MatchStatusScheduled, SideAID: "bob", SideBID: "carol"}} + annotateIneligibleSides(ms, nil, statuses) + assert.Nil(t, ms[0].IneligibleSides) + }) + + t.Run("a bracket match is stamped the same way", func(t *testing.T) { + b := &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol"}, + }}} + annotateIneligibleSides(nil, b, statuses) + require.NotNil(t, b.Rounds[0][0].IneligibleSides) + assert.Equal(t, "kiken-voluntary", b.Rounds[0][0].IneligibleSides.A) + }) + + t.Run("the bronze match is stamped too", func(t *testing.T) { + b := &state.Bracket{ThirdPlaceMatch: &state.BracketMatch{ + ID: "m-bronze", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol", + }} + annotateIneligibleSides(nil, b, statuses) + require.NotNil(t, b.ThirdPlaceMatch.IneligibleSides) + }) + + t.Run("bc-cse: a barred side whose reason has no parseable decision leaves the annotation nil", func(t *testing.T) { + // A status set directly (e.g. via POST /competitor-status) carries + // free text with no " at " suffix, so SplitStatusReason's + // ok is false. Before the fix, out.A was stamped "" anyway (the + // struct was still allocated because a != nil), producing a non-nil + // annotation with BOTH fields empty: Go's nil check reads that as + // barred, but the JSON (A/B are `omitempty`) omits both keys, so a + // client reading a/b directly reads it as NOT barred. + freeTextStatuses := map[string]domain.CompetitorStatus{ + "dave": {PlayerID: "dave", Eligible: false, Reason: "disqualified for unsporting conduct"}, + } + ms := []state.MatchResult{{ID: "Pool A-9", Status: state.MatchStatusScheduled, SideAID: "dave", SideBID: "carol"}} + annotateIneligibleSides(ms, nil, freeTextStatuses) + assert.Nil(t, ms[0].IneligibleSides, "an unparseable barring reason must leave the annotation nil, not an empty {}") + }) + + t.Run("bc-cse: one side parses and the other does not, only the parsed side is stamped", func(t *testing.T) { + mixedStatuses := map[string]domain.CompetitorStatus{ + "alice": {PlayerID: "alice", Eligible: false, MatchID: "Pool A-0", Reason: "kiken-voluntary at Pool A-0"}, + "dave": {PlayerID: "dave", Eligible: false, Reason: "disqualified for unsporting conduct"}, + } + ms := []state.MatchResult{{ID: "Pool A-9", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "dave"}} + annotateIneligibleSides(ms, nil, mixedStatuses) + require.NotNil(t, ms[0].IneligibleSides) + assert.Equal(t, "kiken-voluntary", ms[0].IneligibleSides.A) + assert.Empty(t, ms[0].IneligibleSides.B, "dave's reason does not parse, so his side is not stamped") + }) +} + +// TestQueuePositions_SkipBarredScheduledMatch pins the "position 0" part of +// bc-cse: a stamped ineligibleSides match is not counted toward its court's +// queue, in both the pool (state.DeriveQueuePositions) and bracket +// (annotateBracketQueuePositions) derivations. +func TestQueuePositions_SkipBarredScheduledMatch(t *testing.T) { + t.Run("pool", func(t *testing.T) { + ms := []state.MatchResult{ + {ID: "Pool A-0", Court: "A", Status: state.MatchStatusScheduled, IneligibleSides: &state.IneligibleSidesAnnotation{A: "kiken-voluntary"}}, + {ID: "Pool A-1", Court: "A", Status: state.MatchStatusScheduled}, + } + positions := state.DeriveQueuePositions(ms) + assert.Equal(t, 0, positions[0], "the barred match is not queued") + assert.Equal(t, 1, positions[1], "the next real match becomes position 1") + }) + + t.Run("bracket", func(t *testing.T) { + b := &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", Court: "A", Status: state.MatchStatusScheduled, IneligibleSides: &state.IneligibleSidesAnnotation{B: "fusenpai"}}, + {ID: "m-r1-1", Court: "A", Status: state.MatchStatusScheduled}, + }}} + annotateBracketQueuePositions(b) + assert.Equal(t, 0, b.Rounds[0][0].QueuePosition) + assert.Equal(t, 1, b.Rounds[0][1].QueuePosition) + }) +} + +// TestViewerCompetitionDetail_IneligibleSides_RemovedAfterReinstate is the +// end-to-end HTTP check: a competitor withdrawn via kiken-injury bars her +// next scheduled match on the viewer detail payload (with queuePosition 0), +// and reinstating her removes the annotation and restores the queue +// position, on the very next GET. +func TestViewerCompetitionDetail_IneligibleSides_RemovedAfterReinstate(t *testing.T) { + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + + compID := "ineligible-sides-viewer" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools, + })) + aliceID, bobID, carolID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Court: "A", Status: state.MatchStatusCompleted, + Decision: "kiken-injury", DecisionBy: "aka", Winner: "Bob"}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Court: "A", Status: state.MatchStatusScheduled}, + })) + // The kiken-injury status itself (RecordDecision's normal write path, + // exercised directly here for a smaller fixture than a full /decision + // HTTP round trip). + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "test", nil, false) + require.NoError(t, err) + + gin.SetMode(gin.TestMode) + r := gin.New() + viewer := r.Group("/api/viewer") + RegisterViewerHandlers(viewer, store, eng) + + get := func() map[string]any { + w := httptest.NewRecorder() + req, rerr := http.NewRequest(http.MethodGet, "/api/viewer/competitions/"+compID, nil) + require.NoError(t, rerr) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body + } + findMatch := func(body map[string]any, id string) map[string]any { + for _, raw := range body["poolMatches"].([]any) { + m := raw.(map[string]any) + if m["id"] == id { + return m + } + } + t.Fatalf("match %s not found in poolMatches", id) + return nil + } + + before := findMatch(get(), "Pool A-1") + ineligibleSides, ok := before["ineligibleSides"].(map[string]any) + require.True(t, ok, "Pool A-1 must carry ineligibleSides while Alice is barred, got %+v", before) + assert.Equal(t, "kiken-injury", ineligibleSides["a"]) + // queuePosition is `omitempty`; a 0 (not queued) value is OMITTED from the + // wire entirely, exactly as an ordinary non-barred running/completed + // match's queuePosition already is. + assert.Nil(t, before["queuePosition"], "a barred match is not queued") + + _, err = eng.ReinstateCompetitor(compID, aliceID) + require.NoError(t, err) + + after := findMatch(get(), "Pool A-1") + assert.Nil(t, after["ineligibleSides"], "the annotation must be gone once Alice is reinstated") + assert.Equal(t, float64(1), after["queuePosition"], "the match rejoins the queue") +} diff --git a/internal/mobileapp/middleware_selfrun_test.go b/internal/mobileapp/middleware_selfrun_test.go index 33a37d629..9da0b07e3 100644 --- a/internal/mobileapp/middleware_selfrun_test.go +++ b/internal/mobileapp/middleware_selfrun_test.go @@ -1002,9 +1002,6 @@ func TestSelfRun_ReopenRequiresMainPassword(t *testing.T) { }) t.Run("with_main_password_passes_the_gate", func(t *testing.T) { - // The body carries the mandatory audit reason so the request gets past - // the handler's own 400 and reaches the engine's kachinuki-only check, - // which is what this assertion is actually about. req := jsonReq(http.MethodPost, "/api/competitions/some-comp/matches/m-r1-0/reopen", map[string]any{"reason": "wrong winner recorded"}) req.Header.Set("X-Tournament-Password", "main-pw") @@ -1012,12 +1009,12 @@ func TestSelfRun_ReopenRequiresMainPassword(t *testing.T) { r.ServeHTTP(w, req) // What matters here is that the credential cleared the middleware (a // 401 would mean the gate rejected the main password). The handler - // itself then answers 400: the fixture competition doesn't exist, so - // the engine's kachinuki-only validation fires. + // itself then answers 404: the fixture competition doesn't exist, so + // the engine finds no match to reopen. assert.NotEqual(t, http.StatusUnauthorized, w.Code, w.Body.String()) - assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) - assert.Contains(t, w.Body.String(), "kachinuki", - "the request must reach ReopenKachinukiMatch's own validation") + assert.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "not found", + "the request must reach ReopenMatch's own lookup") }) } diff --git a/internal/mobileapp/pool_rank_override_group_test.go b/internal/mobileapp/pool_rank_override_group_test.go new file mode 100644 index 000000000..1aa8d84ab --- /dev/null +++ b/internal/mobileapp/pool_rank_override_group_test.go @@ -0,0 +1,146 @@ +package mobileapp + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// PUT .../override-rank also takes a whole group of ranks ({"ranks": [...]}), +// the form the chusen panel sends: every rank is recorded and answered for as +// ONE change, so the knockout check sees only the order the operator entered. +// The group form must answer exactly as the single form does (same refusals, +// same confirmation, same receipt) and take back EVERY rank on a refusal. + +func sendRankGroup(t *testing.T, r *gin.Engine, compID string, force bool, ranks ...map[string]any) *httptest.ResponseRecorder { + t.Helper() + return sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/pools/Pool A/override-rank", map[string]any{ + "ranks": ranks, "forceDownstreamReopen": force, + }) +} + +func rankEntry(playerID string, rank int) map[string]any { + return map[string]any{"playerId": playerID, "rank": rank} +} + +func poolARanks(t *testing.T, store *state.Store, compID string) map[string]int { + t.Helper() + o, err := store.LoadOverrides(compID) + require.NoError(t, err) + return o.PoolRanks["Pool A"] +} + +func TestPoolRankOverrideGroup_RecordsTheWholeOrder(t *testing.T) { + r, store, _, _, _ := setupTestRouter(t) + compID := "rank-group-ok" + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusScheduled) + + w := sendRankGroup(t, r, compID, false, rankEntry("a2-id", 1), rankEntry("a1-id", 2)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.JSONEq(t, `{"reopenedMatches":[]}`, w.Body.String()) + assert.Equal(t, map[string]int{ + helper.CompetitorKey("a2-id", "", ""): 1, + helper.CompetitorKey("a1-id", "", ""): 2, + }, poolARanks(t, store, compID)) + b, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, "A2", b.Rounds[0][0].SideA, "the unplayed match is repainted with the new 1st") + assert.Equal(t, "a2-id", b.Rounds[0][0].SideAID) +} + +func TestPoolRankOverrideGroup_RefusesLikeTheSingleForm(t *testing.T) { + t.Run("played", func(t *testing.T) { + // The same order through both forms: A2 1st (A1 2nd). + rs, singleStore, _, _, _ := setupTestRouter(t) + seedMixedCompWithSeatedKnockout(t, singleStore, "rank-single-played", state.MatchStatusCompleted) + single := sendJSON(t, rs, http.MethodPut, "/api/competitions/rank-single-played/pools/Pool A/override-rank", map[string]any{ + "playerId": "a2-id", "rank": 1, + }) + require.Equal(t, http.StatusConflict, single.Code, single.Body.String()) + + r, store, _, _, _ := setupTestRouter(t) + compID := "rank-group-played" + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusCompleted) + w := sendRankGroup(t, r, compID, false, rankEntry("a2-id", 1), rankEntry("a1-id", 2)) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.JSONEq(t, single.Body.String(), w.Body.String(), "the group form refuses with the single form's body") + assert.Contains(t, w.Body.String(), `"error":"downstream_knockout_played"`) + assert.Empty(t, poolARanks(t, store, compID), "the refusal takes back every rank of the group") + + w = sendRankGroup(t, r, compID, true, rankEntry("a2-id", 1), rankEntry("a1-id", 2)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var receipt struct { + ReopenedMatches []struct { + ID string `json:"id"` + } `json:"reopenedMatches"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &receipt)) + require.Len(t, receipt.ReopenedMatches, 1) + assert.Equal(t, "m-r1-0", receipt.ReopenedMatches[0].ID) + assert.Len(t, poolARanks(t, store, compID), 2) + b, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusScheduled, b.Rounds[0][0].Status) + assert.Equal(t, "A2", b.Rounds[0][0].SideA) + }) + + t.Run("running", func(t *testing.T) { + for _, force := range []bool{false, true} { + r, store, _, _, _ := setupTestRouter(t) + compID := fmt.Sprintf("rank-group-running-%v", force) + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusRunning) + w := sendRankGroup(t, r, compID, force, rankEntry("a2-id", 1), rankEntry("a1-id", 2)) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), `"error":"downstream_knockout_running"`, "force=%v", force) + assert.Empty(t, poolARanks(t, store, compID), "force=%v: nothing recorded", force) + } + }) +} + +func TestPoolRankOverrideGroup_Validation(t *testing.T) { + cases := []struct { + name string + body map[string]any + want string + }{ + {"both forms", map[string]any{"playerId": "a1-id", "rank": 1, "ranks": []any{rankEntry("a2-id", 2)}}, + "send either ranks or playerId and rank, not both"}, + {"a player twice", map[string]any{"ranks": []any{rankEntry("a1-id", 1), rankEntry("a1-id", 2)}}, + `playerId "a1-id" is given more than one rank`}, + {"a rank twice", map[string]any{"ranks": []any{rankEntry("a1-id", 1), rankEntry("a2-id", 1)}}, + "rank 1 is given to more than one player"}, + {"an entry with no player", map[string]any{"ranks": []any{rankEntry("a1-id", 1), rankEntry(" ", 2)}}, + "playerId is required"}, + {"a rank that is not positive", map[string]any{"ranks": []any{rankEntry("a1-id", 1), rankEntry("a2-id", 0)}}, + "rank must be a positive integer"}, + {"a rank past the absolute cap", map[string]any{"ranks": []any{rankEntry("a1-id", 1), rankEntry("a2-id", helper.MaxRankOverride+1)}}, + fmt.Sprintf("rank must be a positive integer ≤ %d", helper.MaxRankOverride)}, + {"a rank past the pool size", map[string]any{"ranks": []any{rankEntry("a1-id", 1), rankEntry("a2-id", 3)}}, + "rank 3 exceeds pool size 2"}, + {"a player from another pool", map[string]any{"ranks": []any{rankEntry("a1-id", 1), rankEntry("b1-id", 2)}}, + `playerId "b1-id" not found in this pool`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, store, _, _, _ := setupTestRouter(t) + compID := "rank-group-invalid" + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusScheduled) + w := sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/pools/Pool A/override-rank", tc.body) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + var body struct { + Error string `json:"error"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, tc.want, body.Error) + assert.Empty(t, poolARanks(t, store, compID), "a refused group records nothing") + }) + } +} diff --git a/internal/mobileapp/pool_requalify_handler_test.go b/internal/mobileapp/pool_requalify_handler_test.go new file mode 100644 index 000000000..a6e45be27 --- /dev/null +++ b/internal/mobileapp/pool_requalify_handler_test.go @@ -0,0 +1,522 @@ +package mobileapp + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedMixedCompWithSeatedKnockout builds a minimal mixed competition: two +// 2-player pools (PoolWinners=1), both pool matches scored (A1 and B1 win), +// and a knockout whose one match (Match 1) seats "Pool A-1st" and "Pool +// B-1st" as A1 vs B1, in koStatus. Completed means A1 has already won it. +// Re-scoring "Pool A-0" for A2 then moves Pool A's 1st place out from under +// that match. +func seedMixedCompWithSeatedKnockout(t *testing.T, store *state.Store, compID string, koStatus state.MatchStatus) { + t.Helper() + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, + Name: compID, + Format: state.CompFormatMixed, + Status: state.CompStatusKnockout, + Courts: []string{"A"}, + PoolWinners: 1, + HasParticipantIDs: true, + })) + players := []domain.Player{ + {ID: "a1-id", Name: "A1", Dojo: "Dojo A1"}, {ID: "a2-id", Name: "A2", Dojo: "Dojo A2"}, + {ID: "b1-id", Name: "B1", Dojo: "Dojo B1"}, {ID: "b2-id", Name: "B2", Dojo: "Dojo B2"}, + } + require.NoError(t, store.SavePools(compID, []helper.Pool{ + {PoolName: "Pool A", Players: []helper.Player{players[0], players[1]}}, + {PoolName: "Pool B", Players: []helper.Player{players[2], players[3]}}, + })) + require.NoError(t, store.SaveParticipants(compID, players)) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "A1", SideB: "A2", SideAID: "a1-id", SideBID: "a2-id", + Winner: "A1", WinnerID: "a1-id", IpponsA: []string{"M", "M"}, Status: state.MatchStatusCompleted}, + {ID: "Pool B-0", SideA: "B1", SideB: "B2", SideAID: "b1-id", SideBID: "b2-id", + Winner: "B1", WinnerID: "b1-id", IpponsA: []string{"M", "M"}, Status: state.MatchStatusCompleted}, + })) + ko := state.BracketMatch{ + ID: "m-r1-0", MatchNumber: 1, DisplayRound: 1, Court: "A", + PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-1st", + SideA: "A1", SideB: "B1", SideAID: "a1-id", SideBID: "b1-id", + Status: koStatus, + } + if koStatus == state.MatchStatusCompleted { + ko.Winner, ko.WinnerID, ko.IpponsA = "A1", "a1-id", []string{"M"} + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ko}}})) +} + +// poolFixDoors are the four write doors a pool correction can come through, +// each sending the same correction: Pool A-0 now won by A2. force adds the +// operator's confirmation. +var poolFixDoors = []struct { + name string + send func(t *testing.T, r *gin.Engine, compID string, force bool) *httptest.ResponseRecorder +}{ + {"score", func(t *testing.T, r *gin.Engine, compID string, force bool) *httptest.ResponseRecorder { + return sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/matches/Pool A-0/score", map[string]any{ + "sideA": "A1", "sideB": "A2", "winner": "A2", "ipponsB": []string{"M"}, + "status": "completed", "correctionReason": "scoresheet was misread", "forceDownstreamReopen": force, + }) + }}, + {"quick-score", func(t *testing.T, r *gin.Engine, compID string, force bool) *httptest.ResponseRecorder { + return sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/matches/Pool A-0/quick-score", map[string]any{ + "sideA": "A1", "sideB": "A2", "teamAWins": 0, "teamBWins": 1, "forceDownstreamReopen": force, + }) + }}, + {"decision", func(t *testing.T, r *gin.Engine, compID string, force bool) *httptest.ResponseRecorder { + // aka (SideA, A1) withdraws, so A2 wins. + return sendJSON(t, r, http.MethodPost, "/api/competitions/"+compID+"/matches/Pool A-0/decision", map[string]any{ + "decision": "kiken-voluntary", "decisionBy": "aka", "forceDownstreamReopen": force, + }) + }}, +} + +func sendJSON(t *testing.T, r *gin.Engine, method, url string, payload any) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(payload) + require.NoError(t, err) + req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func assertPoolA0WonBy(t *testing.T, store *state.Store, compID, winner string) { + t.Helper() + stored, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range stored { + if m.ID == "Pool A-0" { + assert.Equal(t, winner, m.Winner) + return + } + } + t.Fatal("Pool A-0 not found") +} + +// Every pool-write door answers a correction that moves a qualifier the +// knockout already played with the confirmable 409 downstream_knockout_played, +// carrying qualifierChange so the operator is told who moves; nothing lands. +func TestPoolCorrection_QualifierMove_409Shape(t *testing.T) { + for _, door := range poolFixDoors { + t.Run(door.name, func(t *testing.T) { + r, store, _, _, _ := setupTestRouter(t) + compID := "rq-409-" + door.name + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusCompleted) + + w := door.send(t, r, compID, false) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + var resp struct { + Error string `json:"error"` + MatchID string `json:"matchId"` + BlockingMatchID string `json:"blockingMatchId"` + BlockingMatches []struct { + ID string `json:"id"` + Number int `json:"number"` + Label string `json:"label"` + } `json:"blockingMatches"` + QualifierChange []struct { + Pool string `json:"pool"` + Rank int `json:"rank"` + Place string `json:"place"` + From struct{ Name, ID string } + To struct{ Name, ID string } + } `json:"qualifierChange"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "downstream_knockout_played", resp.Error) + assert.Equal(t, "Pool A-0", resp.MatchID) + assert.Equal(t, "m-r1-0", resp.BlockingMatchID) + require.Len(t, resp.BlockingMatches, 1) + assert.Equal(t, 1, resp.BlockingMatches[0].Number) + // The words the dialog shows: the round keeps "Match 1" from + // reading as the pool match being corrected (Pool A · Match 1). + assert.Equal(t, "Match 1 (Final)", resp.BlockingMatches[0].Label) + assert.Contains(t, resp.Message, "Match 1 (Final)", "the server's message names it the same way") + require.Len(t, resp.QualifierChange, 1) + qc := resp.QualifierChange[0] + assert.Equal(t, "Pool A", qc.Pool) + assert.Equal(t, 1, qc.Rank) + assert.Equal(t, "1st", qc.Place) + assert.Equal(t, "A1", qc.From.Name) + assert.Equal(t, "a1-id", qc.From.ID) + assert.Equal(t, "A2", qc.To.Name) + assert.Equal(t, "a2-id", qc.To.ID) + assert.NotEmpty(t, resp.Message) + assertPoolA0WonBy(t, store, compID, "A1") + }) + } +} + +// bulk-score is always 200 with per-entry results, so the same refusal rides +// in the entry's Reason. +func TestBulkScore_QualifierMove_ReasonCodes(t *testing.T) { + for _, tc := range []struct { + koStatus state.MatchStatus + reason string + }{ + {state.MatchStatusCompleted, "downstream_knockout_played"}, + {state.MatchStatusRunning, "downstream_knockout_running"}, + } { + t.Run(string(tc.koStatus), func(t *testing.T) { + r, store, _, _, _ := setupTestRouter(t) + compID := "rq-bulk-" + string(tc.koStatus) + seedMixedCompWithSeatedKnockout(t, store, compID, tc.koStatus) + + w := sendJSON(t, r, http.MethodPost, "/api/competitions/"+compID+"/matches/bulk-score", []map[string]any{{ + "id": "Pool A-0", "sideA": "A1", "sideB": "A2", "winner": "A2", "ipponsB": []string{"M"}, + "status": "completed", "correctionReason": "scoresheet was misread", + }}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var resp struct { + Succeeded int `json:"succeeded"` + Errors []struct { + MatchID string `json:"matchId"` + Reason string `json:"reason"` + } `json:"errors"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, 0, resp.Succeeded) + require.Len(t, resp.Errors, 1) + assert.Equal(t, "Pool A-0", resp.Errors[0].MatchID) + assert.Equal(t, tc.reason, resp.Errors[0].Reason) + assertPoolA0WonBy(t, store, compID, "A1") + }) + } +} + +// A knockout match being fought refuses the correction at every door, and the +// operator's confirmation does not get past it (a 409, never a 5xx the +// offline queue would retry forever). +func TestPoolCorrection_RunningKnockout_409Terminal(t *testing.T) { + for _, door := range poolFixDoors { + for _, force := range []bool{false, true} { + t.Run(door.name, func(t *testing.T) { + r, store, _, _, _ := setupTestRouter(t) + compID := "rq-run-" + door.name + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusRunning) + + w := door.send(t, r, compID, force) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + var resp struct { + Error string `json:"error"` + MatchID string `json:"matchId"` + RunningMatches []struct { + ID string `json:"id"` + Number int `json:"number"` + Label string `json:"label"` + } `json:"runningMatches"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "downstream_knockout_running", resp.Error, "force=%v", force) + assert.Equal(t, "Pool A-0", resp.MatchID) + require.Len(t, resp.RunningMatches, 1) + assert.Equal(t, "m-r1-0", resp.RunningMatches[0].ID) + assert.Equal(t, "Match 1 (Final)", resp.RunningMatches[0].Label) + assert.Equal(t, "Match 1 (Final) is being fought now. Finish it or send it back to the queue, then save again.", resp.Message) + assertPoolA0WonBy(t, store, compID, "A1") + }) + } + } +} + +// Confirmed, the correction lands, the played match is reopened with the new +// qualifier seated in it, and it is announced: its own match_updated (a client +// watching only that court must hear its verdict was cleared) and, on /score, +// the reopenedMatches receipt. +func TestPoolCorrection_Forced_ReopensAndBroadcasts(t *testing.T) { + for _, door := range poolFixDoors { + t.Run(door.name, func(t *testing.T) { + r, store, _, hub, _ := setupTestRouter(t) + compID := "rq-force-" + door.name + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusCompleted) + + stop := collectEvents(t, hub) + w := door.send(t, r, compID, true) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + events := stop() + + if door.name == "score" { + var resp struct { + ReopenedMatches []struct { + ID string `json:"id"` + Number int `json:"number"` + Label string `json:"label"` + } `json:"reopenedMatches"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.ReopenedMatches, 1) + assert.Equal(t, "m-r1-0", resp.ReopenedMatches[0].ID) + assert.Equal(t, 1, resp.ReopenedMatches[0].Number) + assert.Equal(t, "Match 1 (Final)", resp.ReopenedMatches[0].Label) + } + announced := false + for _, e := range events { + if strings.Contains(e, `"matchId":"m-r1-0"`) && strings.Contains(e, string(EventMatchUpdated)) { + announced = true + } + } + assert.True(t, announced, "the reopened knockout match gets its own match_updated: %v", events) + + assertPoolA0WonBy(t, store, compID, "A2") + b, err := store.LoadBracket(compID) + require.NoError(t, err) + ko := b.Rounds[0][0] + assert.Equal(t, state.MatchStatusScheduled, ko.Status) + assert.Empty(t, ko.Winner) + assert.Equal(t, "A2", ko.SideA) + assert.Equal(t, "a2-id", ko.SideAID) + }) + } +} + +// Once the knockout has started, the after-write auto-complete runs the pool +// pass only for a completed pool write, so every door has to hand it the match +// it wrote. A pool draw corrected in knockout status leaves Pool A's 1st place +// tied, and the same request must inject the tie-break that settles it; a door +// that passed nothing would skip the pass and leave the place undecided. +func TestPoolCorrection_KnockoutStatusDrawInjectsTieBreak(t *testing.T) { + r, store, _, _, _ := setupTestRouter(t) + compID := "rq-ko-tie" + seedMixedCompWithSeatedKnockout(t, store, compID, state.MatchStatusScheduled) + + w := sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/matches/Pool A-0/score", map[string]any{ + "sideA": "A1", "sideB": "A2", "winner": "", "decision": "hikiwake", + "status": "completed", "correctionReason": "it was a draw", + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + var tieBreaks []string + for _, m := range matches { + if strings.Contains(m.ID, "-TB-") { + tieBreaks = append(tieBreaks, m.ID) + } + } + assert.NotEmpty(t, tieBreaks, "the correcting write itself injects the tie-break for the tied place") + b, err := store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, "Pool A-1st", b.Rounds[0][0].SideA, "the tied place waits on its label") +} + +// PUT .../override-rank (the chusen door) moves a pool's order without a match +// write, and answers for the knockout exactly as a pool correction does: the +// confirmable 409 naming who moves, the refused override taken back; the +// terminal 409 for a match being fought; and, confirmed, the fought match +// reopened, announced and repainted, with the receipt naming it. Before, it +// saved the override with no answer, and the next resolver pass seated A1 in +// the played match and in A1's new place at once. +// +// It runs in both statuses a mixed competition's pool order is open in: the +// pools stage, and the knockout stage, where a pool correction can reopen a +// tie only a chusen settles and a wrong chusen must stay fixable. The door +// used to refuse the knockout stage with a 409, whatever the order did. +func TestPoolRankOverride_AnswersForTheKnockout(t *testing.T) { + for _, compStatus := range []state.CompetitionStatus{state.CompStatusPools, state.CompStatusKnockout} { + t.Run(string(compStatus), func(t *testing.T) { + testPoolRankOverrideAnswersForTheKnockout(t, compStatus) + }) + } +} + +func testPoolRankOverrideAnswersForTheKnockout(t *testing.T, compStatus state.CompetitionStatus) { + seed := func(t *testing.T, store *state.Store, compID string, koStatus state.MatchStatus) { + seedMixedCompWithSeatedKnockout(t, store, compID, koStatus) + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + comp.Status = compStatus + require.NoError(t, store.SaveCompetition(comp)) + } + send := func(t *testing.T, r *gin.Engine, compID string, force bool) *httptest.ResponseRecorder { + return sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/pools/Pool A/override-rank", map[string]any{ + "playerId": "a2-id", "rank": 1, "forceDownstreamReopen": force, + }) + } + noOverride := func(t *testing.T, store *state.Store, compID string) { + o, err := store.LoadOverrides(compID) + require.NoError(t, err) + assert.Empty(t, o.PoolRanks["Pool A"], "the refused override is taken back") + } + + t.Run("played", func(t *testing.T) { + r, store, _, hub, _ := setupTestRouter(t) + compID := "rq-rank-played" + seed(t, store, compID, state.MatchStatusCompleted) + + w := send(t, r, compID, false) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + var refusal struct { + Error string `json:"error"` + MatchID string `json:"matchId"` + BlockingMatches []struct { + ID string `json:"id"` + } `json:"blockingMatches"` + QualifierChange []struct { + Place string + From struct{ Name string } + To struct{ Name string } + } `json:"qualifierChange"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + assert.Equal(t, "downstream_knockout_played", refusal.Error) + assert.Empty(t, refusal.MatchID, "no match is being corrected") + require.Len(t, refusal.BlockingMatches, 1) + assert.Equal(t, "m-r1-0", refusal.BlockingMatches[0].ID) + require.Len(t, refusal.QualifierChange, 1) + assert.Equal(t, "A1", refusal.QualifierChange[0].From.Name) + assert.Equal(t, "A2", refusal.QualifierChange[0].To.Name) + noOverride(t, store, compID) + + stop := collectEvents(t, hub) + w = send(t, r, compID, true) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + events := stop() + var receipt struct { + ReopenedMatches []struct { + ID string `json:"id"` + Number int `json:"number"` + Label string `json:"label"` + } `json:"reopenedMatches"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &receipt)) + require.Len(t, receipt.ReopenedMatches, 1) + assert.Equal(t, "m-r1-0", receipt.ReopenedMatches[0].ID) + assert.Equal(t, 1, receipt.ReopenedMatches[0].Number) + assert.Equal(t, "Match 1 (Final)", receipt.ReopenedMatches[0].Label) + announced := false + for _, e := range events { + if strings.Contains(e, `"matchId":"m-r1-0"`) && strings.Contains(e, string(EventMatchUpdated)) { + announced = true + } + } + assert.True(t, announced, "the reopened knockout match gets its own match_updated: %v", events) + b, err := store.LoadBracket(compID) + require.NoError(t, err) + ko := b.Rounds[0][0] + assert.Equal(t, state.MatchStatusScheduled, ko.Status) + assert.Empty(t, ko.Winner) + assert.Equal(t, "A2", ko.SideA) + assert.Equal(t, "a2-id", ko.SideAID) + }) + + t.Run("running", func(t *testing.T) { + for _, force := range []bool{false, true} { + r, store, _, _, _ := setupTestRouter(t) + compID := fmt.Sprintf("rq-rank-running-%v", force) + seed(t, store, compID, state.MatchStatusRunning) + w := send(t, r, compID, force) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), `"error":"downstream_knockout_running"`, "force=%v", force) + noOverride(t, store, compID) + } + }) +} + +// A pool correction made after a mixed competition's knockout has started can +// leave Pool A's 1st place in a daihyosen cycle that only a chusen settles, +// with the knockout match that place feeds waiting on its label. The chusen +// doors must work in knockout status: GET .../chusen-candidates offers the +// tie, PUT .../override-rank accepts each position, and the request that +// records the last one seats the slot. Before, the candidates came back empty +// and the override was refused with a 409, so that match could never be played. +func TestChusen_KnockoutStatus_AcceptedAndSeatsTheSlot(t *testing.T) { + r, store, eng, _, _ := setupTestRouter(t) + compID := "chusen-ko-door" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Format: state.CompFormatMixed, Status: state.CompStatusKnockout, + Kind: "team", TeamSize: 2, Courts: []string{"A"}, PoolWinners: 1, HasParticipantIDs: true, + })) + team := func(name string) domain.Player { + return domain.Player{ID: strings.ToLower(name) + "-id", Name: name, Dojo: "Dojo " + name} + } + alpha, beta, gamma, delta, epsilon := team("Alpha"), team("Beta"), team("Gamma"), team("Delta"), team("Epsilon") + require.NoError(t, store.SavePools(compID, []helper.Pool{ + {PoolName: "Pool A", Players: []helper.Player{alpha, beta, gamma}}, + {PoolName: "Pool B", Players: []helper.Player{delta, epsilon}}, + })) + require.NoError(t, store.SaveParticipants(compID, []domain.Player{alpha, beta, gamma, delta, epsilon})) + drawn := func(id string, a, b domain.Player) state.MatchResult { + return state.MatchResult{ID: id, SideA: a.Name, SideB: b.Name, SideAID: a.ID, SideBID: b.ID, Court: "A", + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake)} + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + drawn("Pool A-0", alpha, beta), drawn("Pool A-1", alpha, gamma), drawn("Pool A-2", beta, gamma), + {ID: "Pool B-0", SideA: "Delta", SideB: "Epsilon", SideAID: delta.ID, SideBID: epsilon.ID, Court: "A", + Winner: "Delta", WinnerID: delta.ID, Status: state.MatchStatusCompleted}, + })) + _, err := eng.InjectPoolDaihyosenMatches(compID) + require.NoError(t, err) + all, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + beats := map[string]string{"Alpha": "Beta", "Beta": "Gamma", "Gamma": "Alpha"} + dh := 0 + for i := range all { + if !strings.Contains(all[i].ID, "-DH-") { + continue + } + dh++ + all[i].Status = state.MatchStatusCompleted + if beats[all[i].SideA] == all[i].SideB { + all[i].Winner, all[i].WinnerID = all[i].SideA, all[i].SideAID + } else { + all[i].Winner, all[i].WinnerID = all[i].SideB, all[i].SideBID + } + } + require.Equal(t, 3, dh) + require.NoError(t, store.SavePoolMatches(compID, all)) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{ + ID: "m-r1-0", MatchNumber: 1, DisplayRound: 1, Court: "A", + PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-1st", + SideA: "Pool A-1st", SideB: "Delta", SideBID: delta.ID, + Status: state.MatchStatusScheduled, + }}}})) + + candidates := func() int { + t.Helper() + w := sendJSON(t, r, http.MethodGet, "/api/competitions/"+compID+"/chusen-candidates", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body struct { + Candidates []struct { + PoolName string `json:"poolName"` + } `json:"candidates"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return len(body.Candidates) + } + require.Equal(t, 1, candidates(), "the tie is offered in knockout status") + + for rank, p := range []domain.Player{alpha, beta, gamma} { + w := sendJSON(t, r, http.MethodPut, "/api/competitions/"+compID+"/pools/Pool A/override-rank", map[string]any{ + "playerId": p.ID, "rank": rank + 1, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + ko := b.Rounds[0][0] + assert.Equal(t, "Alpha", ko.SideA, "the chusen seats the slot the knockout match waited on") + assert.Equal(t, alpha.ID, ko.SideAID) + assert.Equal(t, 0, candidates(), "the recorded chusen clears the candidate") +} diff --git a/internal/mobileapp/reason_human_barred_test.go b/internal/mobileapp/reason_human_barred_test.go new file mode 100644 index 000000000..e5a0d5454 --- /dev/null +++ b/internal/mobileapp/reason_human_barred_test.go @@ -0,0 +1,333 @@ +// Package mobileapp, reason_human_barred_test.go pins the bc-rawm/bc-cse +// operator sentence a 409 ineligible_competitor / already_ineligible refusal +// carries: it names the barred competitor, the match they were barred IN +// (its operator label), and the remedy -- record the default win for the +// opponent, or reinstate for a kiken-injury. +package mobileapp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// barredCompetitorFixture builds a mixed competition with three participants +// (Alice, Bob, Carol) and two SCHEDULED pool matches: "Pool A-0" (Alice vs +// Bob) and "Pool A-1" (Alice vs Carol, or Carol vs Alice when aliceIsSideA is +// false -- used to exercise both the SideA and SideB attribution arms). +// Returns the wired gin router and the three participant ids. +func barredCompetitorFixture(t *testing.T, compID string, aliceIsSideA bool) (*gin.Engine, string, string, string) { + t.Helper() + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools, + })) + aliceID, bobID, carolID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + })) + + second := state.MatchResult{ + ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, + Status: state.MatchStatusScheduled, + } + if !aliceIsSideA { + second = state.MatchResult{ + ID: "Pool A-1", SideA: "Carol", SideB: "Alice", SideAID: carolID, SideBID: aliceID, + Status: state.MatchStatusScheduled, + } + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, + second, + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + return r, aliceID, bobID, carolID +} + +func postDecisionJSON(t *testing.T, r *gin.Engine, compID, mid string, req DecisionRequest) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(req) + require.NoError(t, err) + w := httptest.NewRecorder() + httpReq, err := http.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/matches/"+mid+"/decision", bytes.NewBuffer(body)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, httpReq) + return w +} + +func putScoreJSON(t *testing.T, r *gin.Engine, compID, mid string, mr state.MatchResult) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(mr) + require.NoError(t, err) + w := httptest.NewRecorder() + httpReq, err := http.NewRequest(http.MethodPut, "/api/competitions/"+compID+"/matches/"+mid+"/score", bytes.NewBuffer(body)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, httpReq) + return w +} + +// TestScoreHandler_IneligibleCompetitor_ReasonHumanNamesMatchAndRemedy +// covers /score's 409 ineligible_competitor for each decision that can bar a +// competitor: Alice withdraws from "Pool A-0" (against Bob), then starting +// "Pool A-1" (Alice vs Carol) is refused, and the refusal must name Alice, +// label "Pool A-0" in operator terms, and give the remedy. +func TestScoreHandler_IneligibleCompetitor_ReasonHumanNamesMatchAndRemedy(t *testing.T) { + cases := []struct { + name string + decision string + want string + }{ + { + "kiken-voluntary", "kiken-voluntary", + "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the default win for Carol.", + }, + { + "kiken-injury", "kiken-injury", + "Alice withdrew injured in Pool A · Match 1. Reinstate Alice if the doctor allows, or record the default win for Carol.", + }, + { + "fusenpai", "fusenpai", + "Alice did not appear for Pool A · Match 1 and cannot fight again. Record the default win for Carol.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + compID := "ineligible-reason-" + tc.name + r, aliceID, _, _ := barredCompetitorFixture(t, compID, true) + + // Alice (aka/SideA) is barred from "Pool A-0" by this decision. + decisionResp := postDecisionJSON(t, r, compID, "Pool A-0", DecisionRequest{ + Decision: tc.decision, DecisionBy: "aka", DecisionReason: "test", + }) + require.Equal(t, http.StatusOK, decisionResp.Code, decisionResp.Body.String()) + + // Starting "Pool A-1" (Alice vs Carol) must be refused: Alice is + // barred by a DIFFERENT match. + startResp := putScoreJSON(t, r, compID, "Pool A-1", state.MatchResult{ + ID: "Pool A-1", Status: state.MatchStatusRunning, + }) + require.Equal(t, http.StatusConflict, startResp.Code, startResp.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(startResp.Body.Bytes(), &body)) + assert.Equal(t, "ineligible_competitor", body["error"]) + assert.Equal(t, aliceID, body["playerId"]) + assert.Equal(t, tc.want, body["reasonHuman"]) + // The wire's raw `reason`/`error` keys are unchanged (task contract). + assert.Contains(t, body["reason"], tc.decision+" at Pool A-0") + }) + } +} + +// TestDecisionHandler_AlreadyIneligible_ReasonHumanNamesMatchAndRemedy +// covers /decision's 409 already_ineligible (T105/CHK047 concurrent +// withdrawal guard): Alice withdraws from "Pool A-0" first, then a SECOND +// decision attempt naming Alice as the loser of "Pool A-1" (Carol vs Alice, +// Alice on SideB this time) is refused because she is already barred by +// "Pool A-0". +func TestDecisionHandler_AlreadyIneligible_ReasonHumanNamesMatchAndRemedy(t *testing.T) { + compID := "already-ineligible-reason" + r, aliceID, _, _ := barredCompetitorFixture(t, compID, false) // "Pool A-1" = Carol vs Alice + + first := postDecisionJSON(t, r, compID, "Pool A-0", DecisionRequest{ + Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test", + }) + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + + // Alice is SideB of "Pool A-1" (Carol vs Alice); decisionBy "shiro" names + // her as the (attempted) loser again. + second := postDecisionJSON(t, r, compID, "Pool A-1", DecisionRequest{ + Decision: "kiken-voluntary", DecisionBy: "shiro", DecisionReason: "test", + }) + require.Equal(t, http.StatusConflict, second.Code, second.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &body)) + assert.Equal(t, "already_ineligible", body["error"]) + assert.Equal(t, aliceID, body["playerId"]) + assert.Equal(t, "Pool A-0", body["matchId"]) + assert.Equal(t, + "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the default win for Carol.", + body["reasonHuman"]) +} + +// TestScoreHandler_SimultaneityReason_IsAlreadyAnOperatorSentence pins that +// the simultaneity-gate refusal (a DIFFERENT IneligibleCompetitorError +// producer, MatchID=="") reaches the wire as reasonHuman VERBATIM: it is +// already a complete operator sentence (checkSimultaneousMatchTx), so the +// handler must not run it through the barred-competitor builder (which +// would only degrade to the domain.ResolveReasonHuman fallback, since this +// Reason is not the " at " shape at all). +func TestScoreHandler_SimultaneityReason_IsAlreadyAnOperatorSentence(t *testing.T) { + compID := "simultaneity-reason" + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools, + })) + aliceID, bobID, carolID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusRunning, Court: "A"}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + + resp := putScoreJSON(t, r, compID, "Pool A-1", state.MatchResult{ID: "Pool A-1", Status: state.MatchStatusRunning}) + require.Equal(t, http.StatusConflict, resp.Code, resp.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, "ineligible_competitor", body["error"]) + assert.Equal(t, "Alice is fighting now in Pool A · Match 1 on Shiaijo A. Finish that match first.", body["reasonHuman"]) + assert.Equal(t, body["reason"], body["reasonHuman"], "the simultaneity sentence IS the reason, verbatim") +} + +// TestScoreHandler_DirectlySetIneligibility_TranslatesRatherThanLeaksRawReason +// covers bc-cse item 3: a competitor-status entry set directly via +// POST /competitor-status (unlike every OTHER producer in this file) carries +// no MatchID at all -- domain.CompetitorStatus.Validate never requires one. +// Before engine.IneligibleCompetitorError.Simultaneous existed, that empty +// MatchID was indistinguishable from the simultaneity gate's own sentinel +// (also MatchID==""), so the handler skipped reasonHumanForBarredCompetitor +// entirely and echoed the raw, untranslated Reason onto the wire as +// reasonHuman. The barred-status producer must still be recognised as such +// and get a translated sentence. +func TestScoreHandler_DirectlySetIneligibility_TranslatesRatherThanLeaksRawReason(t *testing.T) { + compID := "direct-ineligible-no-match" + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools, + })) + aliceID, bobID := helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, + })) + + // Set directly, the way POST /competitor-status would: no MatchID, and + // a bare decision keyword as Reason -- the engine's own writers always + // append " at " (see reasonHumanPatterns), so a directly-set + // status naturally lacks that suffix too. + require.NoError(t, store.SetCompetitorStatus(compID, domain.CompetitorStatus{ + PlayerID: aliceID, Eligible: false, Reason: "kiken-voluntary", + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + + resp := putScoreJSON(t, r, compID, "Pool A-0", state.MatchResult{ID: "Pool A-0", Status: state.MatchStatusRunning}) + require.Equal(t, http.StatusConflict, resp.Code, resp.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, "ineligible_competitor", body["error"]) + assert.Equal(t, aliceID, body["playerId"]) + assert.Equal(t, "kiken-voluntary", body["reason"], "the raw reason is unchanged on the wire") + assert.Equal(t, "voluntary withdrawal", body["reasonHuman"], + "a status set with no matchId must still be translated, not echoed raw") +} + +// TestBulkScoreHandler_AlreadyIneligible_ReasonHumanNamesMatchAndRemedy +// covers bc-cse item 5: bulk-score's partial-success shape has no dedicated +// 409 to answer with, so an ineligibility refusal rides in the per-entry +// scoreError.Error string. Before the fix that string was err.Error()'s raw +// `competitor "" already ineligible (match Pool A-0)`, naming an +// internal id and no remedy; it must instead read the same operator +// sentence the single-match endpoints give. +func TestBulkScoreHandler_AlreadyIneligible_ReasonHumanNamesMatchAndRemedy(t *testing.T) { + compID := "bulk-already-ineligible" + r, aliceID, _, carolID := barredCompetitorFixture(t, compID, true) + + // Alice (aka/SideA of "Pool A-0") withdraws first, via the ordinary + // decision path. + decisionResp := postDecisionJSON(t, r, compID, "Pool A-0", DecisionRequest{ + Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test", + }) + require.Equal(t, http.StatusOK, decisionResp.Code, decisionResp.Body.String()) + + // A bulk-score entry now tries to ALSO record Alice as the loser of + // "Pool A-1" (Alice vs Carol) by a kiken-voluntary decision embedded in + // the plain MatchResult write -- bulk-score has no /decision shape, so + // this is how a withdrawal reaches it. Alice is already barred by a + // DIFFERENT match, so this must be refused per-entry. + body, err := json.Marshal([]state.MatchResult{ + { + ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, + Winner: "Carol", WinnerID: carolID, WinnerSide: "B", + Decision: "kiken-voluntary", Status: state.MatchStatusCompleted, + }, + }) + require.NoError(t, err) + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/matches/bulk-score", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var resp struct { + Succeeded int `json:"succeeded"` + Errors []struct { + MatchID string `json:"matchId"` + Error string `json:"error"` + Reason string `json:"reason,omitempty"` + } `json:"errors"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, 0, resp.Succeeded) + require.Len(t, resp.Errors, 1) + assert.Equal(t, "Pool A-1", resp.Errors[0].MatchID) + assert.Equal(t, + "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the default win for Carol.", + resp.Errors[0].Error) + assert.NotContains(t, resp.Errors[0].Error, aliceID, "the raw participant id must not leak into the operator-facing error") +} diff --git a/internal/mobileapp/reopen_downstream_labels_test.go b/internal/mobileapp/reopen_downstream_labels_test.go new file mode 100644 index 000000000..15729804c --- /dev/null +++ b/internal/mobileapp/reopen_downstream_labels_test.go @@ -0,0 +1,186 @@ +// Package mobileapp, reopen_downstream_labels_test.go pins bc-cse's +// operator-facing wire shapes for the reopen family: a downstream match +// being fought answers 409 downstream_knockout_running (never the bare +// ErrReopenDownstreamFought text), and a busy-court refusal names the +// blocking match by its operator label rather than its internal id. +package mobileapp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReopenHandler_DownstreamRunning_RespondsWithLabel reopens a semifinal +// whose winner already fed a RUNNING final: the refusal must be 409 +// downstream_knockout_running (engine.DownstreamKnockoutRunningError), with +// the final named by its operator label in the message, not the raw +// ErrReopenDownstreamFought sentinel text. +func TestReopenHandler_DownstreamRunning_RespondsWithLabel(t *testing.T) { + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + compID := "reopen-running-downstream" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Kind: "individual", Status: state.CompStatusKnockout, + })) + aliceID, bobID, carolID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Alice", SideAID: aliceID, SideB: "Bob", SideBID: bobID, + Status: state.MatchStatusRunning, DisplayRound: 2, MatchNumber: 1}}, + {{ID: "m-r2-0", SideB: "Carol", SideBID: carolID, DisplayRound: 1, MatchNumber: 2}}, + }})) + _, _, err = eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + require.NoError(t, err) + require.NoError(t, store.UpdateBracket(compID, func(b *state.Bracket) error { + b.Rounds[1][0].Status = state.MatchStatusRunning + b.Rounds[1][0].IpponsA = []string{"M"} + return nil + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + + body, _ := json.Marshal(map[string]any{"reason": "Withdrawal recorded by mistake"}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/matches/m-r1-0/reopen", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "downstream_knockout_running", resp["error"]) + assert.Equal(t, "m-r1-0", resp["matchId"]) + msg, _ := resp["message"].(string) + assert.Contains(t, msg, "Match 2 (Final)", "the running downstream is named by its operator label") + assert.NotContains(t, msg, "cannot reopen: a downstream knockout match has already started", + "the bare ErrReopenDownstreamFought text must not reach the operator") + // bc-cse item 7: the reopen door's own remedy, never the save-path's + // "then save again" -- a reopen has no save step to retry. + assert.Equal(t, + "Match 2 (Final) is being fought now. Finish it or send it back to the queue, then reopen this match again.", + msg) + assert.NotContains(t, msg, "then save again", + "a reopen refusal must never tell the operator to save again") + running, ok := resp["runningMatches"].([]any) + require.True(t, ok && len(running) == 1) + first := running[0].(map[string]any) + assert.Equal(t, "m-r2-0", first["id"]) + assert.Equal(t, "Match 2 (Final)", first["label"]) +} + +// TestScoreHandler_CourtBusy_BodyCarriesLabelAndCourtNoRawID pins the +// court_busy wire shape (bc-cse): `label` and `court` are present, and the +// prose `message` never names the blocking match's internal id, only its +// operator label. +func TestScoreHandler_CourtBusy_BodyCarriesLabelAndCourtNoRawID(t *testing.T) { + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + compID := "court-busy-label" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools, + })) + aliceID, bobID, carolID, davidID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + {ID: davidID, Name: "David", Dojo: "D"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, + Status: state.MatchStatusRunning, Court: "A"}, + {ID: "Pool A-1", SideA: "Carol", SideB: "David", SideAID: carolID, SideBID: davidID, + Status: state.MatchStatusScheduled, Court: "A"}, + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + + resp := putScoreJSON(t, r, compID, "Pool A-1", state.MatchResult{ID: "Pool A-1", Status: state.MatchStatusRunning}) + require.Equal(t, http.StatusConflict, resp.Code, resp.Body.String()) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, "court_busy", body["error"]) + assert.Equal(t, "A", body["court"]) + assert.Equal(t, "Pool A-0", body["matchId"], "the id stays in the body for the client's own use") + assert.Equal(t, "Pool A · Match 1", body["label"]) + msg, _ := body["message"].(string) + assert.Contains(t, msg, "Pool A · Match 1") + assert.False(t, strings.Contains(msg, "Pool A-0"), "the message must not name the internal match id") +} + +// TestReopenHandler_ThirdPlaceMatchNotCompleted_MessageIsSentenceCased +// covers bc-cse item 6: engine.MatchLabel names the 3rd-place match +// deliberately lowercase mid-sentence ("the 3rd-place match"), which reads +// wrong the moment it opens a sentence ("%s is not completed yet..."). The +// reopen handler's ErrReopenNotCompleted refusal must sentence-case it. +func TestReopenHandler_ThirdPlaceMatchNotCompleted_MessageIsSentenceCased(t *testing.T) { + tempDir := t.TempDir() + store, err := state.NewStore(tempDir) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + + compID := "reopen-bronze-not-completed" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Kind: "individual", Status: state.CompStatusKnockout, + })) + aliceID, bobID := helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{ + ThirdPlaceMatch: &state.BracketMatch{ + ID: state.BronzeMatchID, SideA: "Alice", SideAID: aliceID, SideB: "Bob", SideBID: bobID, + Status: state.MatchStatusScheduled, + }, + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + + body, _ := json.Marshal(map[string]any{}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/matches/"+state.BronzeMatchID+"/reopen", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + msg, _ := resp["error"].(string) + assert.Equal(t, "The 3rd-place match is not completed yet, so there is nothing to reopen.", msg) +} diff --git a/internal/mobileapp/reopen_withdrawal_handler_test.go b/internal/mobileapp/reopen_withdrawal_handler_test.go new file mode 100644 index 000000000..f116b9947 --- /dev/null +++ b/internal/mobileapp/reopen_withdrawal_handler_test.go @@ -0,0 +1,421 @@ +package mobileapp + +// Clear withdrawal and reopen, at the HTTP boundary (operator ruling +// 2026-09-24: "Everything should be able to be fixed, in case of a wrong +// entry. The operator just needs to be aware of the consequences, if it +// affects downstream matches."). POST .../reopen accepts any completed match a +// withdrawal decided, individual included; a knockout match whose later round +// has its own result answers the shared downstream_knockout_played 409, and a +// retry with forceDownstreamReopen reopens both, names what it reopened and +// broadcasts every change. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + rwRyuID = "11111111-1111-4111-8111-111111111111" + rwToraID = "22222222-2222-4222-8222-222222222222" + rwKumaID = "33333333-3333-4333-8333-333333333333" +) + +// setupIndividualKnockoutWithdrawal: an individual knockout where Tora +// (shiro) did not appear against Ryu in round 1, so Ryu went through and has +// since WON the final against Kuma. +func setupIndividualKnockoutWithdrawal(t *testing.T) (*gin.Engine, *state.Store, *Hub) { + t.Helper() + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + const compID = "rw" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "rw", Kind: "individual", Status: state.CompStatusKnockout})) + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: rwRyuID, Name: "Ryu", Dojo: "DojoR"}, + {ID: rwToraID, Name: "Tora", Dojo: "DojoT"}, + {ID: rwKumaID, Name: "Kuma", Dojo: "DojoK"}, + })) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Ryu", SideAID: rwRyuID, SideB: "Tora", SideBID: rwToraID, Status: state.MatchStatusRunning, MatchNumber: 1, DisplayRound: 2}}, + {{ID: "m-r2-0", SideB: "Kuma", SideBID: rwKumaID, MatchNumber: 2, DisplayRound: 1}}, + }})) + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + + w := postDecision(t, r, compID, "m-r1-0", map[string]any{"decision": "fusenpai", "decisionBy": "shiro"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.NoError(t, store.UpdateBracket(compID, func(b *state.Bracket) error { + f := &b.Rounds[1][0] + require.Equal(t, "Ryu", f.SideA, "precondition: Ryu went through") + f.Status, f.Winner, f.WinnerID, f.IpponsA = state.MatchStatusCompleted, "Ryu", rwRyuID, []string{"M", "K"} + return nil + })) + return r, store, hub +} + +func collectEvents(t *testing.T, hub *Hub) func() []string { + t.Helper() + var mu sync.Mutex + var events []string + ch := hub.Subscribe() + require.NotNil(t, ch) + done := make(chan struct{}) + go func() { + defer close(done) + for e := range ch { + mu.Lock() + events = append(events, e.payload) + mu.Unlock() + } + }() + return func() []string { + hub.Unsubscribe(ch) + <-done + mu.Lock() + defer mu.Unlock() + return append([]string(nil), events...) + } +} + +func TestReopenHandler_WithdrawalWithAPlayedFinalWarnsThenProceeds(t *testing.T) { + r, store, hub := setupIndividualKnockoutWithdrawal(t) + + w := postReopen(t, r, "rw", "m-r1-0", "Withdrawal recorded by mistake") + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + var refusal map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &refusal)) + assert.Equal(t, "downstream_knockout_played", refusal["error"]) + assert.Equal(t, "m-r2-0", refusal["blockingMatchId"]) + assert.Equal(t, "Ryu", refusal["displaced"]) + b, err := store.LoadBracket("rw") + require.NoError(t, err) + assert.Equal(t, state.MatchStatusCompleted, b.Rounds[0][0].Status, "nothing changes until the operator confirms") + assert.Equal(t, state.MatchStatusCompleted, b.Rounds[1][0].Status) + + stop := collectEvents(t, hub) + body, err := json.Marshal(map[string]any{"reason": "Withdrawal recorded by mistake", "forceDownstreamReopen": true}) + require.NoError(t, err) + w = postReopenRaw(t, r, "rw", "m-r1-0", body) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + events := stop() + + var resp struct { + ReopenedMatches []struct { + ID string `json:"id"` + Number int `json:"number"` + Label string `json:"label"` + } `json:"reopenedMatches"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.ReopenedMatches, 1) + assert.Equal(t, "m-r2-0", resp.ReopenedMatches[0].ID) + assert.Equal(t, 2, resp.ReopenedMatches[0].Number) + assert.Equal(t, "Match 2 (Final)", resp.ReopenedMatches[0].Label, "the words the reopened notice shows") + + b, err = store.LoadBracket("rw") + require.NoError(t, err) + r1, final := b.Rounds[0][0], b.Rounds[1][0] + assert.Equal(t, state.MatchStatusRunning, r1.Status) + assert.Equal(t, "", r1.Decision) + assert.Empty(t, r1.IpponsA, "Ryu's maru went with the verdict") + assert.Equal(t, state.MatchStatusScheduled, final.Status) + assert.Empty(t, final.Winner) + assert.Equal(t, "Winner of r2-m0", final.SideA) + statuses, err := store.LoadCompetitorStatus("rw") + require.NoError(t, err) + assert.True(t, statuses[rwToraID].Eligible, "Tora can compete again") + + joined := strings.Join(events, "\n") + assert.Contains(t, joined, `"matchId":"m-r1-0"`) + assert.Contains(t, joined, `"matchId":"m-r2-0"`, "the reopened final is broadcast too") + assert.Contains(t, joined, `"type":"competitor_status_updated"`) + assert.Contains(t, joined, `"playerId":"`+rwToraID+`"`) +} + +// An individual pool match a withdrawal decided reopens with the struck +// letters kept; a fought individual match is still refused with 400. +func TestReopenHandler_IndividualPoolWithdrawal(t *testing.T) { + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + const compID = "rwp" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "rwp", Kind: "individual", Format: state.CompFormatLeague, Status: state.CompStatusPools})) + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: rwRyuID, Name: "Ryu", Dojo: "DojoR"}, + {ID: rwToraID, Name: "Tora", Dojo: "DojoT"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Ryu", SideAID: rwRyuID, SideB: "Tora", SideBID: rwToraID, IpponsA: []string{"M"}, Status: state.MatchStatusRunning}, + {ID: "Pool A-1", SideA: "Tora", SideAID: rwToraID, SideB: "Ryu", SideBID: rwRyuID, Status: state.MatchStatusCompleted, + Winner: "Tora", WinnerID: rwToraID, IpponsA: []string{"M", "M"}}, + })) + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + w := postDecision(t, r, compID, "Pool A-0", map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + w = postReopen(t, r, compID, "Pool A-0", "Withdrawal recorded by mistake") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusRunning, ms[0].Status) + assert.Equal(t, []string{"M"}, ms[0].IpponsA, "what Ryu struck before the withdrawal is kept") + assert.Empty(t, ms[0].IpponsB) + + w = postReopen(t, r, compID, "Pool A-1", "Scoring error") + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "correct other results via the score editor") + // bc-cse item 14: no internal field-name jargon in an operator-facing + // refusal -- "correctionReason" names the API field, not anything the + // operator sees on the score editor's own correction-reason box. + assert.NotContains(t, w.Body.String(), "correctionReason") +} + +// A withdrawal cleared with no reason is recorded again without one: a match +// can be reopened without any reason, and nothing is gated on that (operator +// ruling 2026-09-25). POST /decision reads ReopenPending for every format, so +// the flag is cleared on the completed match rather than left set. +func TestDecisionHandler_ReopenedWithdrawalNeedsNoReason(t *testing.T) { + r, store, _ := setupIndividualKnockoutWithdrawal(t) + require.NoError(t, store.UpdateBracket("rw", func(b *state.Bracket) error { + f := &b.Rounds[1][0] + f.Status, f.Winner, f.WinnerID, f.IpponsA = state.MatchStatusScheduled, "", "", nil + return nil + })) + w := postReopenRaw(t, r, "rw", "m-r1-0", []byte(`{}`)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + b, err := store.LoadBracket("rw") + require.NoError(t, err) + require.True(t, b.Rounds[0][0].ReopenPending) + + w = postDecision(t, r, "rw", "m-r1-0", map[string]any{"decision": "fusenpai", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + b, err = store.LoadBracket("rw") + require.NoError(t, err) + assert.Equal(t, state.MatchStatusCompleted, b.Rounds[0][0].Status) + assert.False(t, b.Rounds[0][0].ReopenPending, "ending the match clears the flag") +} + +// withdrawalServer wires the match and decision handlers over a fresh store +// seeded by seed, for the tests below that drive a whole reopen through HTTP. +func withdrawalServer(t *testing.T, comp *state.Competition, seed func(store *state.Store)) (*gin.Engine, *state.Store, *Hub) { + t.Helper() + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveCompetition(comp)) + require.NoError(t, store.SaveParticipants(comp.ID, []domain.Player{ + {ID: rwRyuID, Name: "Ryu", Dojo: "DojoR"}, + {ID: rwToraID, Name: "Tora", Dojo: "DojoT"}, + {ID: rwKumaID, Name: "Kuma", Dojo: "DojoK"}, + })) + seed(store) + hub := NewHub() + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + eng := engine.New(store) + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + return r, store, hub +} + +// TestReopen_OlderQueuedWriteIsRefusedAfterward: a reopen stamps the match +// with the server's now, so a write stamped BEFORE it cannot undo it. The +// case: a Save correction made after the withdrawal was recorded, held in an +// offline queue, replays after "Clear withdrawal and reopen". Without the +// stamp the match kept the decision's time, the older write won +// last-write-wins and completed the running match again. With it the write is +// refused as superseded (200 {"applied": false}, the refusal contract) and the +// match stays running. Every reopen door stamps: the plain reopen on each +// home, the kachinuki reopen, and the court-busy remedy. +func TestReopen_OlderQueuedWriteIsRefusedAfterward(t *testing.T) { + decidedAt := time.Now().Add(-time.Hour).UnixMilli() + queuedAt := decidedAt + 60_000 + + type door struct { + name string + serve func(t *testing.T) (*gin.Engine, *state.Store, string) + matchID string + reopen func(t *testing.T, r *gin.Engine, compID string) *httptest.ResponseRecorder + stale map[string]any + status func(t *testing.T, store *state.Store, compID string) state.MatchStatus + } + individualCorrection := map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "winner": "Tora", + "ipponsA": []string{"M"}, "ipponsB": []string{"○", "○"}, + "status": "completed", "correctionReason": "Scoring error: wrong waza entered", + "modifiedAt": queuedAt, + } + poolStatus := func(t *testing.T, store *state.Store, compID string) state.MatchStatus { + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range ms { + if m.ID == "Pool A-0" || m.ID == "P1-0" { + return m.Status + } + } + t.Fatalf("match not found") + return "" + } + bracketStatus := func(t *testing.T, store *state.Store, compID string) state.MatchStatus { + b, err := store.LoadBracket(compID) + require.NoError(t, err) + return b.Rounds[0][0].Status + } + reopen := func(matchID string) func(t *testing.T, r *gin.Engine, compID string) *httptest.ResponseRecorder { + return func(t *testing.T, r *gin.Engine, compID string) *httptest.ResponseRecorder { + return postReopen(t, r, compID, matchID, "Withdrawal recorded by mistake") + } + } + // individualPool: Ryu (aka) withdraws from Pool A-0 at decidedAt. With + // blocker, the court's next match is then running on court A: Tora v + // Kuma, both eligible (Ryu is the one the kiken barred). + individualPool := func(blocker bool) func(t *testing.T) (*gin.Engine, *state.Store, string) { + return func(t *testing.T) (*gin.Engine, *state.Store, string) { + const compID = "rw-lww-pool" + r, store, _ := withdrawalServer(t, &state.Competition{ID: compID, Name: compID, Kind: "individual", Format: state.CompFormatLeague, Status: state.CompStatusPools}, + func(store *state.Store) { + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Ryu", SideAID: rwRyuID, SideB: "Tora", SideBID: rwToraID, Court: "A", IpponsA: []string{"M"}, Status: state.MatchStatusRunning}, + })) + }) + w := postDecision(t, r, compID, "Pool A-0", map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka", "modifiedAt": decidedAt}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + if blocker { + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.NoError(t, store.SavePoolMatches(compID, append(ms, state.MatchResult{ + ID: "Pool A-1", SideA: "Tora", SideAID: rwToraID, SideB: "Kuma", SideBID: rwKumaID, Court: "A", Status: state.MatchStatusRunning, + }))) + } + return r, store, compID + } + } + doors := []door{ + {name: "reopen, pool match", serve: individualPool(false), matchID: "Pool A-0", + reopen: reopen("Pool A-0"), stale: individualCorrection, status: poolStatus}, + {name: "requeue-blocker-and-reopen", serve: individualPool(true), matchID: "Pool A-0", + reopen: func(t *testing.T, r *gin.Engine, compID string) *httptest.ResponseRecorder { + return postRequeueAndReopen(t, r, compID, "Pool A-0", map[string]any{ + "blockerCompId": compID, "blockerMatchId": "Pool A-1", "reason": "Withdrawal recorded by mistake", + }) + }, + stale: individualCorrection, status: poolStatus}, + {name: "reopen, knockout match", + serve: func(t *testing.T) (*gin.Engine, *state.Store, string) { + const compID = "rw-lww-ko" + r, store, _ := withdrawalServer(t, &state.Competition{ID: compID, Name: compID, Kind: "individual", Status: state.CompStatusKnockout}, + func(store *state.Store) { + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Ryu", SideAID: rwRyuID, SideB: "Tora", SideBID: rwToraID, Court: "A", IpponsA: []string{"M"}, Status: state.MatchStatusRunning, MatchNumber: 1}}, + {{ID: "m-r2-0", SideB: "Kuma", SideBID: rwKumaID, MatchNumber: 2}}, + }})) + }) + w := postDecision(t, r, compID, "m-r1-0", map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka", "modifiedAt": decidedAt}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + return r, store, compID + }, + matchID: "m-r1-0", reopen: reopen("m-r1-0"), stale: individualCorrection, status: bracketStatus}, + {name: "kachinuki reopen", + serve: func(t *testing.T) (*gin.Engine, *state.Store, string) { + const compID = "rw-lww-kachinuki" + r, store := setupKachinukiScoreServer(t, compID) + players, err := store.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range players { + ids[p.Name] = p.ID + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "P1-0", SideA: "Ryu", SideAID: ids["Ryu"], SideB: "Tora", SideBID: ids["Tora"], Court: "A", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{{Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M"}, Winner: "R-1", Decision: "fought"}}, + }})) + w := postDecision(t, r, compID, "P1-0", map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka", "modifiedAt": decidedAt}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + return r, store, compID + }, + matchID: "P1-0", reopen: reopen("P1-0"), + stale: map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "winner": "Ryu", "status": "completed", "decision": "kachinuki-exhaustion", + "subResults": []map[string]any{kachinukiSub(1, "R-1", "W-1", []string{"M", "K"}, "R-1", "fought")}, + "correctionReason": "Scoring error: wrong waza entered", "modifiedAt": queuedAt, + }, + status: poolStatus}, + } + for _, d := range doors { + t.Run(d.name, func(t *testing.T) { + r, store, compID := d.serve(t) + w := d.reopen(t, r, compID) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, state.MatchStatusRunning, d.status(t, store, compID), "precondition: reopened") + + w = putScore(t, r, compID, d.matchID, d.stale) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, false, body["applied"], "a write stamped before the reopen must not land; got %s", w.Body.String()) + assert.Equal(t, "superseded", body["reason"]) + assert.Equal(t, state.MatchStatusRunning, d.status(t, store, compID), "the reopened match stays running") + }) + } +} + +// TestReopenHandler_ForceReopenedFinalWithdrawalIsRestoredAndBroadcast: the +// final a confirmed reopen reopens was itself decided by Kuma's kiken. That +// withdrawal goes with its verdict, so Kuma is eligible again and the restore +// is broadcast beside the final's own match_updated, exactly as for the match +// the operator reopened. +func TestReopenHandler_ForceReopenedFinalWithdrawalIsRestoredAndBroadcast(t *testing.T) { + const compID = "rw-final-kiken" + r, store, hub := withdrawalServer(t, &state.Competition{ID: compID, Name: compID, Kind: "individual", Status: state.CompStatusKnockout}, + func(store *state.Store) { + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Ryu", SideAID: rwRyuID, SideB: "Tora", SideBID: rwToraID, Status: state.MatchStatusRunning, MatchNumber: 1}}, + {{ID: "m-r2-0", SideB: "Kuma", SideBID: rwKumaID, MatchNumber: 2}}, + }})) + }) + w := postDecision(t, r, compID, "m-r1-0", map[string]any{"decision": "fusenpai", "decisionBy": "shiro"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + w = postDecision(t, r, compID, "m-r2-0", map[string]any{"decision": "kiken-voluntary", "decisionBy": "shiro", "decisionReason": "knee"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + require.False(t, statuses[rwKumaID].Eligible, "precondition: the final's kiken barred Kuma") + + stop := collectEvents(t, hub) + body, err := json.Marshal(map[string]any{"reason": "Withdrawal recorded by mistake", "forceDownstreamReopen": true}) + require.NoError(t, err) + w = postReopenRaw(t, r, compID, "m-r1-0", body) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + events := stop() + + statuses, err = store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[rwKumaID].Eligible, "Kuma can compete in the reopened final") + var kumaRestored bool + for _, e := range events { + kumaRestored = kumaRestored || (strings.Contains(e, `"type":"competitor_status_updated"`) && + strings.Contains(e, `"playerId":"`+rwKumaID+`"`) && strings.Contains(e, `"eligible":true`)) + } + assert.True(t, kumaRestored, "Kuma's restore is broadcast; got %v", events) +} diff --git a/internal/mobileapp/team_finish_gate.go b/internal/mobileapp/team_finish_gate.go new file mode 100644 index 000000000..c3b13742f --- /dev/null +++ b/internal/mobileapp/team_finish_gate.go @@ -0,0 +1,160 @@ +package mobileapp + +import ( + "strconv" + "strings" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// unfinishedTeamBouts returns, in bout order, the numbered bouts 1..teamSize +// of a team match that carry no result in subs (state.SubMatchResult. +// HasResult). A bout missing from subs has no result either. The daihyosen +// row (state.DaihyosenSubPosition) is never numbered, so it is never +// returned. Every position counts, whoever the lineups field there: a bout +// neither team fields is recorded as a Tie (operator ruling 2026-09-24). +func unfinishedTeamBouts(subs []state.SubMatchResult, teamSize int) []int { + played := make(map[int]bool, len(subs)) + for i := range subs { + if subs[i].Position >= 1 && subs[i].HasResult() { + played[subs[i].Position] = true + } + } + var out []int + for bout := 1; bout <= teamSize; bout++ { + if !played[bout] { + out = append(out, bout) + } + } + return out +} + +// teamBoutLabel names a numbered bout for the operator: "Bout 3", plus its +// FIK position name for a five-person team ("Bout 5 (Taisho)"). +func teamBoutLabel(teamSize, bout int) string { + label := "Bout " + strconv.Itoa(bout) + if teamSize == 5 { + if pos, ok := domain.PositionForBout(teamSize, bout); ok { + label += " (" + pos.Label() + ")" + } + } + return label +} + +// unfinishedTeamBoutsMessage is the refusal copy for finishing a team match +// while bouts have no result. It names the bouts and what to record, and +// never mentions the lineup: a vacancy is legitimate play and must not read +// as something to complete. The JS twin is unfinishedTeamBoutsMessage in +// web-mobile/js/admin_scoring_team.jsx; both are pinned to +// testdata/unfinished_team_bouts.json. +func unfinishedTeamBoutsMessage(teamSize int, bouts []int) string { + labels := make([]string, len(bouts)) + for i, b := range bouts { + labels[i] = teamBoutLabel(teamSize, b) + } + var list, verb string + switch len(labels) { + case 0: + return "" + case 1: + list, verb = labels[0], "has" + default: + list, verb = strings.Join(labels[:len(labels)-1], ", ")+" and "+labels[len(labels)-1], "have" + } + return list + " " + verb + " no result. Record a score, a Tie, or a Fusensho before finishing." +} + +// refuseUnfinishedTeamFinish is the server half of the team finish gate +// (operator ruling 2026-09-24: every bout of a team match is fought, so there +// are no unfinished team matches). It returns the ValidationError naming the +// bouts when a score write would COMPLETE a team match while a numbered bout +// has no result, and nil when the write may proceed. An error return is a +// competition that could not be read; a competition that does not exist is +// left to the engine write, which reports it. +// +// It is the half that runs BEFORE the write's transaction, because it reads +// the competition through the store's own locks, which would deadlock under +// the per-competition lock the transaction holds. The refusal it +// returns is only a candidate: the caller applies it inside the transaction +// through teamFinishRefusalUnderTx, which exempts the one write that depends +// on the STORED match, so that read and the write it gates happen under one +// lock (a reopen landing in between cannot slip a write past the gate). +// +// Out of scope here, each for a reason: +// - a write that does not complete the match (autosave, Start); +// - a whole-encounter withdrawal (kiken, fusenpai), which is not a fought +// finish and reaches the engine with the bouts fought so far; +// - kachinuki, which ends on the operator's explicit End match; +// - an individual competition, and the pool daihyosen / tiebreaker rows of +// a team competition, which are single bouts with no numbered positions. +// +// Every numbered position is gated, whatever the lineups field there +// (operator ruling 2026-09-24): a vacancy on one side is a fusensho for the +// fighter who is present, and a position neither side fields is a Tie. The +// gate never reads the lineups and never asks for one to be completed. +// Corrections are gated too, except the one teamFinishRefusalUnderTx exempts. +func refuseUnfinishedTeamFinish(store CompetitionStore, compID, matchID string, req *state.MatchResult) (*ValidationError, error) { + if req.Status != state.MatchStatusCompleted || domain.IsWithdrawalDecisionStr(req.Decision) { + return nil, nil + } + if engine.IsPoolDaihyosenMatchID(matchID) || engine.IsTiebreakerMatchID(matchID) { + return nil, nil + } + comp, err := store.LoadCompetition(compID) + if err != nil { + return nil, err + } + return refuseUnfinishedTeamFinishForComp(comp, matchID, req), nil +} + +// refuseUnfinishedTeamFinishForComp is the load-free core of the team finish +// gate: the same check as refuseUnfinishedTeamFinish, for a caller that +// already holds the competition record (bc-cse finding 13). It is the ONE +// gate body /score, bulk-score, and quick-score all call: +// +// - refuseUnfinishedTeamFinish (above) is the thin per-call wrapper /score +// and quick-score use, each loading the competition itself once per +// request. +// - bulk-score loads the competition ONCE before its per-entry loop and +// calls this directly for every entry, rather than re-loading it once +// per entry the way a call through refuseUnfinishedTeamFinish would. +// +// See refuseUnfinishedTeamFinish's own doc comment for what this checks and +// what is deliberately out of scope. +func refuseUnfinishedTeamFinishForComp(comp *state.Competition, matchID string, req *state.MatchResult) *ValidationError { + if req.Status != state.MatchStatusCompleted || domain.IsWithdrawalDecisionStr(req.Decision) { + return nil + } + if engine.IsPoolDaihyosenMatchID(matchID) || engine.IsTiebreakerMatchID(matchID) { + return nil + } + if comp == nil || comp.TeamSize < 2 || comp.IsKachinuki() { + return nil + } + bouts := unfinishedTeamBouts(req.SubResults, comp.TeamSize) + if len(bouts) == 0 { + return nil + } + return &ValidationError{Message: unfinishedTeamBoutsMessage(comp.TeamSize, bouts)} +} + +// teamFinishRefusalUnderTx is the in-transaction half of the team finish gate: +// it returns the refusal refuseUnfinishedTeamFinish computed, unless the write +// keeps a DEFAULT-WIN ruling recorded on the match -- any kiken, fusenpai, OR +// fusensho, not withdrawal-only (engine.KeepsWithdrawalRuling was widened +// from IsWithdrawalDecisionStr to domain.IsDefaultWinDecisionStr, bc-tmfn +// follow-up: see its own doc comment, scoring.go). That write is a +// correction to the bouts of a match a default-win ruling already ended, and +// it keeps the ruling (operator ruling 2026-09-24: "Save correction should +// just save what the operator enters"), so the bouts nobody fought after it +// are not a finish it has to answer for. check is the snapshot +// applyCorrectionReasonUnderTx read under the same lock the write holds, so +// the stored decision this exemption reads is the one the engine will see. +func teamFinishRefusalUnderTx(refusal *ValidationError, check correctionCheck, req *state.MatchResult) *ValidationError { + if refusal == nil || engine.KeepsWithdrawalRuling(check.StoredStatus, check.StoredDecision, req) { + return nil + } + return refusal +} diff --git a/internal/mobileapp/team_finish_gate_test.go b/internal/mobileapp/team_finish_gate_test.go new file mode 100644 index 000000000..1908b60b5 --- /dev/null +++ b/internal/mobileapp/team_finish_gate_test.go @@ -0,0 +1,625 @@ +package mobileapp + +// bc-tmfn: a team match cannot be finished while a numbered bout has no +// result (operator ruling 2026-09-24: every bout of a team match is fought). +// These pin the server half of that gate on PUT .../score: who it refuses, +// what the refusal says, and every write it must leave alone. + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + finishGateMatchID = "Pool A-1" + finishGateTeamA = "Ryu" + finishGateTeamB = "Tora" + finishGateTeamAID = "11111111-1111-4111-8111-111111111111" + finishGateTeamBID = "22222222-2222-4222-8222-222222222222" +) + +// setupTeamFinishServer builds a team competition of teamSize with one pool +// match Ryu v Tora (running, nothing recorded) and the score and decision +// handlers wired against a real store and engine. +func setupTeamFinishServer(t *testing.T, teamSize int, matchType state.TeamMatchType) (*gin.Engine, *state.Store) { + t.Helper() + r, store, _ := setupTeamFinishServerWithHub(t, teamSize, matchType) + return r, store +} + +// setupTeamFinishServerWithHub is setupTeamFinishServer that also hands back +// the hub, for a test that asserts what was broadcast. +func setupTeamFinishServerWithHub(t *testing.T, teamSize int, matchType state.TeamMatchType) (*gin.Engine, *state.Store, *Hub) { + t.Helper() + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng := engine.New(store) + hub := NewHub() + const compID = "tf" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Kind: "team", Format: state.CompFormatMixed, Status: state.CompStatusPools, + TeamSize: teamSize, TeamMatchType: matchType, + })) + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: finishGateTeamAID, Name: finishGateTeamA, Dojo: "DojoR"}, + {ID: finishGateTeamBID, Name: finishGateTeamB, Dojo: "DojoT"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: finishGateMatchID, SideA: finishGateTeamA, SideAID: finishGateTeamAID, + SideB: finishGateTeamB, SideBID: finishGateTeamBID, Status: state.MatchStatusRunning, + }})) + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + return r, store, hub +} + +// saveFinishGateLineup saves a round-0 lineup for a team keyed by its +// participant id, naming a fighter at each listed bout and leaving every +// other position vacant. +func saveFinishGateLineup(t *testing.T, store *state.Store, teamID string, teamSize int, occupied ...int) { + t.Helper() + positions := map[domain.Position]string{} + for _, bout := range occupied { + pos, ok := domain.PositionForBout(teamSize, bout) + require.True(t, ok) + positions[pos] = teamID[:4] + "-fighter-" + string(pos) + } + require.NoError(t, store.SetTeamLineup("tf", domain.TeamLineup{TeamID: teamID, Round: 0, Positions: positions}, teamSize)) +} + +// wonBout is a numbered bout Ryu won by a men. +func wonBout(position int) map[string]any { + return map[string]any{ + "position": position, "sideA": "", "sideB": "", + "ipponsA": []string{"M"}, "ipponsB": []string{}, + "winner": finishGateTeamA, "decision": "", + } +} + +// unfoughtBout is what the editor sends for a bout nobody has touched. +func unfoughtBout(position int) map[string]any { + return map[string]any{ + "position": position, "sideA": "", "sideB": "", + "ipponsA": []string{}, "ipponsB": []string{}, + "winner": "", "decision": "", + } +} + +func finishPayload(subs ...map[string]any) map[string]any { + return map[string]any{ + "status": "completed", "winner": finishGateTeamA, + "sideA": finishGateTeamA, "sideB": finishGateTeamB, + "ipponsA": []string{}, "ipponsB": []string{}, + "subResults": subs, + } +} + +func finishGateStored(t *testing.T, store *state.Store) state.MatchResult { + t.Helper() + ms, err := store.LoadPoolMatches("tf") + require.NoError(t, err) + require.Len(t, ms, 1) + return ms[0] +} + +func TestTeamFinishGate_RefusesAndNamesTheBouts(t *testing.T) { + cases := []struct { + name string + subs []map[string]any + want string + }{ + { + name: "bouts missing from the payload", + subs: []map[string]any{wonBout(1), wonBout(2), wonBout(3)}, + want: "Bout 4 (Fukusho) and Bout 5 (Taisho) have no result. Record a score, a Tie, or a Fusensho before finishing.", + }, + { + name: "an untouched row in the payload", + subs: []map[string]any{wonBout(1), unfoughtBout(2), wonBout(3), wonBout(4), wonBout(5)}, + want: "Bout 2 (Jiho) has no result. Record a score, a Tie, or a Fusensho before finishing.", + }, + { + name: "a daihyosen row does not stand in for a numbered bout", + subs: []map[string]any{wonBout(1), wonBout(2), wonBout(3), wonBout(4), { + "position": state.DaihyosenSubPosition, "sideA": finishGateTeamA, "sideB": finishGateTeamB, + "ipponsA": []string{"M"}, "ipponsB": []string{}, "winner": finishGateTeamA, "decision": "daihyosen", + }}, + want: "Bout 5 (Taisho) has no result. Record a score, a Tie, or a Fusensho before finishing.", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, store := setupTeamFinishServer(t, 5, state.TeamMatchTypeFixed) + w := putScore(t, r, "tf", finishGateMatchID, finishPayload(tc.subs...)) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, tc.want, body["error"]) + assert.NotContains(t, w.Body.String(), "lineup", "the refusal must never send the operator to the lineup") + assert.Equal(t, state.MatchStatusRunning, finishGateStored(t, store).Status, "a refused finish writes nothing") + }) + } +} + +func TestTeamFinishGate_AllowsEveryBoutWithAResult(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + tie := map[string]any{ + "position": 2, "sideA": "", "sideB": "", "ipponsA": []string{}, "ipponsB": []string{}, + "winner": "", "decision": "hikiwake", + } + fusensho := map[string]any{ + "position": 3, "sideA": "", "sideB": "", "ipponsA": domain.DefaultWinIppons(false), "ipponsB": []string{}, + "winner": finishGateTeamA, "decision": "fusensho", + } + w := putScore(t, r, "tf", finishGateMatchID, finishPayload(wonBout(1), tie, fusensho)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, state.MatchStatusCompleted, finishGateStored(t, store).Status) +} + +func TestTeamFinishGate_LeavesOtherWritesAlone(t *testing.T) { + t.Run("a running write with bouts still to fight", func(t *testing.T) { + r, _ := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + p := finishPayload(wonBout(1), unfoughtBout(2), unfoughtBout(3)) + p["status"] = "running" + p["winner"] = "" + w := putScore(t, r, "tf", finishGateMatchID, p) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + }) + t.Run("kachinuki, which ends on End match", func(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeKachinuki) + sub := wonBout(1) + sub["sideA"], sub["sideB"], sub["winner"] = "R-1", "W-1", "R-1" + p := finishPayload(sub) + p["decision"] = "kachinuki-exhaustion" + w := putScore(t, r, "tf", finishGateMatchID, p) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, state.MatchStatusCompleted, finishGateStored(t, store).Status) + }) + t.Run("a withdrawal decision on a partly fought encounter", func(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + p := finishPayload(wonBout(1)) + p["status"] = "running" + p["winner"] = "" + require.Equal(t, http.StatusOK, putScore(t, r, "tf", finishGateMatchID, p).Code) + w := postDecision(t, r, "tf", finishGateMatchID, map[string]any{"decision": "kiken-voluntary", "decisionBy": "shiro"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, state.MatchStatusCompleted, finishGateStored(t, store).Status) + }) +} + +func TestTeamFinishGate_CorrectionIsNotExempt(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + require.Equal(t, http.StatusOK, putScore(t, r, "tf", finishGateMatchID, finishPayload(wonBout(1), wonBout(2), wonBout(3))).Code) + p := finishPayload(wonBout(1), wonBout(2), unfoughtBout(3)) + p["correctionReason"] = "wrong bout scored" + w := putScore(t, r, "tf", finishGateMatchID, p) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "Bout 3 has no result.") + stored := finishGateStored(t, store) + require.Len(t, stored.SubResults, 3) + assert.Equal(t, finishGateTeamA, stored.SubResults[2].Winner, "the refused correction left the stored bout alone") +} + +// A correction to the bouts of a match a withdrawal ended keeps the ruling +// (operator ruling 2026-09-24: "Save correction should just save what the +// operator enters"), so the bouts nobody fought after it do not refuse the +// save, on either door. The ruling, the winner it names and the eligibility +// record all stay as recorded, although the sheet's own winner is the team +// that withdrew. +func TestTeamFinishGate_CorrectionOverAWithdrawalIsSaved(t *testing.T) { + for _, door := range []string{"score", "bulk-score"} { + t.Run(door, func(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + p := finishPayload(wonBout(1)) + p["status"], p["winner"] = "running", "" + require.Equal(t, http.StatusOK, putScore(t, r, "tf", finishGateMatchID, p).Code) + w := postDecision(t, r, "tf", finishGateMatchID, map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + statusesBefore, err := store.LoadCompetitorStatus("tf") + require.NoError(t, err) + require.False(t, statusesBefore[finishGateTeamAID].Eligible) + + fixed := wonBout(1) + fixed["ipponsA"] = []string{"K"} + c := finishPayload(fixed, unfoughtBout(2), unfoughtBout(3)) + c["correctionReason"] = "Scoring error: wrong waza entered" + if door == "score" { + w = putScore(t, r, "tf", finishGateMatchID, c) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } else { + c["id"] = finishGateMatchID + body, merr := json.Marshal([]map[string]any{c}) + require.NoError(t, merr) + req, rerr := http.NewRequest(http.MethodPost, "/api/competitions/tf/matches/bulk-score", bytes.NewBuffer(body)) + require.NoError(t, rerr) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), `"succeeded":1`) + } + + stored := finishGateStored(t, store) + assert.Equal(t, "kiken-voluntary", stored.Decision) + assert.Equal(t, "aka", stored.DecisionBy) + assert.Equal(t, finishGateTeamB, stored.Winner, "Ryu withdrew; the sheet's own winner does not replace that") + assert.Equal(t, finishGateTeamBID, stored.WinnerID) + require.NotEmpty(t, stored.SubResults) + assert.Equal(t, []string{"K"}, stored.SubResults[0].IpponsA, "the bout edit is saved") + statusesAfter, err := store.LoadCompetitorStatus("tf") + require.NoError(t, err) + assert.Equal(t, statusesBefore, statusesAfter, "the eligibility record is untouched, RecordedAt included") + }) + } +} + +// Clear withdrawal and reopen (operator ruling 2026-09-24: "Everything should +// be able to be fixed, in case of a wrong entry"): a withdrawal recorded by +// mistake is removed by REOPENING the match, which puts it back to running +// with the bout fought before it kept, restores the team the withdrawal +// barred (broadcast as competitor_status_updated), and leaves the rest to the +// normal finish: the gate refuses while a bout has no result, and once every +// bout has one the match finishes as fought, carrying the reopen's reason. +func TestTeamFinishGate_ClearWithdrawalReopensTheMatch(t *testing.T) { + r, store, hub := setupTeamFinishServerWithHub(t, 3, state.TeamMatchTypeFixed) + p := finishPayload(wonBout(1)) + p["status"], p["winner"] = "running", "" + require.Equal(t, http.StatusOK, putScore(t, r, "tf", finishGateMatchID, p).Code) + w := postDecision(t, r, "tf", finishGateMatchID, map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var mu sync.Mutex + var events []string + ch := hub.Subscribe() + require.NotNil(t, ch) + done := make(chan struct{}) + go func() { + defer close(done) + for e := range ch { + mu.Lock() + events = append(events, e.payload) + mu.Unlock() + } + }() + + w = postReopen(t, r, "tf", finishGateMatchID, "Withdrawal recorded by mistake") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + hub.Unsubscribe(ch) + <-done + + stored := finishGateStored(t, store) + assert.Equal(t, state.MatchStatusRunning, stored.Status, "the match was never decided") + assert.Equal(t, "", stored.Decision) + assert.Equal(t, "", stored.Winner) + require.NotEmpty(t, stored.SubResults, "the bout fought before the withdrawal is kept") + assert.Equal(t, []string{"M"}, stored.SubResults[0].IpponsA) + statuses, err := store.LoadCompetitorStatus("tf") + require.NoError(t, err) + assert.True(t, statuses[finishGateTeamAID].Eligible, "the team the withdrawal barred is eligible again") + mu.Lock() + var restored bool + for _, e := range events { + if strings.Contains(e, `"type":"competitor_status_updated"`) && + strings.Contains(e, `"playerId":"`+finishGateTeamAID+`"`) && strings.Contains(e, `"eligible":true`) { + restored = true + } + } + mu.Unlock() + assert.True(t, restored, "the restore is broadcast; got %v", events) + + partial := finishPayload(wonBout(1), unfoughtBout(2), unfoughtBout(3)) + w = putScore(t, r, "tf", finishGateMatchID, partial) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "Bout 2 and Bout 3 have no result.") + + w = putScore(t, r, "tf", finishGateMatchID, finishPayload(wonBout(1), wonBout(2), wonBout(3))) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + stored = finishGateStored(t, store) + assert.Equal(t, state.MatchStatusCompleted, stored.Status) + assert.Equal(t, finishGateTeamA, stored.Winner) + assert.Equal(t, "Withdrawal recorded by mistake", stored.CorrectionReason, "the reopen's reason is the audit trail") +} + +// A correction naming a decision the sheet cannot send ("fought") does not keep +// the withdrawal, so it is a fought finish and the gate asks for every bout. +// The exemption is read under the write's own lock (teamFinishRefusalUnderTx). +func TestTeamFinishGate_ADecisionThatReplacesTheWithdrawalIsGated(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + p := finishPayload(wonBout(1)) + p["status"], p["winner"] = "running", "" + require.Equal(t, http.StatusOK, putScore(t, r, "tf", finishGateMatchID, p).Code) + w := postDecision(t, r, "tf", finishGateMatchID, map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + c := finishPayload(wonBout(1), unfoughtBout(2), unfoughtBout(3)) + c["decision"] = "fought" + c["correctionReason"] = "Scoring error" + w = putScore(t, r, "tf", finishGateMatchID, c) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "Bout 2 and Bout 3 have no result.") + assert.Equal(t, "kiken-voluntary", finishGateStored(t, store).Decision, "the refused write left the withdrawal alone") +} + +// teamFinishRefusalUnderTx exempts exactly the writes that keep the stored +// withdrawal, read from the in-tx snapshot. +func TestTeamFinishRefusalUnderTx(t *testing.T) { + refusal := &ValidationError{Message: "Bout 2 has no result."} + correction := &state.MatchResult{Status: state.MatchStatusCompleted} + kiken := correctionCheck{StoredStatus: state.MatchStatusCompleted, StoredDecision: "kiken-voluntary"} + fought := correctionCheck{StoredStatus: state.MatchStatusCompleted} + reopened := correctionCheck{StoredStatus: state.MatchStatusRunning} + + assert.Nil(t, teamFinishRefusalUnderTx(nil, fought, correction), "no refusal to apply") + assert.Nil(t, teamFinishRefusalUnderTx(refusal, kiken, correction), "a correction over a withdrawal keeps it") + assert.Same(t, refusal, teamFinishRefusalUnderTx(refusal, fought, correction)) + assert.Same(t, refusal, teamFinishRefusalUnderTx(refusal, reopened, correction), + "a match reopened since the refusal was computed is a fought finish") + assert.Same(t, refusal, teamFinishRefusalUnderTx(refusal, kiken, &state.MatchResult{Status: state.MatchStatusCompleted, Decision: "fought"})) +} + +// Every bout of a team match has a result or a decision (operator ruling +// 2026-09-24): a position neither team fields is recorded as a Tie, which +// counts as an individual draw for both teams. The lineups in force never +// exempt a bout from the gate, and the refusal never mentions them. +func TestTeamFinishGate_APositionNeitherTeamFieldsNeedsATie(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + // Both lineups leave bout 3 empty: nobody fights it, and it still needs a + // result. The operator records it as a Tie. + saveFinishGateLineup(t, store, finishGateTeamAID, 3, 1, 2) + saveFinishGateLineup(t, store, finishGateTeamBID, 3, 1, 2) + + w := putScore(t, r, "tf", finishGateMatchID, finishPayload(wonBout(1), wonBout(2))) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "Bout 3 has no result.") + assert.NotContains(t, w.Body.String(), "lineup", "a vacancy is legitimate play, never something to complete") + assert.Equal(t, state.MatchStatusRunning, finishGateStored(t, store).Status, "a refused finish writes nothing") + + tie := unfoughtBout(3) + tie["decision"] = string(domain.DecisionHikiwake) + w = putScore(t, r, "tf", finishGateMatchID, finishPayload(wonBout(1), wonBout(2), tie)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, state.MatchStatusCompleted, finishGateStored(t, store).Status) +} + +// POST .../bulk-score can complete a team match too, so it runs the same gate +// per entry. A refused entry is reported in errors[] (the endpoint is always +// 200, partial success), names the match and the bouts, and writes nothing. +func TestTeamFinishGate_BulkScore(t *testing.T) { + bulk := func(t *testing.T, r *gin.Engine, entry map[string]any) (int, map[string]any) { + t.Helper() + entry["id"] = finishGateMatchID + body, err := json.Marshal([]map[string]any{entry}) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, "/api/competitions/tf/matches/bulk-score", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + return w.Code, resp + } + + t.Run("an entry finishing with an unfought bout is refused", func(t *testing.T) { + r, store := setupTeamFinishServer(t, 5, state.TeamMatchTypeFixed) + code, resp := bulk(t, r, finishPayload(wonBout(1), wonBout(2), wonBout(3), wonBout(4))) + require.Equal(t, http.StatusOK, code) + assert.EqualValues(t, 0, resp["succeeded"]) + errs, ok := resp["errors"].([]any) + require.True(t, ok) + require.Len(t, errs, 1) + entry := errs[0].(map[string]any) + assert.Equal(t, finishGateMatchID, entry["matchId"]) + assert.Equal(t, "Bout 5 (Taisho) has no result. Record a score, a Tie, or a Fusensho before finishing.", entry["error"]) + assert.Equal(t, state.MatchStatusRunning, finishGateStored(t, store).Status, "a refused entry writes nothing") + }) + + t.Run("an entry with every bout fought is recorded", func(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + code, resp := bulk(t, r, finishPayload(wonBout(1), wonBout(2), wonBout(3))) + require.Equal(t, http.StatusOK, code) + assert.EqualValues(t, 1, resp["succeeded"]) + assert.Equal(t, state.MatchStatusCompleted, finishGateStored(t, store).Status) + }) +} + +// TestTeamFinishGate_BulkScore_MultiEntryUsesOneCompLoad pins bc-cse finding +// 13: the competition record backing the team finish gate is loaded ONCE for +// the whole batch (not once per entry), and that ONE load still gates every +// entry correctly and independently -- a refused entry and an accepted entry +// in the SAME request, sharing the SAME loaded competition record. +func TestTeamFinishGate_BulkScore_MultiEntryUsesOneCompLoad(t *testing.T) { + r, store := setupTeamFinishServer(t, 3, state.TeamMatchTypeFixed) + require.NoError(t, store.SavePoolMatches("tf", []state.MatchResult{ + {ID: finishGateMatchID, SideA: finishGateTeamA, SideAID: finishGateTeamAID, + SideB: finishGateTeamB, SideBID: finishGateTeamBID, Status: state.MatchStatusRunning}, + {ID: "Pool A-2", SideA: finishGateTeamA, SideAID: finishGateTeamAID, + SideB: finishGateTeamB, SideBID: finishGateTeamBID, Status: state.MatchStatusRunning}, + })) + + unfinished := finishPayload(wonBout(1), wonBout(2)) + unfinished["id"] = finishGateMatchID + complete := finishPayload(wonBout(1), wonBout(2), wonBout(3)) + complete["id"] = "Pool A-2" + + body, err := json.Marshal([]map[string]any{unfinished, complete}) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, "/api/competitions/tf/matches/bulk-score", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.EqualValues(t, 1, resp["succeeded"]) + errs, ok := resp["errors"].([]any) + require.True(t, ok) + require.Len(t, errs, 1) + entry := errs[0].(map[string]any) + assert.Equal(t, finishGateMatchID, entry["matchId"]) + + ms, err := store.LoadPoolMatches("tf") + require.NoError(t, err) + byID := map[string]state.MatchResult{} + for _, m := range ms { + byID[m.ID] = m + } + assert.Equal(t, state.MatchStatusRunning, byID[finishGateMatchID].Status, "the refused entry writes nothing") + assert.Equal(t, state.MatchStatusCompleted, byID["Pool A-2"].Status, "the complete entry, gated by the SAME loaded comp, is still stored") +} + +// loadCompetitionCountingStore wraps a real CompetitionStore, counting calls +// to LoadCompetition and delegating every other method unchanged (via +// interface embedding), so a handler wired against it behaves identically +// while this test can assert exactly how many times it read the competition +// record. bc-cse finding 13. +type loadCompetitionCountingStore struct { + CompetitionStore + loadCompetitionCalls int +} + +func (s *loadCompetitionCountingStore) LoadCompetition(id string) (*state.Competition, error) { + s.loadCompetitionCalls++ + return s.CompetitionStore.LoadCompetition(id) +} + +// TestTeamFinishGate_BulkScore_LoadsCompetitionOnce pins bc-cse finding 13: +// bulk-score's team finish gate loads the competition record ONCE for the +// whole batch, not once per entry (refuseUnfinishedTeamFinishForComp is +// called against the ONE comp loaded before the per-entry loop, rather than +// through refuseUnfinishedTeamFinish, which would reload it for every +// entry). +func TestTeamFinishGate_BulkScore_LoadsCompetitionOnce(t *testing.T) { + realStore, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng := engine.New(realStore) + hub := NewHub() + const compID = "tf-once" + require.NoError(t, realStore.SaveCompetition(&state.Competition{ + ID: compID, Kind: "team", Format: state.CompFormatMixed, Status: state.CompStatusPools, + TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed, + })) + require.NoError(t, realStore.SaveParticipants(compID, []domain.Player{ + {ID: finishGateTeamAID, Name: finishGateTeamA, Dojo: "DojoR"}, + {ID: finishGateTeamBID, Name: finishGateTeamB, Dojo: "DojoT"}, + })) + require.NoError(t, realStore.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-1", SideA: finishGateTeamA, SideAID: finishGateTeamAID, SideB: finishGateTeamB, SideBID: finishGateTeamBID, Status: state.MatchStatusRunning}, + {ID: "Pool A-2", SideA: finishGateTeamA, SideAID: finishGateTeamAID, SideB: finishGateTeamB, SideBID: finishGateTeamBID, Status: state.MatchStatusRunning}, + {ID: "Pool A-3", SideA: finishGateTeamA, SideAID: finishGateTeamAID, SideB: finishGateTeamB, SideBID: finishGateTeamBID, Status: state.MatchStatusRunning}, + })) + + counting := &loadCompetitionCountingStore{CompetitionStore: realStore} + + gin.SetMode(gin.TestMode) + r := gin.New() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, counting, realStore, hub, NewFileVerifier(realStore), realStore) + + var entries []map[string]any + for _, mid := range []string{"Pool A-1", "Pool A-2", "Pool A-3"} { + e := finishPayload(wonBout(1), wonBout(2), wonBout(3)) + e["id"] = mid + entries = append(entries, e) + } + body, err := json.Marshal(entries) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/matches/bulk-score", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.EqualValues(t, 3, resp["succeeded"], "all three entries must still be gated correctly and stored") + + assert.Equal(t, 1, counting.loadCompetitionCalls, + "the competition is loaded ONCE for the whole 3-entry batch, not once per entry") +} + +func TestRefuseUnfinishedTeamFinish_Scope(t *testing.T) { + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "ind", Format: state.CompFormatMixed})) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "team", Kind: "team", Format: state.CompFormatMixed, TeamSize: 3})) + partial := &state.MatchResult{Status: state.MatchStatusCompleted, SubResults: []state.SubMatchResult{{Position: 1, Winner: "A"}}} + + verr, err := refuseUnfinishedTeamFinish(store, "ind", "Pool A-1", partial) + require.NoError(t, err) + assert.Nil(t, verr, "an individual competition has no bouts to gate") + + for _, id := range []string{"Pool A-DH-1", "Pool A-TB-1"} { + verr, err = refuseUnfinishedTeamFinish(store, "team", id, partial) + require.NoError(t, err) + assert.Nil(t, verr, "%s is a single rep bout, not a team encounter", id) + } + + withdrawal := *partial + withdrawal.Decision = string(domain.DecisionFusenpai) + verr, err = refuseUnfinishedTeamFinish(store, "team", "Pool A-1", &withdrawal) + require.NoError(t, err) + assert.Nil(t, verr, "a withdrawal is not a fought finish") + + verr, err = refuseUnfinishedTeamFinish(store, "team", "Pool A-1", partial) + require.NoError(t, err) + require.NotNil(t, verr) + assert.Equal(t, "Bout 2 and Bout 3 have no result. Record a score, a Tie, or a Fusensho before finishing.", verr.Error()) + + verr, err = refuseUnfinishedTeamFinish(store, "missing", "Pool A-1", partial) + require.NoError(t, err) + assert.Nil(t, verr, "a competition that does not exist is left to the engine write to report") + + _, err = refuseUnfinishedTeamFinish(store, "bad/id", "Pool A-1", partial) + assert.Error(t, err, "an unreadable competition is an error, not a pass") +} + +// TestUnfinishedTeamBoutsMessage_SharedTable is the Go half of the shared +// copy table; the JS half reads the same file (unfinished_team_bouts.test.jsx). +func TestUnfinishedTeamBoutsMessage_SharedTable(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "unfinished_team_bouts.json")) + require.NoError(t, err) + var table struct { + Cases []struct { + TeamSize int `json:"teamSize"` + Bouts []int `json:"bouts"` + Message string `json:"message"` + } `json:"cases"` + } + require.NoError(t, json.Unmarshal(raw, &table)) + require.NotEmpty(t, table.Cases) + for _, c := range table.Cases { + assert.Equal(t, c.Message, unfinishedTeamBoutsMessage(c.TeamSize, c.Bouts)) + } + assert.Empty(t, unfinishedTeamBoutsMessage(5, nil)) +} + +func TestUnfinishedTeamBouts(t *testing.T) { + subs := []state.SubMatchResult{ + {Position: 1, Winner: "A"}, + {Position: 2}, + {Position: state.DaihyosenSubPosition, Winner: "A", Decision: "daihyosen"}, + {Position: 4, Decision: "hikiwake"}, + } + assert.Equal(t, []int{2, 3, 5}, unfinishedTeamBouts(subs, 5)) +} diff --git a/internal/mobileapp/testdata/unfinished_team_bouts.json b/internal/mobileapp/testdata/unfinished_team_bouts.json new file mode 100644 index 000000000..445aab963 --- /dev/null +++ b/internal/mobileapp/testdata/unfinished_team_bouts.json @@ -0,0 +1,19 @@ +{ + "_comment": [ + "bc-tmfn: the refusal copy for finishing a team match while numbered bouts", + "have no result, shared by BOTH implementations of unfinishedTeamBoutsMessage:", + "internal/mobileapp/team_finish_gate.go (the server's 400) and", + "web-mobile/js/admin_scoring_team.jsx (the editor's refusal). The operator", + "must read the same words whichever side refused. It pins values, not source", + "text. The copy names the bouts and what to record and never mentions the", + "lineup. Go half: TestUnfinishedTeamBoutsMessage_SharedTable; JS half:", + "unfinished_team_bouts.test.jsx." + ], + "cases": [ + { "teamSize": 5, "bouts": [5], "message": "Bout 5 (Taisho) has no result. Record a score, a Tie, or a Fusensho before finishing." }, + { "teamSize": 5, "bouts": [2, 3], "message": "Bout 2 (Jiho) and Bout 3 (Chuken) have no result. Record a score, a Tie, or a Fusensho before finishing." }, + { "teamSize": 5, "bouts": [1, 4, 5], "message": "Bout 1 (Senpo), Bout 4 (Fukusho) and Bout 5 (Taisho) have no result. Record a score, a Tie, or a Fusensho before finishing." }, + { "teamSize": 3, "bouts": [3], "message": "Bout 3 has no result. Record a score, a Tie, or a Fusensho before finishing." }, + { "teamSize": 7, "bouts": [6, 7], "message": "Bout 6 and Bout 7 have no result. Record a score, a Tie, or a Fusensho before finishing." } + ] +} diff --git a/internal/state/bracket.go b/internal/state/bracket.go index 93b248390..7ac013b09 100644 --- a/internal/state/bracket.go +++ b/internal/state/bracket.go @@ -190,8 +190,9 @@ func (s *Store) copyBracket(b *Bracket) *Bracket { return nil } res := &Bracket{ - Rounds: make([][]BracketMatch, len(b.Rounds)), - Preview: b.Preview, + Rounds: make([][]BracketMatch, len(b.Rounds)), + Preview: b.Preview, + TimesSettled: b.TimesSettled, } if b.DrawOrder != nil { res.DrawOrder = append([]string(nil), b.DrawOrder...) diff --git a/internal/state/bracket_rounds.go b/internal/state/bracket_rounds.go new file mode 100644 index 000000000..c5d5b7384 --- /dev/null +++ b/internal/state/bracket_rounds.go @@ -0,0 +1,424 @@ +package state + +import ( + "errors" + "fmt" + "sort" + "time" +) + +// BracketMatchLeafSlot is the LEFTMOST first-round slot that match (roundIdx, +// matchIdx) is rooted above, in a pow2-padded Bracket.Rounds: match (r, m) +// covers leaves [m*2^(r+1), (m+1)*2^(r+1)). +// +// Two things derive from it and they MUST agree: a match's court (the region +// owning that leaf, engine.buildBracketFromDraw) and the tie-break inside a +// DisplayRound when numbering matches (NumberMatches). They are the same +// quantity, so they are one function -- numbering a bout by one rule and +// placing it by another is precisely how the printed sheet's "Match 12" and the +// app's "Match 12" became different bouts. +func BracketMatchLeafSlot(roundIdx, matchIdx int) int { + return matchIdx * (1 << (roundIdx + 1)) +} + +// numbered reports whether m is a real bout, one that carries a round and a +// match number: not a Hidden structural bye and not a both-sides-empty dead +// match. NumberMatches numbers exactly these, and StampRoundsFromFeeders +// requires its walk to reach every one of them, so the two cannot disagree +// about which matches count. At generation it equals !Hidden +// (engine.computeBracketDisplayMetadata hides every match missing a side), and +// in every supported flow it stays so, because no write after the draw +// empties a side of a match in Rounds: a result moves a name in, retracting it +// restores a "Winner of" placeholder, and only the bronze, outside Rounds, is +// ever blanked. The one exception is data no draw produces: a pool with fewer +// ranked finishers than the places it sends (hand-edited or imported files) +// has each missing place resolved to an empty side (engine.qualifierResolver). +// Should that empty both sides of a real match, the walk from the final can +// no longer pass through it, so StampRoundsFromFeeders refuses with +// ErrBracketFeedersUnwalkable and the load-time repair leaves the bracket as +// stored and logs why. The side check mirrors the Excel numbering's nil-node +// skip. +func (m *BracketMatch) numbered() bool { + return !m.Hidden && (m.SideA != "" || m.SideB != "") +} + +// NumberMatches sets MatchNumber on every real (non-Hidden, non-empty) bracket +// match. It is the ONE body of the web API's numbering, run by +// StampRoundsFromFeeders right after it stamps the rounds, both when +// engine.buildBracketFromDraw generates a draw and when +// RestampRoundsFromFeeders brings a stored bracket up to date on load. The Excel +// renderer has a SEPARATE implementation, helper.AssignMatchNumbers, which +// operates on []*Node instead of *Bracket. The two are NOT a literally-shared +// function (the types differ), they are kept equal-by-contract so the on-screen +// "Match N" always equals the printed Excel "Match N". +// +// Ordering, CRITICAL for byes: the Excel sheet numbers via eliminationMatchRounds, +// which groups matches by DEPTH-FROM-ROOT (the unbalanced tree's deepest matches +// come first), NOT by raw Rounds index. With a non-power-of-two roster the +// pow2-padded Rounds order diverges from that depth grouping, so numbering in raw +// Rounds order drifts (e.g. 5 entrants: the lone deep first-round bout must be +// Match 1, not the shallow slot-0 bout). DisplayRound already encodes the Excel +// depth grouping (verified by TestBracketDisplayMetadata_MatchesExcelRounds), so +// matches are numbered by descending DisplayRound (deepest/earliest round first). +// +// The tie-break inside a DisplayRound is the match's LEFTMOST FIRST-ROUND SLOT, +// BracketMatchLeafSlot -- the same function engine.buildBracketFromDraw uses to +// find a match's court. It has to be, because Excel's TraverseRounds walks each +// depth level LEFT TO RIGHT across the whole tree, and one effective round can +// draw its matches from several pow2 rounds at once: a shallow region's first +// bout and a deep region's second bout share a DisplayRound while sitting in +// Rounds 0 and 1. Tie-breaking on the within-round position alone (the old rule) +// then interleaves them by an index that means different things in the two +// rounds, and the printed "Match 12" and the app's "Match 12" become different +// bouts. Measured on a pool-fed draw of 8 pools x 3 qualifiers: the sheet +// numbered Pool G-3rd v Pool H-3rd 12 while the app numbered the E-1st/A-2nd v +// F-1st/B-2nd bout 12 (bc-draw Phase 5). The leaf slot orders them the way the +// sheet prints them, because a node's slot range is contiguous and left-to-right +// IS increasing slot. +// +// Skip rule (matches the Excel nil-node skip): Hidden (structural-bye) matches and +// both-sides-empty dead matches are excluded and do not consume a number. +// +// The printed Excel sheet is authoritative. The contract is enforced by +// TestMatchNumberingParity_ExcelVsWeb (internal/engine, +// match_numbering_parity_test.go) for knockout brackets and by +// TestExcelWorkbookMatchesEngineBracket_Mixed (excel_draw_parity_test.go) for +// pool-fed ones, the latter by reading the numbers back out of a rendered +// workbook. If they ever diverge, fix THIS path to match the Excel one -- and the +// JS buildDisplayModel matchNumById ordering with it (web-mobile/js/bracket.jsx), +// which is the third implementation of this walk. +// +// Must run AFTER Hidden (engine.computeBracketDisplayMetadata, at generation) +// and DisplayRound (StampRoundsFromFeeders) are set. +func (b *Bracket) NumberMatches() { + type ref struct { + m *BracketMatch + // leafSlot is the first-round slot this match's subtree starts at. + leafSlot int + } + var real []ref + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + m := &b.Rounds[ri][mi] + if !m.numbered() { + continue + } + real = append(real, ref{m: m, leafSlot: BracketMatchLeafSlot(ri, mi)}) + } + } + // Descending DisplayRound (deepest/earliest round first), then left to right + // across the whole tree, mirrors the Excel eliminationMatchRounds walk. + sort.SliceStable(real, func(i, j int) bool { + if real[i].m.DisplayRound != real[j].m.DisplayRound { + return real[i].m.DisplayRound > real[j].m.DisplayRound + } + return real[i].leafSlot < real[j].leafSlot + }) + for i, r := range real { + r.m.MatchNumber = i + 1 + } +} + +// ErrBracketNoFeeders reports a bracket whose matches carry no Feeders at all: +// one generated before the display metadata existed (mp-7f2w, v0.17.0). Its +// rounds cannot be walked, so RestampRoundsFromFeeders leaves it as stored. +var ErrBracketNoFeeders = errors.New("bracket carries no feeder metadata") + +// ErrBracketFeedersUnwalkable reports stored Feeders that do not form the tree +// generation writes, so no round can be derived from them with confidence. +var ErrBracketFeedersUnwalkable = errors.New("bracket feeders cannot be walked from the final") + +// ScheduledAtLayout is how a match's ScheduledAt reads: HH:MM, 24-hour. The +// engine's scheduler writes it in this layout (scheduleClockLayout). +const ScheduledAtLayout = "15:04" + +// BracketRoundChange is one match whose stored round, match number or +// scheduled time RestampRoundsFromFeeders changed. +type BracketRoundChange struct { + ID string + OldRound int + NewRound int + OldNumber int + NewNumber int + OldScheduledAt string + NewScheduledAt string +} + +// RestampRoundsFromFeeders brings a STORED bracket's DisplayRound and +// MatchNumber up to the rule generation applies, StampRoundsFromFeeders, puts +// an unstarted old bracket's times in match-number order, and returns what +// moved (nil when nothing did, so a second call is a no-op). It may also set +// TimesSettled with no match moving, which the caller must save too. +// Store.EnsureLegacyUpgraded calls it once per load. +// +// Why it exists: DisplayRound and MatchNumber are stamped once, when the draw +// is generated, and nothing recomputed them afterwards, while the Excel export +// rebuilds the tree, numbers it with today's walk, and lays scores and courts +// onto the printed numbers. So it recomputes any stored bracket whose rounds or +// numbers differ from the rule. In practice that is two kinds of bracket: +// +// - drawn by v2.0.0 or v2.1.0, which put a pair drawn beside an empty pair +// one round early (P1 v P2 of a five-entrant knockout was a quarterfinal +// rather than a semifinal) and numbered by those rounds; +// - drawn by an older release (Feeders are stored, and the rounds derived +// from them, since v0.17.0), whose rounds are therefore already right +// but which broke a tie inside a round by the match's position in its +// own pow2 round rather than by its first-round slot +// (BracketMatchLeafSlot, v2.0.0). Where one round draws its bouts from +// two pow2 rounds, which takes byes, the two orders differ and the +// bracket is renumbered (a nine-entrant v1.1.0 knockout swaps its +// Matches 4 and 5). +// +// Times: every release before this one scheduled a court in storage order, not +// match-number order, so on a bracket with byes the court queue (ordered by +// time) could list Match 2 before Match 1, renumbered or not. This is repaired +// ONCE per bracket, on the first load of a bracket not yet TimesSettled (one +// written by an older release; this release's draw sets it). If nobody has +// started it (no real match started, scored or decided), a court whose times +// still rise in storage order has them handed out again in match-number +// order, the order a fresh draw is scheduled in; see +// scheduleNumberedInNumberOrder. If any real match has been touched the times +// stay as stored, and a court whose times do not rise in storage order (a +// time the operator moved) keeps them. Either way the bracket is then +// TimesSettled and its times are never looked at again, so a move the +// operator makes later, which may well leave a court rising in storage +// order, is not mistaken for the old scheduling. +// +// Only real matches move. Hidden matches keep their stored values, the bronze +// (ThirdPlaceMatch, DisplayRound -1) is neither read nor written, and +// pairings, results and courts are never touched. +// +// When the bracket cannot be walked safely it changes NOTHING, numbers and +// times included, and says why: ErrBracketNoFeeders when no match carries +// Feeders, and ErrBracketFeedersUnwalkable as StampRoundsFromFeeders +// describes. +func (b *Bracket) RestampRoundsFromFeeders() ([]BracketRoundChange, error) { + if b == nil || len(b.Rounds) == 0 { + return nil, nil + } + hasFeeders := false + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + if len(b.Rounds[ri][mi].Feeders) > 0 { + hasFeeders = true + } + } + } + if !hasFeeders { + return nil, ErrBracketNoFeeders + } + + type stamp struct { + round, number int + at string + } + before := make(map[*BracketMatch]stamp) + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + m := &b.Rounds[ri][mi] + before[m] = stamp{m.DisplayRound, m.MatchNumber, m.ScheduledAt} + } + } + if err := b.StampRoundsFromFeeders(); err != nil { + return nil, err + } + if !b.TimesSettled { + if !b.anyNumberedMatchTouched() { + b.scheduleNumberedInNumberOrder() + } + b.TimesSettled = true + } + + var changes []BracketRoundChange + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + m := &b.Rounds[ri][mi] + old := before[m] + if old.round != m.DisplayRound || old.number != m.MatchNumber || old.at != m.ScheduledAt { + changes = append(changes, BracketRoundChange{ + ID: m.ID, + OldRound: old.round, + NewRound: m.DisplayRound, + OldNumber: old.number, + NewNumber: m.MatchNumber, + OldScheduledAt: old.at, + NewScheduledAt: m.ScheduledAt, + }) + } + } + } + return changes, nil +} + +// StampRoundsFromFeeders is the ONE producer of DisplayRound and MatchNumber +// on a bracket's real matches: each one's round is its distance from the +// final along the stored Feeders (the final, the sole match of the last +// round, is round 1; every match named in a match's Feeders is one round +// deeper), and the matches are then numbered by NumberMatches. +// engine.buildBracketFromDraw calls it once Hidden and Feeders are stamped, +// and RestampRoundsFromFeeders calls it on load, so a freshly generated +// bracket and a restamped one cannot disagree about a round. +// +// It reads Feeders and numbered() (Hidden, and whether both sides are empty), +// never a side's contents, so it walks a bracket in play exactly as it walked +// the fresh one: nothing after the draw rewrites Feeders or Hidden, and +// numbered() says why its side check does not change either, and what +// happens in the one case, outside any supported flow, where it can. +// +// Hidden matches and the bronze (ThirdPlaceMatch) are neither read nor +// written. A bracket with no real match (fewer than two entrants) is left as +// it is. When the Feeders cannot be walked it changes NOTHING and returns +// ErrBracketFeedersUnwalkable: the last round is not a single final, a match +// id repeats, a feeder names a match that is missing, not real, or already +// reached, or a real match is never reached from the final. +func (b *Bracket) StampRoundsFromFeeders() error { + if b == nil || len(b.Rounds) == 0 { + return nil + } + rounds, err := b.feederRounds() + if err != nil { + return err + } + for m, r := range rounds { + m.DisplayRound = r + } + b.NumberMatches() + return nil +} + +// feederRounds walks the Feeders from the final and returns every real +// match's round, writing nothing, so a refusal leaves the bracket exactly as +// it was. See StampRoundsFromFeeders for the rule and the refusals. +func (b *Bracket) feederRounds() (map[*BracketMatch]int, error) { + byID := make(map[string]*BracketMatch) + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + m := &b.Rounds[ri][mi] + if _, dup := byID[m.ID]; dup { + return nil, fmt.Errorf("%w: match id %q appears twice", ErrBracketFeedersUnwalkable, m.ID) + } + byID[m.ID] = m + } + } + last := b.Rounds[len(b.Rounds)-1] + if len(last) != 1 { + return nil, fmt.Errorf("%w: the last round holds %d matches, not one final", ErrBracketFeedersUnwalkable, len(last)) + } + + // Breadth first from the final. A final that is not a real bout (fewer + // than two entrants) roots no walk. + round := make(map[*BracketMatch]int) + if final := &last[0]; final.numbered() { + round[final] = 1 + queue := []*BracketMatch{final} + for len(queue) > 0 { + m := queue[0] + queue = queue[1:] + for _, fid := range m.Feeders { + if fid == "" { + continue // a seeded entrant or a bye: no match feeds this side + } + f, ok := byID[fid] + if !ok { + return nil, fmt.Errorf("%w: %s names feeder %q, which is not in the bracket", ErrBracketFeedersUnwalkable, m.ID, fid) + } + if !f.numbered() { + return nil, fmt.Errorf("%w: %s names feeder %s, which is not a real match", ErrBracketFeedersUnwalkable, m.ID, fid) + } + if _, seen := round[f]; seen { + return nil, fmt.Errorf("%w: %s is reached twice", ErrBracketFeedersUnwalkable, fid) + } + round[f] = round[m] + 1 + queue = append(queue, f) + } + } + } + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + m := &b.Rounds[ri][mi] + if _, reached := round[m]; m.numbered() && !reached { + return nil, fmt.Errorf("%w: %s is not reached from the final", ErrBracketFeedersUnwalkable, m.ID) + } + } + } + return round, nil +} + +// anyNumberedMatchTouched reports whether any real match of the bracket has +// been started or carries anything a result writes: a status other than +// scheduled, a winner, a score, sub-bouts, a decision, overtime, or a write +// stamp (a match started and put back in the queue keeps its stamp). Byes and +// Hidden matches are resolved by the draw itself and do not count, and the +// bronze cannot be played before the semifinals. +func (b *Bracket) anyNumberedMatchTouched() bool { + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + m := &b.Rounds[ri][mi] + if !m.numbered() { + continue + } + if m.Status != MatchStatusScheduled || m.Winner != "" || m.WinnerID != "" || + len(m.IpponsA) > 0 || len(m.IpponsB) > 0 || m.HansokuA != 0 || m.HansokuB != 0 || + len(m.SubResults) > 0 || m.Decision != "" || m.Encho != nil || m.ModifiedAt != 0 { + return true + } + } + } + return false +} + +// scheduleNumberedInNumberOrder gives each court's real matches that court's +// OWN scheduled times, earliest time to the lowest match number, so the court +// plays in match-number order as a fresh draw does +// (engine.assignBracketMatchSlots). No time is invented or dropped: a court's +// set of times is unchanged, only who holds which. Hidden matches and the +// bronze keep theirs. +// +// It runs only on a bracket written by an older release (not TimesSettled, +// see RestampRoundsFromFeeders), and only touches a court whose times still +// carry the signature of the old scheduler: every release before match-number +// scheduling handed a court its times in STORAGE order (Rounds, then +// position), so they rise along it. A court whose times do not (a time the +// operator moved by hand) is left as it is, and so is one where a real match +// carries no time or one that does not read as a clock time. A court whose +// storage and number orders agree comes back unchanged. +func (b *Bracket) scheduleNumberedInNumberOrder() { + byCourt := make(map[string][]*BracketMatch) + var courts []string + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + m := &b.Rounds[ri][mi] + if !m.numbered() { + continue + } + if _, seen := byCourt[m.Court]; !seen { + courts = append(courts, m.Court) + } + byCourt[m.Court] = append(byCourt[m.Court], m) + } + } + for _, court := range courts { + ms := byCourt[court] // storage order: Rounds, then position + times := make([]string, 0, len(ms)) + storageOrdered := true + var prev time.Time + for i, m := range ms { + clock, err := time.Parse(ScheduledAtLayout, m.ScheduledAt) + if err != nil || (i > 0 && clock.Before(prev)) { + storageOrdered = false + break + } + prev = clock + times = append(times, m.ScheduledAt) + } + if !storageOrdered { + continue + } + // times is ascending already (it was checked to be), so the earliest + // goes to the lowest match number. + sort.SliceStable(ms, func(i, j int) bool { return ms[i].MatchNumber < ms[j].MatchNumber }) + for i, m := range ms { + m.ScheduledAt = times[i] + } + } +} diff --git a/internal/state/bracket_rounds_schedule_test.go b/internal/state/bracket_rounds_schedule_test.go new file mode 100644 index 000000000..5219ec01a --- /dev/null +++ b/internal/state/bracket_rounds_schedule_test.go @@ -0,0 +1,188 @@ +package state_test + +// bracket_rounds_schedule_test.go pins what RestampRoundsFromFeeders does to +// scheduled times (bc-tmfn). A court's queue is ordered by time, and every +// release before match-number scheduling gave a court its times in storage +// order, so a stored bracket with byes can list Match 2 before Match 1. On a +// bracket nobody has started, a court whose times still rise in storage order +// has its own times handed out again, earliest to the lowest match number; +// once a real bout has been touched, or when a court's times are not the old +// scheduler's, they stay exactly as stored. Either way this happens once: the +// bracket is then TimesSettled and its times are never examined again. + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// unstartedV21FiveEntrant is the v2.1.0 fixture with its one recorded result +// (P4 beat P5 M-K) taken back, as if the draw had been generated and nothing +// fought yet: P4 v P5 is scheduled again and P3 waits on its winner. +func unstartedV21FiveEntrant(t *testing.T) *state.Bracket { + t.Helper() + b := loadV21FiveEntrant(t) + m := matchByID(t, b, "m-r1-3") + m.Status = state.MatchStatusScheduled + m.Winner, m.WinnerID = "", "" + m.IpponsA, m.IpponsB = nil, nil + m.ResultSource = "" + m.ModifiedAt = 0 + next := matchByID(t, b, "m-r2-1") + next.SideB, next.SideBID = "Winner of r3-m3", "" + return b +} + +// setRoundNumberTime sets one match's round, number and time on want. +func setRoundNumberTime(t *testing.T, b *state.Bracket, id string, round, number int, at string) { + t.Helper() + m := matchByID(t, b, id) + m.DisplayRound, m.MatchNumber, m.ScheduledAt = round, number, at +} + +func TestRestampRoundsFromFeeders_UnstartedBracketIsScheduledInNumberOrder(t *testing.T) { + got := unstartedV21FiveEntrant(t) + require.Equal(t, "09:00", matchByID(t, got, "m-r1-0").ScheduledAt, "fixture: v2.1.0 gave its Match 1 the first time") + + changes, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Equal(t, []state.BracketRoundChange{ + {ID: "m-r1-0", OldRound: 3, NewRound: 2, OldNumber: 1, NewNumber: 2, OldScheduledAt: "09:00", NewScheduledAt: "09:05"}, + {ID: "m-r1-3", OldRound: 3, NewRound: 3, OldNumber: 2, NewNumber: 1, OldScheduledAt: "09:05", NewScheduledAt: "09:00"}, + }, changes) + + // Match 1 (P4 v P5) now holds the court's first time and Match 2 (P1 v + // P2) the next. Everything else, the Hidden rows' times and the bronze's + // included, is exactly as stored. + want := unstartedV21FiveEntrant(t) + want.TimesSettled = true + setRoundNumberTime(t, want, "m-r1-3", 3, 1, "09:00") + setRoundNumberTime(t, want, "m-r1-0", 2, 2, "09:05") + setRoundNumberTime(t, want, "m-r2-1", 2, 3, "09:10") + setRoundNumberTime(t, want, "m-r3-0", 1, 4, "09:15") + assert.Equal(t, want, got) + + again, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Empty(t, again, "a second pass finds nothing to move") +} + +// Anything a result write leaves on a real bout keeps the whole bracket's +// times: the court may already be playing in the order they give. +func TestRestampRoundsFromFeeders_TouchedBracketKeepsItsTimes(t *testing.T) { + touches := []struct { + name string + touch func(m *state.BracketMatch) + }{ + {"running", func(m *state.BracketMatch) { m.Status = state.MatchStatusRunning }}, + {"completed", func(m *state.BracketMatch) { m.Status = state.MatchStatusCompleted }}, + {"a winner", func(m *state.BracketMatch) { m.Winner = "P3" }}, + {"a winner id", func(m *state.BracketMatch) { m.WinnerID = "d2bcc7a9-0254-474f-9bbe-016464d5a925" }}, + {"an ippon for side A", func(m *state.BracketMatch) { m.IpponsA = []string{"M"} }}, + {"an ippon for side B", func(m *state.BracketMatch) { m.IpponsB = []string{"K"} }}, + {"a hansoku on side A", func(m *state.BracketMatch) { m.HansokuA = 1 }}, + {"a hansoku on side B", func(m *state.BracketMatch) { m.HansokuB = 1 }}, + {"sub-bouts", func(m *state.BracketMatch) { m.SubResults = []state.SubMatchResult{{Position: 1}} }}, + {"a decision", func(m *state.BracketMatch) { m.Decision = "kiken-voluntary" }}, + {"overtime", func(m *state.BracketMatch) { m.Encho = &state.EnchoMetadata{PeriodCount: 1} }}, + {"a write stamp (started, then put back in the queue)", func(m *state.BracketMatch) { m.ModifiedAt = 1790337238130 }}, + } + for _, tc := range touches { + t.Run(tc.name, func(t *testing.T) { + got := unstartedV21FiveEntrant(t) + tc.touch(matchByID(t, got, "m-r2-1")) // P3 v W(P4 v P5), a real bout + _, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + + assert.Equal(t, 1, matchByID(t, got, "m-r1-3").MatchNumber, "the numbers are corrected regardless") + assert.Equal(t, "09:05", matchByID(t, got, "m-r1-3").ScheduledAt, "a touched bracket keeps its times") + assert.Equal(t, "09:00", matchByID(t, got, "m-r1-0").ScheduledAt, "a touched bracket keeps its times") + }) + } +} + +// Renumbering is not what makes the times wrong: every release before +// match-number scheduling gave a court its times in storage order. A bracket +// whose rounds and numbers already follow the rule (as a release before +// v2.0.0 stored this shape) still lists Match 2 (P1 v P2, stored first) at +// 09:00, and gets the same repair. +func TestRestampRoundsFromFeeders_StorageOrderTimesAreRepairedWithoutARenumbering(t *testing.T) { + got := unstartedV21FiveEntrant(t) + setRoundNumberTime(t, got, "m-r1-0", 2, 2, "09:00") + setRoundNumberTime(t, got, "m-r1-3", 3, 1, "09:05") + + changes, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Equal(t, []state.BracketRoundChange{ + {ID: "m-r1-0", OldRound: 2, NewRound: 2, OldNumber: 2, NewNumber: 2, OldScheduledAt: "09:00", NewScheduledAt: "09:05"}, + {ID: "m-r1-3", OldRound: 3, NewRound: 3, OldNumber: 1, NewNumber: 1, OldScheduledAt: "09:05", NewScheduledAt: "09:00"}, + }, changes) +} + +// A time the operator moved by hand is not undone on the next load: the +// first pass settles the bracket, and a settled bracket's times stay as they +// are. (engine's TestBracketTimesTheOperatorMovedSurviveAReload pins the move +// that leaves a court rising in storage order again, which only the marker +// tells apart from the old scheduling.) +func TestRestampRoundsFromFeeders_HandMovedTimesSurvive(t *testing.T) { + b := unstartedV21FiveEntrant(t) + _, err := b.RestampRoundsFromFeeders() + require.NoError(t, err) + // P4 v P5 (Match 1) waits for a late competitor. + matchByID(t, b, "m-r1-3").ScheduledAt = "09:30" + moved := *b + moved.Rounds = cloneRounds(b.Rounds) + + changes, err := b.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Empty(t, changes) + assert.Equal(t, &moved, b) +} + +// Times never cross from one court to another: each court hands out only its +// own. +func TestRestampRoundsFromFeeders_TimesStayOnTheirCourt(t *testing.T) { + got := unstartedV21FiveEntrant(t) + // P3 v W(P4 v P5) (to be Match 3) on a second court that opens earlier. + // Court A keeps P1 v P2 at 09:00, P4 v P5 at 09:05 and the final at 09:15, + // rising in storage order as the old scheduler left them. + other := matchByID(t, got, "m-r2-1") + other.Court, other.ScheduledAt = "B", "08:55" + + _, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + require.Equal(t, 1, matchByID(t, got, "m-r1-3").MatchNumber) + assert.Equal(t, "09:00", matchByID(t, got, "m-r1-3").ScheduledAt, "Match 1 takes court A's first time") + assert.Equal(t, "09:05", matchByID(t, got, "m-r1-0").ScheduledAt) + assert.Equal(t, "09:15", matchByID(t, got, "m-r3-0").ScheduledAt) + assert.Equal(t, "08:55", matchByID(t, got, "m-r2-1").ScheduledAt, "court B keeps its one time") +} + +// A court with a real bout that carries no time, or one that does not read as +// a clock time, is left as it is: handing out the times anyway would invent a +// slot or drop one. +func TestRestampRoundsFromFeeders_CourtWithAnUnreadableTimeIsLeftAlone(t *testing.T) { + for name, at := range map[string]string{"no time": "", "not a clock time": "soon"} { + t.Run(name, func(t *testing.T) { + got := unstartedV21FiveEntrant(t) + matchByID(t, got, "m-r2-1").ScheduledAt = at + + _, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Equal(t, 1, matchByID(t, got, "m-r1-3").MatchNumber, "the numbers are corrected regardless") + assert.Equal(t, "09:05", matchByID(t, got, "m-r1-3").ScheduledAt) + assert.Equal(t, "09:00", matchByID(t, got, "m-r1-0").ScheduledAt) + assert.Equal(t, at, matchByID(t, got, "m-r2-1").ScheduledAt) + }) + } +} + +func cloneRounds(rounds [][]state.BracketMatch) [][]state.BracketMatch { + out := make([][]state.BracketMatch, len(rounds)) + for i := range rounds { + out[i] = append([]state.BracketMatch(nil), rounds[i]...) + } + return out +} diff --git a/internal/state/bracket_rounds_test.go b/internal/state/bracket_rounds_test.go new file mode 100644 index 000000000..e6d6c4441 --- /dev/null +++ b/internal/state/bracket_rounds_test.go @@ -0,0 +1,199 @@ +package state_test + +// bracket_rounds_test.go pins Bracket.RestampRoundsFromFeeders, the load-time +// correction for stored brackets whose rounds or match numbers differ from the +// rule generation applies (bc-tmfn): v2.0.0 and v2.1.0 put a pair drawn beside +// an empty pair one round early, and older releases broke a tie inside a round +// by a different order. The restamp recomputes each real match's round as its +// distance from the final along the stored Feeders and renumbers; on a bracket +// nobody has started it also puts each court's times in match-number order +// (bracket_rounds_schedule_test.go), and it changes nothing else. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// v21FiveEntrantFixture is a five-entrant knockout on one shiaijo exactly as a +// v2.1.0 binary wrote it through its API (generate-draw, start, then P4 beat P5 +// M-K), bytes unchanged. P1 v P2 (m-r1-0) sits beside an empty pair: v2.1.0 +// stored it as round 3, Match 1; its distance from the final makes it a +// semifinal, round 2, and so Match 2 after P4 v P5. +const v21FiveEntrantFixture = "testdata/bracket_v2.1.0_knockout5.json" + +func loadV21FiveEntrant(t *testing.T) *state.Bracket { + t.Helper() + raw, err := os.ReadFile(filepath.FromSlash(v21FiveEntrantFixture)) + require.NoError(t, err) + var b state.Bracket + require.NoError(t, json.Unmarshal(raw, &b)) + require.False(t, b.TimesSettled, "fixture: written by v2.1.0, before the times were settled") + // A bronze the restamp must neither read nor write. v2.1.0 draws one only + // for a format that needs a single 3rd place; adding it here keeps the + // "bronze untouched" rule under test without a second fixture. + b.ThirdPlaceMatch = &state.BracketMatch{ + ID: state.BronzeMatchID, + Status: state.MatchStatusScheduled, + Court: "A", + ScheduledAt: "09:15", + DisplayRound: -1, + } + return &b +} + +func matchByID(t *testing.T, b *state.Bracket, id string) *state.BracketMatch { + t.Helper() + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + if b.Rounds[ri][mi].ID == id { + return &b.Rounds[ri][mi] + } + } + } + require.Failf(t, "no such match", "%s", id) + return nil +} + +func TestRestampRoundsFromFeeders_CorrectsTheV21FiveEntrantBracket(t *testing.T) { + got := loadV21FiveEntrant(t) + require.Equal(t, 3, matchByID(t, got, "m-r1-0").DisplayRound, "fixture must carry the v2.1 classification") + + changes, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + // P4 v P5 carries a recorded result, so the times stay where v2.1.0 put + // them even though the two bouts swap numbers. + assert.Equal(t, []state.BracketRoundChange{ + {ID: "m-r1-0", OldRound: 3, NewRound: 2, OldNumber: 1, NewNumber: 2, OldScheduledAt: "09:00", NewScheduledAt: "09:00"}, + {ID: "m-r1-3", OldRound: 3, NewRound: 3, OldNumber: 2, NewNumber: 1, OldScheduledAt: "09:05", NewScheduledAt: "09:05"}, + }, changes) + + // The whole bracket equals the stored one with exactly these four + // matches' rounds and numbers set, and the bracket marked TimesSettled + // (P4 v P5 has been fought, so its times are kept), so sides, ids, + // winners, results, courts, scheduled times, Hidden rows, feeders, the + // draw order and the bronze are all proved untouched. + want := loadV21FiveEntrant(t) + want.TimesSettled = true + for id, rn := range map[string][2]int{ + "m-r1-0": {2, 2}, // P1 v P2: a semifinal, fought after P4 v P5 + "m-r1-3": {3, 1}, // P4 v P5: the one first-round bout + "m-r2-1": {2, 3}, // P3 v W(P4 v P5) + "m-r3-0": {1, 4}, // the final + } { + m := matchByID(t, want, id) + m.DisplayRound, m.MatchNumber = rn[0], rn[1] + } + assert.Equal(t, want, got) +} + +func TestRestampRoundsFromFeeders_SecondCallChangesNothing(t *testing.T) { + b := loadV21FiveEntrant(t) + _, err := b.RestampRoundsFromFeeders() + require.NoError(t, err) + + once, err := json.Marshal(b) + require.NoError(t, err) + changes, err := b.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Empty(t, changes) + twice, err := json.Marshal(b) + require.NoError(t, err) + assert.Equal(t, string(once), string(twice)) +} + +// A bracket from before the display metadata carries no Feeders anywhere. It +// predates the misclassification too, so it is left exactly as stored, match +// numbers included. +func TestRestampRoundsFromFeeders_NoFeedersLeavesTheBracketAlone(t *testing.T) { + b := loadV21FiveEntrant(t) + for ri := range b.Rounds { + for mi := range b.Rounds[ri] { + b.Rounds[ri][mi].Feeders = nil + } + } + before, err := json.Marshal(b) + require.NoError(t, err) + + changes, err := b.RestampRoundsFromFeeders() + require.ErrorIs(t, err, state.ErrBracketNoFeeders) + assert.Nil(t, changes) + after, err := json.Marshal(b) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) +} + +// Feeders that do not form the tree generation writes are not guessed at: the +// bracket is left exactly as stored, rounds and numbers alike. +func TestRestampRoundsFromFeeders_UnwalkableFeedersLeaveTheBracketAlone(t *testing.T) { + cases := []struct { + name string + mutate func(t *testing.T, b *state.Bracket) + }{ + {"a real match the walk does not reach", func(t *testing.T, b *state.Bracket) { + // m-r1-1 made a real bout that no match names as a feeder. + m := matchByID(t, b, "m-r1-1") + m.SideA, m.SideB, m.Hidden = "X", "Y", false + }}, + {"a feeder naming a missing match", func(t *testing.T, b *state.Bracket) { + matchByID(t, b, "m-r2-1").Feeders = []string{"", "m-r9-9"} + }}, + {"a feeder naming a hidden match", func(t *testing.T, b *state.Bracket) { + matchByID(t, b, "m-r2-1").Feeders = []string{"m-r1-2", "m-r1-3"} + }}, + {"a match fed into twice", func(t *testing.T, b *state.Bracket) { + matchByID(t, b, "m-r2-1").Feeders = []string{"m-r1-0", "m-r1-3"} + }}, + {"a last round that is not one final", func(t *testing.T, b *state.Bracket) { + last := len(b.Rounds) - 1 + b.Rounds[last] = append(b.Rounds[last], state.BracketMatch{ID: "m-r3-1", Hidden: true}) + }}, + {"a duplicated match id", func(t *testing.T, b *state.Bracket) { + // A Hidden row sharing a later real bout's id: the walk itself + // would succeed, so only the duplicate check can refuse it. + matchByID(t, b, "m-r1-1").ID = "m-r1-3" + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := loadV21FiveEntrant(t) + tc.mutate(t, b) + before, err := json.Marshal(b) + require.NoError(t, err) + + changes, err := b.RestampRoundsFromFeeders() + require.ErrorIs(t, err, state.ErrBracketFeedersUnwalkable) + assert.Nil(t, changes) + after, err := json.Marshal(b) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) + }) + } +} + +// Nothing to walk is not an error: an empty bracket, and a one-entrant bracket +// whose only match is a Hidden bye (generation stamps no round on it). +func TestRestampRoundsFromFeeders_NothingToWalk(t *testing.T) { + var nilBracket *state.Bracket + changes, err := nilBracket.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Nil(t, changes) + + changes, err = (&state.Bracket{}).RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Nil(t, changes) + + lone := &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", SideA: "P1", Winner: "P1", Status: state.MatchStatusCompleted, Hidden: true, Feeders: []string{"", ""}}, + }}} + changes, err = lone.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Nil(t, changes) + assert.Zero(t, lone.Rounds[0][0].DisplayRound) + assert.Zero(t, lone.Rounds[0][0].MatchNumber) +} diff --git a/internal/state/bracket_rounds_v110_test.go b/internal/state/bracket_rounds_v110_test.go new file mode 100644 index 000000000..5ce257404 --- /dev/null +++ b/internal/state/bracket_rounds_v110_test.go @@ -0,0 +1,141 @@ +package state_test + +// bracket_rounds_v110_test.go pins what the load-time restamp does to a bracket +// drawn before v2.0.0 (bc-tmfn). v1.1.0 already stored each bout's round as its +// distance from the final, so its rounds are left alone; but it numbered the +// bouts of one round by their position inside their own pow2 round, not by +// their first-round slot. Where a round draws bouts from two pow2 rounds (it +// takes byes) the two orders differ, the Excel export numbers by the slot, and +// the restamp renumbers the stored bracket to agree with it. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// v110NineEntrantFixture is a nine-entrant knockout on one shiaijo exactly as +// a v1.1.0 binary wrote it through its API (tournament, competition, nine +// participants Player01..Player09, generate-draw; nothing fought), bytes +// unchanged. v110NineEntrantRoster is the participants.csv it wrote alongside. +// In the third round from the final, P5 v P6 (m-r1-4, first-round slot 8) +// and P7 v the winner of P8 v P9 (m-r2-3, slot 12) share a round; v1.1.0 +// numbered them 5 and 4 by their positions in their own pow2 rounds (4 and +// 3), the sheet numbers them 4 and 5 left to right. +const ( + v110NineEntrantFixture = "testdata/bracket_v1.1.0_knockout9.json" + v110NineEntrantRoster = "testdata/participants_v1.1.0_knockout9.csv" +) + +func loadV110NineEntrant(t *testing.T) *state.Bracket { + t.Helper() + raw, err := os.ReadFile(filepath.FromSlash(v110NineEntrantFixture)) + require.NoError(t, err) + var b state.Bracket + require.NoError(t, json.Unmarshal(raw, &b)) + require.False(t, b.TimesSettled, "fixture: written by v1.1.0, before the times were settled") + return &b +} + +// v110NineEntrantCorrected sets on b the rounds, numbers and times the +// restamp gives the v1.1.0 bracket. The rounds are v1.1.0's own. The numbers +// follow the sheet's left-to-right order. v1.1.0 also scheduled the court in +// storage order (its Match 1, P8 v P9, was the fourth bout on the court), and +// nothing has been fought, so the court's eight times are handed out again in +// match-number order. +func v110NineEntrantCorrected(t *testing.T, b *state.Bracket) { + t.Helper() + for id, want := range map[string]struct { + round, number int + at string + }{ + "m-r1-7": {4, 1, "09:00"}, // P8 v P9, the one bout of the deepest round + "m-r1-0": {3, 2, "09:05"}, // P1 v P2 + "m-r1-1": {3, 3, "09:10"}, // P3 v P4 + "m-r1-4": {3, 4, "09:15"}, // P5 v P6: 5 in v1.1.0 + "m-r2-3": {3, 5, "09:20"}, // P7 v W(P8 v P9): 4 in v1.1.0 + "m-r2-0": {2, 6, "09:25"}, + "m-r3-1": {2, 7, "09:30"}, + "m-r4-0": {1, 8, "09:35"}, // the final keeps the last time + } { + setRoundNumberTime(t, b, id, want.round, want.number, want.at) + } + b.TimesSettled = true +} + +func TestRestampRoundsFromFeeders_RenumbersTheV110NineEntrantBracket(t *testing.T) { + got := loadV110NineEntrant(t) + require.Equal(t, 5, matchByID(t, got, "m-r1-4").MatchNumber, "fixture must carry v1.1.0's numbering") + require.Equal(t, 4, matchByID(t, got, "m-r2-3").MatchNumber, "fixture must carry v1.1.0's numbering") + + changes, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + for _, c := range changes { + assert.Equalf(t, c.OldRound, c.NewRound, "%s: v1.1.0 already stored the distance from the final", c.ID) + } + + want := loadV110NineEntrant(t) + v110NineEntrantCorrected(t, want) + assert.Equal(t, want, got) + + again, err := got.RestampRoundsFromFeeders() + require.NoError(t, err) + assert.Empty(t, again, "a second pass finds nothing to move") +} + +// Through the load path: the v1.1.0 bracket predates side ids too, so its +// first load also stamps them from the roster. Both repairs land in ONE write +// of bracket.json (the file version moves once), and a later process writes +// nothing. +func TestLegacyBracketUpgrade_V110BracketTakesIdsAndNumbersInOneWrite(t *testing.T) { + dir, _ := newLegacyUpgradeFixture(t) + v110, err := os.ReadFile(filepath.FromSlash(v110NineEntrantFixture)) + require.NoError(t, err) + roster, err := os.ReadFile(filepath.FromSlash(v110NineEntrantRoster)) + require.NoError(t, err) + compDir := filepath.Join(dir, "competitions", "c1") + require.NoError(t, os.WriteFile(filepath.Join(compDir, "participants.csv"), roster, 0o600)) + path := filepath.Join(compDir, "bracket.json") + require.NoError(t, os.WriteFile(path, v110, 0o600)) + + first := freshLegacyUpgradeStore(t, dir) + served, err := first.LoadBracket("c1") + require.NoError(t, err) + assert.Equal(t, uint64(1), first.FileVersion("c1", "bracket.json"), + "the side ids and the numbers are saved together, in one write") + + for _, m := range []struct{ id, sideAID string }{ + {"m-r1-0", "61f84cfd-791c-455f-b57a-94fcaf820d87"}, // Player01, from the roster + {"m-r1-7", "eb0e30d1-27f2-4dab-9701-305544e26b0c"}, // Player08 + } { + assert.Equalf(t, m.sideAID, matchByID(t, served, m.id).SideAID, "%s: side ids stamped by the same load", m.id) + } + corrected := loadV110NineEntrant(t) + v110NineEntrantCorrected(t, corrected) + for ri := range corrected.Rounds { + for mi, want := range corrected.Rounds[ri] { + got := served.Rounds[ri][mi] + assert.Equalf(t, [3]any{want.DisplayRound, want.MatchNumber, want.ScheduledAt}, + [3]any{got.DisplayRound, got.MatchNumber, got.ScheduledAt}, "%s", want.ID) + } + } + + upgraded, err := os.ReadFile(path) + require.NoError(t, err) + var onDisk state.Bracket + require.NoError(t, json.Unmarshal(upgraded, &onDisk)) + assert.Equal(t, served, &onDisk, "the read serves exactly what was written") + + second := freshLegacyUpgradeStore(t, dir) + _, err = second.LoadBracket("c1") + require.NoError(t, err) + again, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(upgraded), string(again), "an upgraded bracket must not be rewritten") + assert.Zero(t, second.FileVersion("c1", "bracket.json"), "no write, so no version bump") +} diff --git a/internal/state/competition.go b/internal/state/competition.go index 15d99b3eb..385f87fc1 100644 --- a/internal/state/competition.go +++ b/internal/state/competition.go @@ -440,8 +440,10 @@ func (s *Store) DeleteCompetition(id string) error { defer mu.Unlock() // A recreated same-ID competition must be re-checked for legacy shapes - // (its files are new); drop the once-per-process upgrade mark with it. + // (its files are new); drop the once-per-process upgrade mark with it, and + // the mark that its bracket's refusal was already logged. s.legacyUpgraded.Delete(id) + s.bracketRoundsRefusalLogged.Delete(id) // overrides.json is the one file in this directory whose writers serialize // on the store-wide s.mu rather than the per-competition lock, so the lock diff --git a/internal/state/default_win_bout_padding_unreadable_test.go b/internal/state/default_win_bout_padding_unreadable_test.go new file mode 100644 index 000000000..ccaa30dd1 --- /dev/null +++ b/internal/state/default_win_bout_padding_unreadable_test.go @@ -0,0 +1,55 @@ +package state + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLegacyDefaultWinPaddingNeverOverwritesAnUnreadableCell guards the +// bc-tmfn data-loss finding: a pool match closed by a default win (kiken) +// whose SubResults cell failed to parse must NOT be padded by the legacy +// upgrade pass. Padding turns the (empty, because unreadable) SubResults +// into a non-empty slice of placeholder rows, and savePoolMatchesLocked's +// SubResults column only falls back to the retained raw bytes when +// SubResults is still empty -- so padding it destroys the organiser's only +// copy of the corrupt cell forever. +func TestLegacyDefaultWinPaddingNeverOverwritesAnUnreadableCell(t *testing.T) { + dir := t.TempDir() + s, err := NewStore(dir) + require.NoError(t, err) + require.NoError(t, s.SaveCompetition(&Competition{ID: "c", Name: "C", TeamSize: 3})) + + team := MatchResult{ + ID: "Pool A-1", SideA: "Kenshikan", SideB: "Sanshukan", Winner: "Kenshikan", + Status: MatchStatusCompleted, Decision: "kiken", + SubResults: []SubMatchResult{ + {Position: 1, SideA: "Tanaka", SideB: "Suzuki", IpponsA: []string{"M"}, Winner: "Tanaka"}, + }, + } + require.NoError(t, s.SavePoolMatches("c", []MatchResult{team})) + + path := s.compPath("c", "pool-matches.csv") + mangleCell(t, path, `""position"":1`, `""position"":1x`) + + // Cold reload: LoadPoolMatches runs EnsureLegacyUpgraded, whose last step + // is upgradeTeamDefaultWinBoutPaddingLocked. Before the fix this padded + // the now-empty SubResults with 3 placeholder positions and immediately + // rewrote the file, destroying the corrupt cell's bytes. + fresh, err := NewStore(dir) + require.NoError(t, err) + loaded, err := fresh.LoadPoolMatches("c") + require.NoError(t, err) + require.Len(t, loaded, 1) + assert.Empty(t, loaded[0].SubResults, "an unreadable cell must not be padded") + assert.True(t, loaded[0].SubResultsUnreadable, "and must still say so") + + raw, err := os.ReadFile(path) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(raw), "Tanaka", + "the malformed cell's bytes must survive the legacy default-win padding pass") + assert.Contains(t, string(raw), `""position"":1x`, + "and must survive VERBATIM, not partially rewritten by padding") +} diff --git a/internal/state/legacy_team_members_filename_test.go b/internal/state/legacy_team_members_filename_test.go index 1c2cf0674..04d551b04 100644 --- a/internal/state/legacy_team_members_filename_test.go +++ b/internal/state/legacy_team_members_filename_test.go @@ -185,7 +185,14 @@ func TestLegacyUpgrade_UnreadableLegacyFileNeverMintsBlanksOverIt(t *testing.T) s, id := newTeamMemberTestStore(t, "team", 3, false) dir := filepath.Join(s.GetFolder(), "competitions", id) require.NoError(t, os.WriteFile(filepath.Join(dir, legacySquadsFilename), []byte("squads: [this is not a map\n"), 0o600)) - require.NoError(t, s.SaveParticipants(id, []domain.Player{{Name: "Tora A", Dojo: "Tora Dojo"}})) + // ID fixed at "c1-p1" (matching v200SquadsYAML's own key below) on BOTH + // saves in this test, rather than left to auto-mint: this test's roster + // save also runs saveParticipantsNoLock's own-roster orphan prune + // (bc-tmfn), so a real caller's identity-preserving re-save -- the only + // realistic way two saves of "the same Tora A" happen -- is exactly what + // must be modeled here for the migration's recovered "c1-p1" entry to + // still match the roster the second save actually persists. + require.NoError(t, s.SaveParticipants(id, []domain.Player{{ID: "c1-p1", Name: "Tora A", Dojo: "Tora Dojo"}})) s.EnsureLegacyUpgraded(id) @@ -207,7 +214,8 @@ func TestLegacyUpgrade_UnreadableLegacyFileNeverMintsBlanksOverIt(t *testing.T) // And once the fault clears the adoption still happens, through a caller // the stamp does not gate. A roster write is one; so is any squad mutator. require.NoError(t, os.WriteFile(filepath.Join(dir, legacySquadsFilename), []byte(v200SquadsYAML), 0o600)) - require.NoError(t, s.SaveParticipants(id, []domain.Player{{Name: "Tora A", Dojo: "Tora Dojo"}})) + // Same fixed ID as the first save above: the SAME Tora A, re-saved. + require.NoError(t, s.SaveParticipants(id, []domain.Player{{ID: "c1-p1", Name: "Tora A", Dojo: "Tora Dojo"}})) members, err := s.LoadSquads(id) require.NoError(t, err) assert.Equal(t, "Haruki Tanaka", members["c1-p1"][0].Name, "the retry must recover the real names") @@ -222,8 +230,15 @@ func TestLegacyUpgrade_RosterWriteAdoptsTheLegacyFileFirst(t *testing.T) { dir := filepath.Join(s.GetFolder(), "competitions", id) require.NoError(t, os.WriteFile(filepath.Join(dir, legacySquadsFilename), []byte(v200SquadsYAML), 0o600)) - // A roster save, with no prior read of this competition. - require.NoError(t, s.SaveParticipants(id, []domain.Player{{Name: "Tora A", Dojo: "Tora Dojo"}})) + // A roster save, with no prior read of this competition. ID fixed at + // "c1-p1" (matching v200SquadsYAML's own key), same reasoning as + // TestLegacyUpgrade_UnreadableLegacyFileNeverMintsBlanksOverIt above: + // this save also runs saveParticipantsNoLock's own-roster orphan prune + // (bc-tmfn), so the roster it persists must carry the id the legacy + // file's adopted entry is keyed on, or the prune correctly (and + // unhelpfully, for what this test wants to observe) drops it right back + // out in the same call. + require.NoError(t, s.SaveParticipants(id, []domain.Player{{ID: "c1-p1", Name: "Tora A", Dojo: "Tora Dojo"}})) members, err := s.LoadSquads(id) require.NoError(t, err) diff --git a/internal/state/legacy_upgrade.go b/internal/state/legacy_upgrade.go index e2c84c8a0..91026fe48 100644 --- a/internal/state/legacy_upgrade.go +++ b/internal/state/legacy_upgrade.go @@ -1,6 +1,7 @@ package state import ( + "errors" "fmt" "log" "maps" @@ -108,6 +109,37 @@ import ( // exact name) is left alone; the fields stay empty and the identity- // critical readers keep their name-based fallback for it. // +// - bracket.json DisplayRound/MatchNumber convert ON READ, below (bc-tmfn), +// in the same parse and the same write as the side-id repair above +// (upgradeBracketLocked). Any stored bracket whose rounds or numbers +// differ from the rule generation applies is recomputed: each real +// bout's round is its distance from the final along the bracket's OWN +// stored Feeders, and the bouts are renumbered by the one numbering +// rule (Bracket.RestampRoundsFromFeeders, sharing +// Bracket.StampRoundsFromFeeders with generation). In practice that +// means brackets drawn by v2.0.0 or v2.1.0, which put a pair drawn beside +// an empty pair one round early and numbered by those rounds, and older +// brackets with byes: their rounds were already right (Feeders are +// stored since v0.17.0, mp-7f2w, and every release before v2.0.0 +// derived the rounds from them), +// but before v2.0.0 a tie inside a round was broken by the bout's +// position in its own pow2 round, not its first-round slot, so where one +// round draws bouts from two pow2 rounds they are renumbered. Either +// way the app then agrees with the Excel export, which numbers the +// rebuilt tree and lays scores and courts onto the printed numbers. +// Recognising one needs no guess, and a bracket that is already right +// compares equal and is not written. Times too: every release before +// match-number scheduling gave a court its times in storage order, so +// on a bracket where no real bout has been started, scored or decided, a +// court whose times still rise in storage order has its OWN times handed +// out again in match-number order and the court queue runs Match 1 +// first. A touched bracket keeps its times, and so does a court whose +// times do not rise in storage order (this release's scheduling, or a +// time moved by hand). Pairings, results, courts and the bronze are +// never touched. A bracket with no Feeders at all predates the +// feeder metadata and is left alone silently; one whose Feeders do not +// form the generated tree is left alone and logged, once per process. +// // - participants.csv rows without the leading id column convert ON WRITE // (marshalParticipantsCSV mints ids for id-less rows on every save), and // DELIBERATELY NOT on read. A legacy no-id roster and a roster carrying @@ -307,12 +339,17 @@ func (s *Store) EnsureLegacyUpgraded(compID string) { if err := s.upgradePoolMatchSideIDsLocked(compID, roster); err != nil { log.Printf("state: legacy pool-match-side-id upgrade for %s: %v", compID, err) } - if err := s.upgradeBracketSideIDsLocked(compID, roster); err != nil { - log.Printf("state: legacy bracket-side-id upgrade for %s: %v", compID, err) + // bracket.json's side ids, then its rounds, match numbers and times, over + // one parse and at most one write (upgradeBracketLocked). + if err := s.upgradeBracketLocked(compID, roster); err != nil { + log.Printf("state: legacy bracket upgrade for %s: %v", compID, err) } if err := s.upgradeLineupMemberIDsLocked(compID, roster); err != nil { log.Printf("state: legacy lineup-member-id upgrade for %s: %v", compID, err) } + if err := s.upgradeTeamDefaultWinBoutPaddingLocked(compID, roster); err != nil { + log.Printf("state: legacy team-default-win-bout-padding upgrade for %s: %v", compID, err) + } s.legacyUpgraded.Store(compID, struct{}{}) } @@ -1040,19 +1077,28 @@ func bracketNeedsIDRepair(b *Bracket) bool { return b.ThirdPlaceMatch != nil && bracketSideNeedsIDRepair(b.ThirdPlaceMatch) } -// upgradeBracketSideIDsLocked completes a legacy (no-id) bracket.json's -// SideAID/SideBID/WinnerID. See the header comment above for the full -// resolution order (DrawOrder positionally for round 0, then the -// unique-bare-name fallback everywhere else, then WinnerID derived from each -// row's own resolved sides). Caller holds the per-comp lock. +// upgradeBracketLocked runs the two bracket.json repairs, the side ids +// (upgradeBracketSideIDsLocked) and then the rounds, match numbers and times +// (upgradeBracketRoundsLocked), over ONE parse of the file, and saves it once +// when either changed it. The rounds pass reads one thing the side-id pass +// writes, WinnerID, in its "has this bracket been played" check, and the +// side-id pass only derives a WinnerID on a row whose Winner already answers +// yes, so their order does not change the outcome. Caller holds the per-comp +// lock. // // Parses bracket.json directly (parseBracketFile) rather than going through // loadBracketLocked, for the same reason the pools/pool-matches upgrades // above parse directly: that wrapper is parse-then-deep-copy // (state.copyBracket), and the copy is pure overhead when this repair owns // the parsed value exclusively and either discards it or hands it straight -// to saveBracketLocked. -func (s *Store) upgradeBracketSideIDsLocked(compID string, roster *legacyUpgradeRoster) error { +// to saveBracketLocked, which refreshes the bracket cache and bumps its file +// version as every bracket write does. +// +// A side-id repair that fails part way (an unreadable roster) is logged and +// does not stop the rounds: what it had already stamped is the positional +// round-0 repair from DrawOrder, which needs no roster and is exact, so it is +// saved along with the rounds rather than thrown away. +func (s *Store) upgradeBracketLocked(compID string, roster *legacyUpgradeRoster) error { path := s.compPath(compID, "bracket.json") parsed, err := parseBracketFile(path) if err != nil { @@ -1062,9 +1108,41 @@ func (s *Store) upgradeBracketSideIDsLocked(compID string, roster *legacyUpgrade if bracket == nil || len(bracket.Rounds) == 0 { return nil } - if !bracketNeedsIDRepair(bracket) { + idsChanged, err := s.upgradeBracketSideIDsLocked(compID, bracket, roster) + if err != nil { + log.Printf("state: legacy bracket-side-id upgrade for %s: %v", compID, err) + } + settledBefore := bracket.TimesSettled + changes := s.upgradeBracketRoundsLocked(compID, bracket) + // A bracket that only became TimesSettled moved no match but must still + // be saved, or its times are examined again on every load. + if !idsChanged && len(changes) == 0 && bracket.TimesSettled == settledBefore { return nil } + if err := s.saveBracketLocked(compID, bracket, s.directWrite); err != nil { + return err + } + if len(changes) > 0 { + moved := make([]string, 0, len(changes)) + for _, c := range changes { + moved = append(moved, fmt.Sprintf("%s round %d->%d match %d->%d at %q->%q", + c.ID, c.OldRound, c.NewRound, c.OldNumber, c.NewNumber, c.OldScheduledAt, c.NewScheduledAt)) + } + log.Printf("state: bracket rounds for %s recomputed from its feeders: %s", compID, strings.Join(moved, "; ")) + } + return nil +} + +// upgradeBracketSideIDsLocked completes a legacy (no-id) bracket's +// SideAID/SideBID/WinnerID in place and reports whether it changed anything. +// See the header comment above for the full resolution order (DrawOrder +// positionally for round 0, then the unique-bare-name fallback everywhere +// else, then WinnerID derived from each row's own resolved sides). Called by +// upgradeBracketLocked, which owns the parse and the save. +func (s *Store) upgradeBracketSideIDsLocked(compID string, bracket *Bracket, roster *legacyUpgradeRoster) (bool, error) { + if !bracketNeedsIDRepair(bracket) { + return false, nil + } // (a) Round 0, positionally, from DrawOrder -- exact even for a // duplicated name, and the ONLY resolver here that never compares names. @@ -1076,7 +1154,7 @@ func (s *Store) upgradeBracketSideIDsLocked(compID string, roster *legacyUpgrade // documented on the pools.csv/pool-matches.csv upgrades above. idx, err := roster.get() if err != nil { - return err + return changed, err } if idx != nil { resolveSide := func(m *BracketMatch) { @@ -1172,7 +1250,129 @@ func (s *Store) upgradeBracketSideIDsLocked(compID string, roster *legacyUpgrade if bracket.ThirdPlaceMatch != nil { resolveSubs(bracket.ThirdPlaceMatch) } + return changed, nil +} +// upgradeBracketRoundsLocked brings a parsed bracket's DisplayRound and +// MatchNumber (and, on an untouched bracket it renumbers, its times) up to +// the rule generation applies (bc-tmfn), in place, through +// Bracket.RestampRoundsFromFeeders, and returns what moved. See the header +// comment above for which brackets it changes and which it leaves alone. +// Called by upgradeBracketLocked, which owns the parse and the save. +// +// A bracket whose Feeders cannot be walked is left as stored and logged, but +// only the FIRST time this process meets it: saveParticipantsNoLock re-arms +// EnsureLegacyUpgraded after every roster write (bc-pnum), and a bracket that +// cannot be walked on one pass cannot be on the next, so logging each pass +// would repeat the same line after every check-in. +func (s *Store) upgradeBracketRoundsLocked(compID string, bracket *Bracket) []BracketRoundChange { + changes, err := bracket.RestampRoundsFromFeeders() + if errors.Is(err, ErrBracketNoFeeders) { + return nil // predates the feeder metadata: nothing to walk + } + if err != nil { + if _, logged := s.bracketRoundsRefusalLogged.LoadOrStore(compID, struct{}{}); !logged { + log.Printf("state: legacy bracket-rounds upgrade for %s: rounds and match numbers left as stored: %v", compID, err) + } + return nil + } + return changes +} + +// upgradeTeamDefaultWinBoutPaddingLocked pads a stored COMPLETED team match +// that a default-win ruling (any kiken, fusenpai, or fusensho: +// domain.IsDefaultWinDecisionStr) closed with fewer SubResults rows than the +// competition's TeamSize -- e.g. a kiken recorded before the match's first +// bout was ever scored, on a file saved before this padding existed -- with +// an empty SubMatchResult row (Position only) for every missing numbered +// position, via PadDefaultWinBoutPositions, the SAME function +// engine.RecordMatchResultWithIneligibilityTx now applies on write. Without +// this, a match recorded before that write-time padding existed keeps +// contributing nothing to IV/PW forever: DefaultWinCreditSide's readers +// (TeamResultFrom, engine.accrueTeamSubResults, the Excel export) can only +// credit a Position actually present in SubResults. +// +// Independent of the roster/id repairs above (it touches Position/SubResults +// shape only, never a name or id), so ordering relative to them does not +// matter; it runs last purely because it is the newest, unrelated addition. +// Caller holds the per-comp lock (EnsureLegacyUpgraded). +func (s *Store) upgradeTeamDefaultWinBoutPaddingLocked(compID string, roster *legacyUpgradeRoster) error { + comp, err := roster.competition() + if err != nil || comp == nil || comp.TeamSize < 2 || comp.IsKachinuki() { + return err + } + if err := s.padTeamDefaultWinBoutsInPoolMatchesLocked(compID, comp.TeamSize); err != nil { + return err + } + return s.padTeamDefaultWinBoutsInBracketLocked(compID, comp.TeamSize) +} + +// padTeamDefaultWinBoutsInPoolMatchesLocked is +// upgradeTeamDefaultWinBoutPaddingLocked's pool-matches.csv half. Caller +// holds the per-comp lock. +func (s *Store) padTeamDefaultWinBoutsInPoolMatchesLocked(compID string, teamSize int) error { + path := s.compPath(compID, "pool-matches.csv") + parsed, err := parsePoolMatchesFile(path) + if err != nil { + return nil // missing/unreadable pool matches are the consumers' error to report + } + matches, _ := parsed.([]MatchResult) + if len(matches) == 0 { + return nil + } + changed := false + for i := range matches { + m := &matches[i] + if !NeedsDefaultWinBoutPadding(m.Status, m.Decision, m.SideA, m.SideB, m.ID, m.SubResults, m.SubResultsUnreadable, teamSize) { + continue + } + m.SubResults = PadDefaultWinBoutPositions(m.SubResults, teamSize) + changed = true + } + if !changed { + return nil + } + return s.savePoolMatchesLocked(compID, matches, s.directWrite) +} + +// padTeamDefaultWinBoutsInBracketLocked is +// upgradeTeamDefaultWinBoutPaddingLocked's bracket.json half. Caller holds +// the per-comp lock. A bracket match id never carries the pool DH/TB suffix, +// so IsPoolDaihyosenMatchID/IsTiebreakerMatchID are harmless (always false) +// here -- checked anyway inside NeedsDefaultWinBoutPadding so the one +// predicate serves both halves rather than one gated copy and one ungated. +func (s *Store) padTeamDefaultWinBoutsInBracketLocked(compID string, teamSize int) error { + path := s.compPath(compID, "bracket.json") + parsed, err := parseBracketFile(path) + if err != nil { + return nil // missing/unreadable bracket is the consumers' error to report + } + bracket, _ := parsed.(*Bracket) + if bracket == nil || len(bracket.Rounds) == 0 { + return nil + } + changed := false + pad := func(m *BracketMatch) { + // bracket.json parses as one atomic JSON document, so there is no + // per-match "unreadable SubResults cell" shape here the way a + // pool-matches.csv row has (a parse failure fails the WHOLE file, + // caught by parseBracketFile above, and this loop never runs). + // false is therefore always correct, not a stand-in for a missing + // field. + if !NeedsDefaultWinBoutPadding(m.Status, m.Decision, m.SideA, m.SideB, m.ID, m.SubResults, false, teamSize) { + return + } + m.SubResults = PadDefaultWinBoutPositions(m.SubResults, teamSize) + changed = true + } + for i := range bracket.Rounds { + for j := range bracket.Rounds[i] { + pad(&bracket.Rounds[i][j]) + } + } + if bracket.ThirdPlaceMatch != nil { + pad(bracket.ThirdPlaceMatch) + } if !changed { return nil } diff --git a/internal/state/legacy_upgrade_bracket_rounds_test.go b/internal/state/legacy_upgrade_bracket_rounds_test.go new file mode 100644 index 000000000..2332d70a2 --- /dev/null +++ b/internal/state/legacy_upgrade_bracket_rounds_test.go @@ -0,0 +1,177 @@ +package state_test + +// legacy_upgrade_bracket_rounds_test.go pins the bc-tmfn load-time repair for +// bracket.json: a bracket drawn by v2.0.0 or v2.1.0 carries rounds and match +// numbers from the old classification (a pair beside an empty pair one round +// early), and EnsureLegacyUpgraded rewrites them from the bracket's own stored +// Feeders, once, leaving everything else as it was. The input is the file a +// v2.1.0 binary wrote (testdata/bracket_v2.1.0_knockout5.json), placed on disk +// byte for byte. + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLegacyBracketRoundsUpgrade_V21BracketIsCorrectedOnLoadOnce(t *testing.T) { + dir, _ := newLegacyUpgradeFixture(t) + v21, err := os.ReadFile(filepath.FromSlash(v21FiveEntrantFixture)) + require.NoError(t, err) + path := filepath.Join(dir, "competitions", "c1", "bracket.json") + require.NoError(t, os.WriteFile(path, v21, 0o600)) + + first := freshLegacyUpgradeStore(t, dir) + served, err := first.LoadBracket("c1") + require.NoError(t, err) + + upgraded, err := os.ReadFile(path) + require.NoError(t, err) + require.NotEqual(t, string(v21), string(upgraded), "the v2.1 rounds must be rewritten on disk") + assert.NotZero(t, first.FileVersion("c1", "bracket.json"), + "the rewrite goes through saveBracketLocked, which bumps the bracket's file version") + + // The file on disk equals the v2.1 file with exactly these four matches' + // rounds and numbers changed, and the bracket marked TimesSettled: sides, + // ids, winners, the recorded P4 v P5 result, courts, scheduled times, + // Hidden rows, feeders and the draw order are identical. + var want state.Bracket + require.NoError(t, json.Unmarshal(v21, &want)) + want.TimesSettled = true + for id, rn := range map[string][2]int{ + "m-r1-0": {2, 2}, + "m-r1-3": {3, 1}, + "m-r2-1": {2, 3}, + "m-r3-0": {1, 4}, + } { + m := matchByID(t, &want, id) + m.DisplayRound, m.MatchNumber = rn[0], rn[1] + } + var onDisk state.Bracket + require.NoError(t, json.Unmarshal(upgraded, &onDisk)) + assert.Equal(t, want, onDisk) + // And the read that triggered it serves the corrected bracket, not a + // cached copy of the old one. + assert.Equal(t, &want, served) + + // A later process loads the corrected file and writes nothing. + second := freshLegacyUpgradeStore(t, dir) + _, err = second.LoadBracket("c1") + require.NoError(t, err) + again, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(upgraded), string(again), "an already-corrected bracket must not be rewritten") + assert.Zero(t, second.FileVersion("c1", "bracket.json"), "no write, so no version bump") +} + +// A bracket whose Feeders cannot be walked is left byte for byte as stored: +// the repair never guesses at a round. +func TestLegacyBracketRoundsUpgrade_UnwalkableBracketIsLeftAsStored(t *testing.T) { + dir, _ := newLegacyUpgradeFixture(t) + var b state.Bracket + v21, err := os.ReadFile(filepath.FromSlash(v21FiveEntrantFixture)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(v21, &b)) + m := matchByID(t, &b, "m-r1-1") // a real bout no match names as a feeder + m.SideA, m.SideB, m.Hidden = "X", "Y", false + // Ids of its own, so the side-id repair that runs first has nothing to do + // and any byte that moves would be this pass's. + m.SideAID, m.SideBID = "x-id", "y-id" + raw, err := json.MarshalIndent(&b, "", " ") + require.NoError(t, err) + path := filepath.Join(dir, "competitions", "c1", "bracket.json") + require.NoError(t, os.WriteFile(path, raw, 0o600)) + + fresh := freshLegacyUpgradeStore(t, dir) + _, err = fresh.LoadBracket("c1") + require.NoError(t, err) + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(raw), string(after)) + assert.Zero(t, fresh.FileVersion("c1", "bracket.json")) +} + +// A bracket that cannot be walked is reported once per process, not after +// every roster write: saveParticipantsNoLock re-arms the upgrade pass +// (bc-pnum), and the refusal is the same on every pass. +func TestLegacyBracketRoundsUpgrade_UnwalkableBracketIsLoggedOnce(t *testing.T) { + dir, _ := newLegacyUpgradeFixture(t) + var b state.Bracket + v21, err := os.ReadFile(filepath.FromSlash(v21FiveEntrantFixture)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(v21, &b)) + m := matchByID(t, &b, "m-r1-1") // a real bout no match names as a feeder + m.SideA, m.SideB, m.Hidden = "X", "Y", false + m.SideAID, m.SideBID = "x-id", "y-id" + raw, err := json.MarshalIndent(&b, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", "c1", "bracket.json"), raw, 0o600)) + + logged := captureStateLog(t, func() { + // Opening the store runs the pass once (the startup sweep). + fresh := freshLegacyUpgradeStore(t, dir) + for i := range 3 { + _, err := fresh.LoadBracket("c1") + require.NoError(t, err) + // A roster write re-arms the once-per-process upgrade pass. + require.NoError(t, fresh.SaveParticipants("c1", []domain.Player{ + {Name: fmt.Sprintf("Entrant %d", i), Dojo: "Seibukan"}, + })) + } + }) + assert.Equal(t, 1, strings.Count(logged, "legacy bracket-rounds upgrade for c1"), + "the refusal is logged once, not once per roster write:\n%s", logged) +} + +// A stored bracket whose rounds and numbers are already right but which +// predates TimesSettled moves no match on load, and is still saved once with +// the marker set: otherwise its times would be examined again on every load, +// and a move the operator makes later could be mistaken for the old +// scheduling. The v2.1 bracket here has been played (P4 v P5 is recorded), +// so its times are kept as they are. +func TestLegacyBracketRoundsUpgrade_OnlyTheMarkerIsSavedOnce(t *testing.T) { + dir, _ := newLegacyUpgradeFixture(t) + v21, err := os.ReadFile(filepath.FromSlash(v21FiveEntrantFixture)) + require.NoError(t, err) + var stored state.Bracket + require.NoError(t, json.Unmarshal(v21, &stored)) + for id, rn := range map[string][2]int{ + "m-r1-0": {2, 2}, + "m-r1-3": {3, 1}, + "m-r2-1": {2, 3}, + "m-r3-0": {1, 4}, + } { + m := matchByID(t, &stored, id) + m.DisplayRound, m.MatchNumber = rn[0], rn[1] + } + require.False(t, stored.TimesSettled) + raw, err := json.Marshal(stored) + require.NoError(t, err) + path := filepath.Join(dir, "competitions", "c1", "bracket.json") + require.NoError(t, os.WriteFile(path, raw, 0o600)) + + first := freshLegacyUpgradeStore(t, dir) + _, err = first.LoadBracket("c1") + require.NoError(t, err) + assert.Equal(t, uint64(1), first.FileVersion("c1", "bracket.json"), "the marker alone is a change worth saving") + upgraded, err := os.ReadFile(path) + require.NoError(t, err) + var onDisk state.Bracket + require.NoError(t, json.Unmarshal(upgraded, &onDisk)) + want := stored + want.TimesSettled = true + assert.Equal(t, want, onDisk, "nothing but the marker changes") + + second := freshLegacyUpgradeStore(t, dir) + _, err = second.LoadBracket("c1") + require.NoError(t, err) + assert.Zero(t, second.FileVersion("c1", "bracket.json"), "a settled bracket is not saved again") +} diff --git a/internal/state/match.go b/internal/state/match.go index 9b87f472b..55ac29ee4 100644 --- a/internal/state/match.go +++ b/internal/state/match.go @@ -156,7 +156,14 @@ func DeriveQueuePositions(matches []MatchResult) []int { }) counter := 0 for _, e := range entries { - if e.m.Status == MatchStatusScheduled { + // bc-cse: a scheduled match whose resolved side ids are + // currently barred (IneligibleSides, stamped by the caller + // BEFORE this runs -- see mobileapp.annotateIneligibleSides) + // is not queued: it cannot be started until the operator + // reinstates the barred side or records the default win, so + // "Next up" and on-deck alerts must skip it rather than + // present a match nobody can actually start next. + if e.m.Status == MatchStatusScheduled && e.m.IneligibleSides == nil { counter++ positions[e.idx] = counter } diff --git a/internal/state/match_id_grammar.go b/internal/state/match_id_grammar.go new file mode 100644 index 000000000..60cb26cff --- /dev/null +++ b/internal/state/match_id_grammar.go @@ -0,0 +1,52 @@ +package state + +import "strings" + +// IsPoolDaihyosenMatchID reports whether a match ID is a pool-stage +// daihyosen (representative) bout (IDs of the form "Pool X-DH-N"), generated +// when all team-pool matches complete with a tie on all 8 ranking criteria. +// These are structurally pool matches but scored as individual (one +// representative per side) rather than as full team bouts. +// +// The canonical home for this id grammar: engine.IsPoolDaihyosenMatchID +// delegates here (engine -> state, never the reverse), so there is exactly +// one definition rather than two that agree only by discipline. +func IsPoolDaihyosenMatchID(matchID string) bool { + return hasNumericSuffixAfter(matchID, "-DH-") +} + +// IsTiebreakerMatchID reports whether matchID identifies a supplementary +// ippon-shobu tiebreaker match (IDs of the form "Pool X-TB-N"), injected by +// engine/tiebreaker.go. See IsPoolDaihyosenMatchID's doc for why this +// grammar lives here rather than in engine. +func IsTiebreakerMatchID(matchID string) bool { + return hasNumericSuffixAfter(matchID, "-TB-") +} + +// hasNumericSuffixAfter reports whether id ends with marker followed by one +// or more digits and nothing else, e.g. hasNumericSuffixAfter("Pool A-DH-3", +// "-DH-") is true. A plain strings.Contains(id, marker) would also match a +// REGULAR pool match whose pool name happens to contain the marker, e.g. a +// pool literally named "Pool A-DH-East" produces regular match ids like +// "Pool A-DH-East-0"; that id contains "-DH-" but is not a daihyosen bout +// (its numeric suffix follows a later, unmarked "-"). Anchoring the digits to +// the LAST occurrence of marker rejects that case: the suffix after it is +// "East-0", not all-digits, so it correctly reports false. Mirrors the JS +// twin's anchored regex (pool_ids.jsx DAIHYOSEN_BOUT_RE / SUPPLEMENTARY_BOUT_RE, +// both /-DH-\d+$/ style), which was already correctly suffix-anchored. +func hasNumericSuffixAfter(id, marker string) bool { + i := strings.LastIndex(id, marker) + if i < 0 { + return false + } + suffix := id[i+len(marker):] + if suffix == "" { + return false + } + for _, r := range suffix { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/internal/state/match_id_grammar_test.go b/internal/state/match_id_grammar_test.go new file mode 100644 index 000000000..85e755585 --- /dev/null +++ b/internal/state/match_id_grammar_test.go @@ -0,0 +1,72 @@ +package state + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestIsPoolDaihyosenMatchID covers the ID-recognition helper. Moved here +// from internal/engine (bc-cse/11a): this grammar's one owner is state +// (engine.IsPoolDaihyosenMatchID is a thin delegate), so its exhaustive +// suffix-anchoring cases belong on the owner, not on a one-line wrapper. +func TestIsPoolDaihyosenMatchID(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {"Pool A-DH-0", true}, + {"Pool A-DH-1", true}, + {"Pool B-DH-42", true}, + {"My-Pool A-DH-0", true}, // hyphenated pool name, strings.Contains handles correctly + {"Pool A-East-DH-0", true}, // realistic hyphenated pool name + {"Pool A-0", false}, + {"Pool A-TB-0", false}, + {"Pool A-DH", false}, // no index after DH (no trailing dash) + {"Pool A-D-0", false}, // different prefix + {"Pool A-DHx-0", false}, // wrong prefix + {"DH-0", false}, // no pool separator + {"", false}, + // A pool literally named "Pool A-DH-East" produces regular match ids + // like "Pool A-DH-East-0". A plain strings.Contains(id, "-DH-") would + // misclassify these as daihyosen bouts; the suffix after the LAST + // "-DH-" here is "East-0", not all-digits, so it must be false. + {"Pool A-DH-East-0", false}, + {"Pool A-DH-East-12", false}, + } + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + assert.Equal(t, tc.want, IsPoolDaihyosenMatchID(tc.id)) + }) + } +} + +// TestIsTiebreakerMatchID covers the ID-recognition helper. Moved here from +// internal/engine (bc-cse/11a); see TestIsPoolDaihyosenMatchID's doc. +func TestIsTiebreakerMatchID(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {"Pool A-TB-0", true}, + {"Pool A-TB-1", true}, + {"Pool B-TB-42", true}, + {"Pool A-East-TB-0", true}, // hyphenated pool name + {"Pool A-0", false}, + {"Pool A-1", false}, + {"Pool A-TB", false}, // no index after TB + {"Pool A-T-0", false}, // different prefix + {"Pool A-TBx-0", false}, // wrong prefix + {"TB-0", false}, // no pool name separator + {"", false}, + // Same sibling scenario as IsPoolDaihyosenMatchID: a pool literally + // named "Pool A-TB-East" must not have its regular match ids + // misclassified as tiebreaker bouts. + {"Pool A-TB-East-0", false}, + } + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + assert.Equal(t, tc.want, IsTiebreakerMatchID(tc.id)) + }) + } +} diff --git a/internal/state/models.go b/internal/state/models.go index 01156d70f..fea67ba4d 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -997,6 +997,20 @@ func CanGenerateDraw(status CompetitionStatus) bool { return status == CompStatusSetup || status == "" } +// AcceptsPoolRankOverride reports whether a competition's pool order may be +// set by hand (a chusen, recorded as a pool-rank override): in its pools +// stage, and, for a pools + knockout competition, after the knockout has +// started as well. A pool result corrected then can leave a qualifying tie +// that the daihyosen cannot settle, and a chusen recorded by mistake has to +// stay fixable (operator ruling: "Everything should be able to be fixed, in +// case of a wrong entry"); what the new order does to the knockout is +// answered by engine.OverridePoolRanks' requalification. The one statement of +// this rule, shared by the override-rank door and engine.ChusenStatus, so +// the chusen panel is never offered a tie the door would refuse. +func (c Competition) AcceptsPoolRankOverride() bool { + return c.Status == CompStatusPools || (c.Format == CompFormatMixed && c.Status == CompStatusKnockout) +} + type MatchStatus string const ( @@ -1194,6 +1208,23 @@ func (s *SubMatchResult) HanteiDecided() bool { return domain.ContainsHantei(s.IpponsA) || domain.ContainsHantei(s.IpponsB) } +// HasResult reports whether this bout carries a result: a winner, a decision +// (a Tie is "hikiwake", a default win "fusensho"), a struck point or the +// hantei mark on either side, a foul, or an overtime. A row with none of +// those was never fought. It is the Go twin of subBoutHasBeenPlayed +// (web-mobile/js/admin_scoring_team.jsx), and the finish gate for a team +// match (every bout is fought, operator ruling 2026-09-24) asks it of each +// numbered bout. Placeholder and empty cells are not points, the same rule +// CountScoringIppons applies everywhere else, so a row of unfilled slots +// reads as unfought exactly as it does in the editor. +func (s *SubMatchResult) HasResult() bool { + return s.Winner != "" || s.Decision != "" || + domain.CountScoringIppons(s.IpponsA) > 0 || domain.CountScoringIppons(s.IpponsB) > 0 || + s.HanteiDecided() || + s.HansokuA > 0 || s.HansokuB > 0 || + s.Encho.On() +} + // Attribution reads this row's six identity fields into the shape every // "which side won" owner takes (domain.AttributeWinnerSide, and // domain.SubBoutAttribution for the sub-bout rule). It exists so that no @@ -1268,6 +1299,21 @@ func (s *SubMatchResult) ResolveMemberWinnerID() bool { return false } +// IneligibleSidesAnnotation names which side(s) of a SCHEDULED match are +// currently barred by a withdrawal recorded on a DIFFERENT match (bc-cse). +// Both fields are optional and independent: either, both, or neither may be +// set. The value is the barring status's decision string (domain.Decision, +// e.g. "kiken-voluntary"), never the reason sentence -- a client that wants +// prose reads the 409 a write attempt against this match would get +// (reasonHumanForBarredCompetitor, mobileapp/errors.go); this annotation +// exists so a match LIST can grey the row without attempting the write +// first. See MatchResult.IneligibleSides / BracketMatch.IneligibleSides for +// how it is stamped and kept off disk. +type IneligibleSidesAnnotation struct { + A string `json:"a,omitempty"` + B string `json:"b,omitempty"` +} + type MatchResult struct { ID string `json:"id"` SideA string `json:"sideA"` // Player/Team Name @@ -1344,6 +1390,17 @@ type MatchResult struct { SubResultsUnreadable bool `json:"subResultsUnreadable,omitempty" yaml:"-"` Encho *EnchoMetadata `json:"encho,omitempty" yaml:"encho,omitempty"` QueuePosition int `json:"queuePosition,omitempty" yaml:"-"` + // IneligibleSides is a READ-ONLY, request-time annotation (bc-cse), + // exactly like QueuePosition above: stamped only on the copy a viewer + // endpoint serves (mobileapp.annotateIneligibleSides), never on an + // object bound for a write, so it never reaches pool-matches.csv (no + // entry in poolMatchColumns, pools.go) the same way QueuePosition does + // not. Non-nil only for a SCHEDULED match whose stamped SideAID/SideBID + // are currently barred (engine.BarredSides) by a withdrawal recorded on + // a DIFFERENT match, so the SPA can grey the row / skip it in "next up" + // without a second round trip. Omitted on the wire entirely when + // neither side is barred. + IneligibleSides *IneligibleSidesAnnotation `json:"ineligibleSides,omitempty" yaml:"-"` // DecidedByHantei is a LEGACY READ-ONLY channel, exactly as on // SubMatchResult (see there and legacy_hantei.go): the verdict is the // domain.HanteiMark entry in the winner's IpponsA/IpponsB. A hantei on a @@ -1380,14 +1437,15 @@ type MatchResult struct { // columns absent load as 0. Bracket matches persist fine via bracket.json. FlagsA int `json:"flagsA,omitempty" yaml:"flags_a,omitempty"` FlagsB int `json:"flagsB,omitempty" yaml:"flags_b,omitempty"` - // ReopenPending marks a kachinuki match that was reopened WITHOUT an audit - // reason and therefore still owes one (mp-gmcg). Reopening is one tap so an - // operator who ended a match by mistake can get straight back in; the - // justification is collected on the NEXT completion instead, folded into a - // step they were already taking. The score path refuses to complete a - // flagged match without a correctionReason and clears the flag once one - // lands, so the audit record is written later than the action it justifies - // but is never lost. + // ReopenPending marks a match that was reopened (engine.ReopenMatch: a + // kachinuki match, or one a withdrawal decided) WITHOUT an audit reason + // and not yet ended again. Nothing is refused for it: a match can be + // reopened without any reason, and ending it again works like ending any + // match (operator ruling 2026-09-25, which retired mp-gmcg's rule that the + // next completion must carry a reason). What it still does: a reason the + // ending write does carry is kept as the correction reason, since that + // completion replaces a result the reopen discarded, and the ending write + // clears it. // // SERVER-OWNED. MatchResult binds straight from the score request body, so // a client could otherwise plant or clear the flag; the handler overwrites @@ -1471,6 +1529,20 @@ func (m *MatchResult) MissingSideOrWinnerID() bool { (m.Winner != "" && m.WinnerID == "") } +// Attribution reads this match's six identity fields into the shape every +// "which side won" owner takes (domain.AttributeWinnerSide), mirroring +// SubMatchResult.Attribution above. It exists so a caller never hand-builds +// the domain.WinnerAttribution literal at each call site: all six fields +// are the same type and mutually assignable, so a transposed pair compiles +// clean and silently marks the wrong competitor (the same hazard +// WinnerAttribution's own doc comment names). +func (m *MatchResult) Attribution() domain.WinnerAttribution { + return domain.WinnerAttribution{ + Winner: m.Winner, SideA: m.SideA, SideB: m.SideB, + WinnerID: m.WinnerID, SideAID: m.SideAID, SideBID: m.SideBID, + } +} + // EnchoMetadata records overtime / sudden-death periods played in a // match. Read/persisted only in Slice 1; the score endpoint accepts it // but does not yet act on it. Slice 3 (T076) will wire it into the @@ -1550,7 +1622,10 @@ type PlayerStanding struct { IndividualDraws int `json:"individualDraws,omitempty"` PointsWon int `json:"pointsWon,omitempty"` PointsLost int `json:"pointsLost,omitempty"` - Tied bool `json:"tied,omitempty"` + // Tied marks a row in a tie still to be broken (the amber standings + // row): equal Points, and not yet settled by a recorded chusen or by its + // supplementary bouts (engine.markTiedStandings). + Tied bool `json:"tied,omitempty"` // Flags is the total accumulated own-side referee flags for engi (kata // demonstration) standings. A dedicated field, NOT an overload of // IpponsGiven, so the wire stays self-describing (wins vs flags as distinct @@ -1611,10 +1686,21 @@ type BracketMatch struct { HansokuB int `json:"hansokuB,omitempty"` IsOverridden bool `json:"isOverridden"` QueuePosition int `json:"queuePosition,omitempty"` + // IneligibleSides mirrors MatchResult.IneligibleSides for a bracket + // match (bc-cse): a request-time-only annotation, stamped by + // mobileapp.annotateIneligibleSides on the copy a viewer endpoint + // serves, never on the object a write persists to bracket.json (the + // same discipline QueuePosition above already relies on -- see its own + // doc comment on MatchResult for why that is safe without a json:"-" + // tag: BracketMatch's own MarshalJSON, below, is a straight struct + // marshal, so what keeps a derived field off disk is WHEN it is set, + // not a wire/disk type split). + IneligibleSides *IneligibleSidesAnnotation `json:"ineligibleSides,omitempty"` // MatchNumber is the sequential bracket match number, matching the // "Match N" label printed on the Excel tree sheet. 0 means unset; for a // BracketMatch that is a hidden/bye placeholder, or a legacy bracket saved - // before numbering was assigned. + // before numbering was assigned. Stamped together with DisplayRound, by + // the one producer DisplayRound's comment below names. MatchNumber int `json:"matchNumber,omitempty"` // Decision-type metadata mirrors MatchResult so an elimination-stage // kiken/fusenpai/encho is reconstructable from bracket.json alone @@ -1643,9 +1729,9 @@ type BracketMatch struct { // counts survive a restart. Zero for non-engi matches. FlagsA int `json:"flagsA,omitempty"` FlagsB int `json:"flagsB,omitempty"` - // ReopenPending mirrors MatchResult.ReopenPending for bracket matches - // (mp-gmcg): the match was reopened with no audit reason and still owes - // one, so the next completion must carry a correctionReason. Persisted in + // ReopenPending mirrors MatchResult.ReopenPending for bracket matches: the + // match was reopened with no audit reason and has not been ended again. + // Nothing is refused for it (see MatchResult.ReopenPending). Persisted in // bracket.json. Server-owned: the score write deliberately does NOT copy // this field off the client-supplied MatchResult (unlike ResultSource / // CorrectionReason), so only the reopen path and the handler's @@ -1666,6 +1752,14 @@ type BracketMatch struct { // real bout and must not be drawn as a match card. Feeders holds the IDs of // the two real feeder matches whose winners meet here, in [A, B] order; an // empty string means that side is a seeded entrant / bye (no connector line). + // DisplayRound and MatchNumber have ONE producer, + // Bracket.StampRoundsFromFeeders (a real match's round is its distance + // from the final along Feeders), run when the draw is generated and again + // on load (Bracket.RestampRoundsFromFeeders, via Store.EnsureLegacyUpgraded). + // The load pass recomputes any stored bracket whose rounds or numbers + // differ from that rule: in practice one drawn by v2.0.0 or v2.1.0 (rounds + // misclassified) or an older one with byes numbered before the leaf-slot + // tie-break (v2.0.0). It changes nothing on one the current generator drew. DisplayRound int `json:"displayRound,omitempty"` Hidden bool `json:"hidden,omitempty"` Feeders []string `json:"feeders,omitempty"` @@ -1705,6 +1799,16 @@ type BracketMatch struct { PlaceholderWinner string `json:"placeholderWinner,omitempty"` } +// Attribution reads this bracket match's six identity fields into the shape +// every "which side won" owner takes (domain.AttributeWinnerSide), mirroring +// MatchResult.Attribution above. +func (m *BracketMatch) Attribution() domain.WinnerAttribution { + return domain.WinnerAttribution{ + Winner: m.Winner, SideA: m.SideA, SideB: m.SideB, + WinnerID: m.WinnerID, SideAID: m.SideAID, SideBID: m.SideBID, + } +} + // BronzeMatchID is the id every 3rd-place match carries. It hangs off // Bracket.ThirdPlaceMatch rather than sitting in Rounds (see that field), so // code that must recognise the bronze compares against this rather than @@ -1743,6 +1847,18 @@ type Bracket struct { // this field existed has no draw order and yields NO numbers, by design; // there is no name-based fallback. DrawOrder []string `json:"drawOrder,omitempty"` + // TimesSettled records that the load-time repair of old scheduling + // (Bracket.RestampRoundsFromFeeders) owes this bracket's times nothing: + // the draw that built it scheduled every court in match-number order + // (engine.buildBracketFromDraw), or the repair has already run on it + // once, handing the times out again or, when play had already started, + // leaving them as they were. The repair runs only while it is false, so + // a time the operator moves afterwards is never taken back: moving a + // match up the court queue only swaps two times, and without this record + // the repair could not tell that swap from the old scheduler's storage + // order. Absent on a bracket written before it existed, which is exactly + // the bracket the repair is for. + TimesSettled bool `json:"timesSettled,omitempty"` } type Announcement struct { diff --git a/internal/state/models_test.go b/internal/state/models_test.go index 9d9bfb63a..f30651265 100644 --- a/internal/state/models_test.go +++ b/internal/state/models_test.go @@ -852,3 +852,26 @@ func TestMatchWinnerRanksNeeded(t *testing.T) { }) } } + +// A pool order is set by hand (a chusen) in the pools stage, and, for a mixed +// competition only, after its knockout has started as well: a pool correction +// made then can reopen a tie only a chusen settles. +func TestCompetition_AcceptsPoolRankOverride(t *testing.T) { + for _, tc := range []struct { + format string + status CompetitionStatus + want bool + }{ + {CompFormatMixed, CompStatusPools, true}, + {CompFormatLeague, CompStatusPools, true}, + {CompFormatMixed, CompStatusKnockout, true}, + {CompFormatKnockout, CompStatusKnockout, false}, + {"", CompStatusKnockout, false}, + {CompFormatMixed, CompStatusSetup, false}, + {CompFormatMixed, CompStatusDrawReady, false}, + {CompFormatMixed, CompStatusComplete, false}, + } { + c := Competition{Format: tc.format, Status: tc.status} + assert.Equal(t, tc.want, c.AcceptsPoolRankOverride(), "format=%q status=%q", tc.format, tc.status) + } +} diff --git a/internal/state/overrides.go b/internal/state/overrides.go index 06b886306..a9980d743 100644 --- a/internal/state/overrides.go +++ b/internal/state/overrides.go @@ -13,7 +13,7 @@ import ( // failure reading overrides.json. Standings computation deliberately fails // closed on a corrupt overrides file (a silently-dropped override could // resurrect a broken chusen or a stale manual rank), but every override -// writer -- SaveRankOverrideChanged, SaveWinnerOverride, ResetOverrides -- +// writer -- SaveRankOverridesChanged, SaveWinnerOverride, ResetOverrides -- // goes through modifyOverridesChanged, which LOADS the file first, so a // corrupt file has no writer that can repair it: even "reset" fails // identically. errors.Is(err, ErrCorruptOverrides) lets a caller (the HTTP @@ -150,31 +150,68 @@ func (s *Store) modifyOverrides(compID string, fn func(*Overrides)) error { return err } -// SaveRankOverrideChanged saves a manual pool-rank override for one -// competitor, identified by playerID ONLY, and reports whether the -// overrides file actually changed. Use this to gate broadcasts. +// SaveRankOverridesChanged saves manual pool-rank overrides for one or more +// competitors of one pool (ranks maps playerID -> rank), each identified by +// playerID ONLY, in ONE write, and reports whether the overrides file +// actually changed. Use this to gate broadcasts. A group set together (a +// chusen's whole order, engine.OverridePoolRanks) lands at once, so nothing +// ever reads the pool with half of the new order applied. // -// The override is keyed by helper.CompetitorKey(playerID, "", "") (bc-cse), +// Each override is keyed by helper.CompetitorKey(playerID, "", "") (bc-cse), // which resolves to "id:"+playerID: playerID is REQUIRED (bc-pnum) by every // caller (the mobileapp override-rank handler rejects a request with no // playerId before this is ever reached), so the name/dojo composite branch // CompetitorKey falls back to for an empty id is dead code from this // caller's side and is not exposed here. -func (s *Store) SaveRankOverrideChanged(compID, poolID, playerID string, rank int) (bool, error) { - key := helper.CompetitorKey(playerID, "", "") +func (s *Store) SaveRankOverridesChanged(compID, poolID string, ranks map[string]int) (bool, error) { return s.modifyOverridesChanged(compID, func(o *Overrides) { if o.PoolRanks[poolID] == nil { o.PoolRanks[poolID] = make(map[string]int) } - o.PoolRanks[poolID][key] = rank + for playerID, rank := range ranks { + o.PoolRanks[poolID][helper.CompetitorKey(playerID, "", "")] = rank + } }) } +// SaveRankOverride is SaveRankOverridesChanged for one competitor, discarding +// whether it changed anything. func (s *Store) SaveRankOverride(compID, poolID, playerID string, rank int) error { - _, err := s.SaveRankOverrideChanged(compID, poolID, playerID, rank) + _, err := s.SaveRankOverridesChanged(compID, poolID, map[string]int{playerID: rank}) return err } +// PriorRank is one competitor's pool-rank override as read before a change: +// Rank when Present, else no override at all. +type PriorRank struct { + Rank int + Present bool +} + +// RestoreRankOverrides puts pool-rank overrides back to the values read +// before a change (prior maps playerID -> what it held), in ONE write. It is +// the undo half of an override group the engine refused after writing it +// (engine.OverridePoolRanks): overrides.json serializes on the store-wide +// lock, not the per-competition one, so it is never staged in a transaction +// and a refusal has to put it back by hand. A present prior is written back +// under the participant-id key SaveRankOverridesChanged writes; an absent one +// deletes that key. +func (s *Store) RestoreRankOverrides(compID, poolID string, prior map[string]PriorRank) error { + return s.modifyOverrides(compID, func(o *Overrides) { + for playerID, p := range prior { + key := helper.CompetitorKey(playerID, "", "") + if !p.Present { + delete(o.PoolRanks[poolID], key) + continue + } + if o.PoolRanks[poolID] == nil { + o.PoolRanks[poolID] = make(map[string]int) + } + o.PoolRanks[poolID][key] = p.Rank + } + }) +} + func (s *Store) SaveWinnerOverride(compID, matchID, winnerName string) error { return s.modifyOverrides(compID, func(o *Overrides) { o.Winners[matchID] = winnerName diff --git a/internal/state/overrides_test.go b/internal/state/overrides_test.go index dff3d8ae4..2c53044b2 100644 --- a/internal/state/overrides_test.go +++ b/internal/state/overrides_test.go @@ -35,7 +35,7 @@ func TestOverrides(t *testing.T) { assert.Empty(t, overrides.Winners) // 2. Save rank override, keyed by participant id (bc-pnum: playerID is - // the only identity SaveRankOverrideChanged accepts); the key is + // the only identity SaveRankOverridesChanged accepts); the key is // helper.CompetitorKey("alice-id", "", ""), i.e. "id:alice-id" -- see // Overrides.PoolRanks' doc comment. Same-name-different-dojo identity is // verified in engine's TestCalculatePoolStandings_Override_SameNameDifferentDojo; @@ -70,6 +70,78 @@ func TestOverrides(t *testing.T) { assert.Empty(t, overrides.Winners) } +// RestoreRankOverrides is the undo of a refused override group: it puts back +// the value each competitor held before the change, or removes the key when +// there was none, in one write. +func TestRestoreRankOverrides(t *testing.T) { + dir, err := os.MkdirTemp("", "overrides-restore-*") + require.NoError(t, err) + defer os.RemoveAll(dir) + store, err := NewStore(dir) + require.NoError(t, err) + compID := "comp-restore" + require.NoError(t, store.SaveCompetition(&Competition{ID: compID, Name: "Comp Restore"})) + key := helper.CompetitorKey("alice-id", "", "") + bobKey := helper.CompetitorKey("bob-id", "", "") + ranks := func() map[string]int { + o, lerr := store.LoadOverrides(compID) + require.NoError(t, lerr) + return o.PoolRanks["Pool A"] + } + + require.NoError(t, store.SaveRankOverride(compID, "Pool A", "alice-id", 2)) + require.NoError(t, store.RestoreRankOverrides(compID, "Pool A", map[string]PriorRank{"alice-id": {}})) + assert.NotContains(t, ranks(), key, "no prior override: the key is removed") + + require.NoError(t, store.SaveRankOverride(compID, "Pool A", "alice-id", 3)) + require.NoError(t, store.RestoreRankOverrides(compID, "Pool B", map[string]PriorRank{"alice-id": {Rank: 1, Present: true}})) + o, err := store.LoadOverrides(compID) + require.NoError(t, err) + assert.Equal(t, 1, o.PoolRanks["Pool B"][key], "a prior override is put back, creating its pool's map") + assert.Equal(t, 3, ranks()[key], "another pool's override is untouched") + + // A group: each member goes back to what IT held, present or not. + _, err = store.SaveRankOverridesChanged(compID, "Pool A", map[string]int{"alice-id": 1, "bob-id": 2}) + require.NoError(t, err) + require.NoError(t, store.RestoreRankOverrides(compID, "Pool A", map[string]PriorRank{ + "alice-id": {Rank: 3, Present: true}, + "bob-id": {}, + })) + assert.Equal(t, map[string]int{key: 3}, ranks(), "alice back to 3, bob's new key removed") + assert.NotContains(t, ranks(), bobKey) +} + +// SaveRankOverridesChanged writes a whole group in one write and reports a +// change only when some rank in it actually moved. +func TestSaveRankOverridesChanged_Group(t *testing.T) { + dir, err := os.MkdirTemp("", "overrides-group-*") + require.NoError(t, err) + defer os.RemoveAll(dir) + store, err := NewStore(dir) + require.NoError(t, err) + compID := "comp-group" + require.NoError(t, store.SaveCompetition(&Competition{ID: compID, Name: "Comp Group"})) + ranks := func() map[string]int { + o, lerr := store.LoadOverrides(compID) + require.NoError(t, lerr) + return o.PoolRanks["Pool A"] + } + + changed, err := store.SaveRankOverridesChanged(compID, "Pool A", map[string]int{"a-id": 1, "b-id": 2, "c-id": 3}) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, map[string]int{"id:a-id": 1, "id:b-id": 2, "id:c-id": 3}, ranks()) + + changed, err = store.SaveRankOverridesChanged(compID, "Pool A", map[string]int{"a-id": 1, "b-id": 2, "c-id": 3}) + require.NoError(t, err) + assert.False(t, changed, "the same order again changes nothing") + + changed, err = store.SaveRankOverridesChanged(compID, "Pool A", map[string]int{"a-id": 3, "b-id": 2, "c-id": 1}) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, map[string]int{"id:a-id": 3, "id:b-id": 2, "id:c-id": 1}, ranks()) +} + func TestSaveOverrides_InvalidDir(t *testing.T) { // Try to save to a directory that cannot be created dir, err := os.MkdirTemp("", "overrides-fail-*") @@ -127,7 +199,7 @@ func TestLoadOverrides_InvalidJSON(t *testing.T) { } // TestResetOverridesForce_RepairsCorruptFile is PR #416 finding 10: every -// OTHER override writer (SaveRankOverrideChanged, SaveWinnerOverride, +// OTHER override writer (SaveRankOverridesChanged, SaveWinnerOverride, // ResetOverridesChanged) goes through modifyOverridesChanged, which LOADS // the file first, so none of them can repair a corrupt overrides.json -- // including "reset", which one might expect to be the escape hatch. @@ -203,12 +275,12 @@ func TestModifyOverridesChanged_NoChange(t *testing.T) { require.NoError(t, store.SaveCompetition(&Competition{ID: compID, Name: "No Change"})) // First save sets a rank - changed1, err := store.SaveRankOverrideChanged(compID, "Pool1", "alice-id", 1) + changed1, err := store.SaveRankOverridesChanged(compID, "Pool1", map[string]int{"alice-id": 1}) require.NoError(t, err) assert.True(t, changed1) // Saving the same value again should return false (no change) - changed2, err := store.SaveRankOverrideChanged(compID, "Pool1", "alice-id", 1) + changed2, err := store.SaveRankOverridesChanged(compID, "Pool1", map[string]int{"alice-id": 1}) require.NoError(t, err) assert.False(t, changed2) } diff --git a/internal/state/participants.go b/internal/state/participants.go index a9dee298c..ecacf8811 100644 --- a/internal/state/participants.go +++ b/internal/state/participants.go @@ -1440,7 +1440,12 @@ func (s *Store) saveParticipantsNoLock(compID string, players []domain.Player, w mintedByCompetitor[helper.CompetitorKey("", stamped[i].Name, stamped[i].Dojo)] = stamped[i].ID } - if err := s.upgradeSquadsFromMetadataLocked(compID, &legacyUpgradeRoster{store: s, compID: compID}, mintedByCompetitor); err != nil { + // Hoisted into a variable (rather than a fresh literal per call) so the + // orphan prune below can reuse its cached competition() lookup: both + // steps need Kind/TeamSize, and loadComp() memoises the read for the + // life of this one saveParticipantsNoLock call. + roster := &legacyUpgradeRoster{store: s, compID: compID} + if err := s.upgradeSquadsFromMetadataLocked(compID, roster, mintedByCompetitor); err != nil { log.Printf("state: saveParticipants %s: pre-write squad migration: %v", compID, err) } @@ -1481,6 +1486,34 @@ func (s *Store) saveParticipantsNoLock(compID string, players []domain.Player, w // will acquire that same per-comp lock before it re-checks the map. s.legacyUpgraded.Delete(compID) + // bc-tmfn: prune team-members.yaml / lineups.yaml entries for a team no + // longer on the roster this write just persisted, now that the write has + // landed (pruning ahead of the write risks deleting a team's members over + // a write that then fails, leaving the roster and its members disagreeing + // about what the write actually reached). keepIDs is the POST-mint id set + // -- stamped, not players -- since a row that matched an existing + // participant above (see the id-carry-forward comment on the batch + // POST /participants handler, handlers_participants.go) keeps that + // existing id here too, and a genuinely new row's freshly minted id is + // only on stamped. Best-effort, same contract as the seeds-orphan prune + // next to this write's callers: a failure here is logged, not + // propagated, and the leftover orphan is retried on the next roster + // write. + keepIDs := make(map[string]bool, len(stamped)) + for i := range stamped { + keepIDs[stamped[i].ID] = true + } + if comp, err := roster.competition(); err != nil { + log.Printf("state: saveParticipants %s: prune orphaned team data: load competition: %v", compID, err) + } else { + if err := s.pruneOrphanedTeamMembersLocked(compID, comp, keepIDs); err != nil { + log.Printf("state: saveParticipants %s: prune orphaned team members: %v", compID, err) + } + if err := s.pruneOrphanedTeamLineupsLocked(compID, comp, keepIDs); err != nil { + log.Printf("state: saveParticipants %s: prune orphaned team lineups: %v", compID, err) + } + } + return nil } diff --git a/internal/state/pool_roundtrip_test.go b/internal/state/pool_roundtrip_test.go index 03c7d1dc5..8ac119559 100644 --- a/internal/state/pool_roundtrip_test.go +++ b/internal/state/pool_roundtrip_test.go @@ -38,6 +38,10 @@ var notPersistedInPoolCSV = map[string]string{ "QueuePosition": "derived on read from court and scheduled time " + "(DeriveQueuePositions), never authored, so persisting it would create a " + "second source of truth that could disagree with the schedule.", + "IneligibleSides": "request-time-only annotation (bc-cse), stamped by " + + "mobileapp.annotateIneligibleSides on the served copy from the " + + "competitor-status store, never authored onto the object a write " + + "persists -- same discipline as QueuePosition above.", "WinnerSide": "derived: the winner name compared against SideA/SideB.", "SubResultsRaw": "the SubResults cell's UNPARSED bytes, retained only when " + "that cell failed to parse so the whole-file rewrite cannot destroy an " + diff --git a/internal/state/squad.go b/internal/state/squad.go index 70f47e9cd..f1e42b554 100644 --- a/internal/state/squad.go +++ b/internal/state/squad.go @@ -303,6 +303,56 @@ func (s *Store) saveSquadsLocked(compID string, squads map[string][]domain.TeamM return nil } +// pruneOrphanedTeamMembersLocked drops team-members.yaml entries for teams +// no longer present in keepIDs, the ids on the roster the save that calls +// this just persisted (bc-tmfn). Called from saveParticipantsNoLock, under +// the same per-competition lock that write already holds, after the +// participants.csv write has landed. +// +// Same Kind/TeamSize gate as upgradeSquadsFromMetadataLocked (an individual +// competition never has a team-members.yaml worth reading) and the same +// best-effort contract as saveParticipantsNoLock's caller applies to the +// analogous seeds-orphan cleanup: a failure here is logged by the caller, +// not propagated, so a roster write that already succeeded is never rolled +// back over a squad file that failed to prune. Only writes when an entry was +// actually dropped, matching saveSquadsLocked's own change-gated write. +func (s *Store) pruneOrphanedTeamMembersLocked(compID string, comp *Competition, keepIDs map[string]bool) error { + if comp == nil || (comp.Kind != "team" && comp.TeamSize == 0) { + return nil + } + // Scoped to a competition still in setup, no draw yet (CanGenerateDraw: + // status "setup" or the legacy empty status). Once a draw exists the + // roster stays editable after the start by ruling (bc-pnum ruling 1: a + // clean Apply on a started competition is expected to succeed), and a + // fought bout can resolve a team member by id (the Excel export and + // bout-log resolution both do, per resolveKachinukiDisplayName / + // resolveBoutSideDisplayName) even after that team leaves the roster. + // Pruning past this point would silently delete data a match still + // references. Before the draw no match exists, so an entry for a team + // no longer on the roster is dead data and pruning it is safe. + if !CanGenerateDraw(comp.Status) { + return nil + } + squads, err := s.loadSquadsLocked(compID) + if err != nil { + return err + } + if len(squads) == 0 { + return nil + } + changed := false + for teamID := range squads { + if !keepIDs[teamID] { + delete(squads, teamID) + changed = true + } + } + if !changed { + return nil + } + return s.saveSquadsLocked(compID, squads, s.directWrite) +} + // requireTeamParticipantLocked refuses a teamID no participant in this // competition carries. Caller MUST hold the per-competition lock. // diff --git a/internal/state/squad_test.go b/internal/state/squad_test.go index ee58e2cbc..b590f27dc 100644 --- a/internal/state/squad_test.go +++ b/internal/state/squad_test.go @@ -896,3 +896,67 @@ func TestClearTeamMemberName_LeavesTheSquadUntouchedWhenTheLineupHalfFails(t *te } } } + +// bc-tmfn: a roster replace that drops a team must prune that team's +// team-members.yaml entry, not leave it to accumulate under an id no +// surviving roster row carries. A team that STAYS (same id, whether +// unchanged or re-submitted by a caller that preserves identity) must keep +// its members untouched. +func TestSaveParticipants_PrunesOrphanedTeamMembers(t *testing.T) { + s, id, teamA, teamB := newSquadTestStore(t) + + squadsBefore, err := s.LoadSquads(id) + require.NoError(t, err) + require.NotEmpty(t, squadsBefore[teamA], "team A must have been seeded on the first save") + require.NotEmpty(t, squadsBefore[teamB], "team B must have been seeded on the first save") + + // Replace the roster, dropping team B and keeping team A under its SAME + // id -- a real caller preserving identity, exactly like the batch + // POST /participants handler's id carry-forward or the PUT roster + // path's client-side minting. + require.NoError(t, s.SaveParticipants(id, []domain.Player{ + {ID: teamA, Name: "Tora", Dojo: "Tora Dojo"}, + })) + + squadsAfter, err := s.LoadSquads(id) + require.NoError(t, err) + assert.Equal(t, squadsBefore[teamA], squadsAfter[teamA], "team A's members must be untouched") + _, stillPresent := squadsAfter[teamB] + assert.False(t, stillPresent, "team B's key must be pruned, not merely emptied, once it leaves the roster") +} + +// bc-tmfn follow-up: once a draw exists, a fought bout can resolve a team +// member by id (the Excel export and bout-log resolution both do), and the +// roster stays editable after the start by ruling (bc-pnum ruling 1: a +// clean Apply on a started competition is expected to succeed) -- so a +// roster replace on a STARTED competition must NOT prune a removed team's +// team-members.yaml entry, even though that team is no longer on the +// roster. Only a competition still in setup (no draw yet) is safe to prune. +func TestSaveParticipants_DoesNotPruneTeamMembersOnceStarted(t *testing.T) { + s, id, teamA, teamB := newSquadTestStore(t) + + squadsBefore, err := s.LoadSquads(id) + require.NoError(t, err) + require.NotEmpty(t, squadsBefore[teamA], "team A must have been seeded on the first save") + require.NotEmpty(t, squadsBefore[teamB], "team B must have been seeded on the first save") + + // Move the competition past setup, as a generated draw and a started + // competition would (e.g. pools). + comp, err := s.LoadCompetition(id) + require.NoError(t, err) + comp.Status = CompStatusPools + require.NoError(t, s.SaveCompetition(comp)) + + // Replace the roster, dropping team B and keeping team A under its SAME + // id -- exactly the "clean Apply" bc-pnum ruling 1 keeps open after the + // start. + require.NoError(t, s.SaveParticipants(id, []domain.Player{ + {ID: teamA, Name: "Tora", Dojo: "Tora Dojo"}, + })) + + squadsAfter, err := s.LoadSquads(id) + require.NoError(t, err) + assert.Equal(t, squadsBefore[teamA], squadsAfter[teamA], "team A's members must be untouched") + assert.Equal(t, squadsBefore[teamB], squadsAfter[teamB], + "team B's members must survive once a draw exists: a fought bout may still reference them by id, so the prune must not run") +} diff --git a/internal/state/store.go b/internal/state/store.go index 27417320d..bab5d55fd 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -34,6 +34,12 @@ type Store struct { // DeleteCompetition so a recreated same-ID competition is re-checked. legacyUpgraded sync.Map + // bracketRoundsRefusalLogged marks competitions whose bracket.json the + // rounds upgrade has already logged as unwalkable this process + // (upgradeBracketRoundsLocked), so a re-armed upgrade pass does not repeat + // the line after every roster write. Cleared on DeleteCompetition. + bracketRoundsRefusalLogged sync.Map + // compRenameMu serializes "uniqueness-check + save" sequences across // all competitions. Required because per-comp locks alone can't fix // the cross-comp AB-BA race: two concurrent renames of different diff --git a/internal/state/sub_match_has_result_test.go b/internal/state/sub_match_has_result_test.go new file mode 100644 index 000000000..4ac8b82e3 --- /dev/null +++ b/internal/state/sub_match_has_result_test.go @@ -0,0 +1,37 @@ +package state + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/stretchr/testify/assert" +) + +// TestSubMatchResult_HasResult pins the Go twin of subBoutHasBeenPlayed +// (bc-tmfn): each kind of recorded input counts, and a row of placeholders +// or empty cells does not. +func TestSubMatchResult_HasResult(t *testing.T) { + cases := []struct { + name string + sub SubMatchResult + want bool + }{ + {"an untouched row", SubMatchResult{Position: 1, IpponsA: []string{}, IpponsB: []string{}}, false}, + {"unfilled slots only", SubMatchResult{Position: 1, IpponsA: []string{domain.IpponPlaceholder, ""}}, false}, + {"a winner", SubMatchResult{Position: 1, Winner: "A"}, true}, + {"a Tie", SubMatchResult{Position: 1, Decision: DecisionDraw}, true}, + {"a fusensho", SubMatchResult{Position: 1, Decision: string(domain.DecisionFusensho)}, true}, + {"a point for A", SubMatchResult{Position: 1, IpponsA: []string{"M"}}, true}, + {"a point for B", SubMatchResult{Position: 1, IpponsB: []string{"K"}}, true}, + {"the hantei mark", SubMatchResult{Position: 1, IpponsB: []string{domain.HanteiMark}}, true}, + {"a foul on A", SubMatchResult{Position: 1, HansokuA: 1}, true}, + {"a foul on B", SubMatchResult{Position: 1, HansokuB: 1}, true}, + {"an overtime", SubMatchResult{Position: 1, Encho: &EnchoMetadata{PeriodCount: 1}}, true}, + {"a zero-period encho block", SubMatchResult{Position: 1, Encho: &EnchoMetadata{}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.sub.HasResult()) + }) + } +} diff --git a/internal/state/team_lineup.go b/internal/state/team_lineup.go index 163902951..9d109818f 100644 --- a/internal/state/team_lineup.go +++ b/internal/state/team_lineup.go @@ -189,6 +189,44 @@ func (s *Store) saveTeamLineupsLocked(compID string, lineups map[string]domain.T return nil } +// pruneOrphanedTeamLineupsLocked drops lineups.yaml entries for teams no +// longer present in keepIDs, the lineup sibling of +// pruneOrphanedTeamMembersLocked in squad.go (bc-tmfn): same trigger (a +// roster write that already landed), same Kind/TeamSize gate, same +// best-effort contract (a failure here is logged by the caller, not +// propagated). Only writes when an entry was actually dropped. +func (s *Store) pruneOrphanedTeamLineupsLocked(compID string, comp *Competition, keepIDs map[string]bool) error { + if comp == nil || (comp.Kind != "team" && comp.TeamSize == 0) { + return nil + } + // Same setup-only scope as pruneOrphanedTeamMembersLocked (squad.go), + // and for the same reason: once a draw exists a fought bout can resolve + // a lineup position by member id, and the roster stays editable after + // the start by ruling (bc-pnum ruling 1), so pruning past this point + // would silently delete data a match still references. + if !CanGenerateDraw(comp.Status) { + return nil + } + lineups, err := s.loadTeamLineupsLocked(compID) + if err != nil { + return err + } + if len(lineups) == 0 { + return nil + } + changed := false + for key, l := range lineups { + if !keepIDs[l.TeamID] { + delete(lineups, key) + changed = true + } + } + if !changed { + return nil + } + return s.saveTeamLineupsLocked(compID, lineups, s.directWrite) +} + // SetTeamLineup validates and persists a lineup, replacing any prior // entry for the same (teamID, round). The caller MUST pass the // competition's team size so ValidatePositions can check that the diff --git a/internal/state/team_lineup_test.go b/internal/state/team_lineup_test.go index 3c7ea66e6..da3ec9a57 100644 --- a/internal/state/team_lineup_test.go +++ b/internal/state/team_lineup_test.go @@ -474,3 +474,106 @@ func TestFindBestLineupAny_TieBreakDeterminism(t *testing.T) { } }) } + +// bc-tmfn: a roster replace that drops a team must prune that team's +// lineups.yaml entries too, the lineup sibling of +// TestSaveParticipants_PrunesOrphanedTeamMembers (squad_test.go). A team +// that stays (same id) must keep its lineup. +func TestSaveParticipants_PrunesOrphanedTeamLineups(t *testing.T) { + s, id := newTeamMemberTestStore(t, "team", 5, false) + require.NoError(t, s.SaveParticipants(id, []domain.Player{ + {Name: "Team Alpha", Dojo: "Alpha Dojo"}, + {Name: "Team Beta", Dojo: "Beta Dojo"}, + })) + stored, err := s.LoadParticipants(id, false) + require.NoError(t, err) + require.Len(t, stored, 2) + var alphaID, betaID string + for _, p := range stored { + switch p.Name { + case "Team Alpha": + alphaID = p.ID + case "Team Beta": + betaID = p.ID + } + } + require.NotEmpty(t, alphaID) + require.NotEmpty(t, betaID) + + require.NoError(t, s.SetTeamLineup(id, fiveStarter(alphaID, 0), 5)) + require.NoError(t, s.SetTeamLineup(id, fiveStarter(betaID, 0), 5)) + + before, err := s.LoadTeamLineups(id) + require.NoError(t, err) + require.Len(t, before, 2, "both teams' lineups must be recorded") + + // Replace the roster, dropping Team Beta and keeping Team Alpha under + // its SAME id. + require.NoError(t, s.SaveParticipants(id, []domain.Player{ + {ID: alphaID, Name: "Team Alpha", Dojo: "Alpha Dojo"}, + })) + + after, err := s.LoadTeamLineups(id) + require.NoError(t, err) + require.Len(t, after, 1, "Team Beta's lineup must be pruned; Team Alpha's must survive") + _, ok := after[teamLineupKey(alphaID, 0)] + assert.True(t, ok, "Team Alpha's lineup must still be keyed under its own id") + _, ok = after[teamLineupKey(betaID, 0)] + assert.False(t, ok, "Team Beta's lineup key must be gone") +} + +// bc-tmfn follow-up: the lineup sibling of +// TestSaveParticipants_DoesNotPruneTeamMembersOnceStarted (squad_test.go). +// Once a draw exists, a fought bout can resolve a lineup position by member +// id and the roster stays editable after the start by ruling (bc-pnum +// ruling 1), so a roster replace on a STARTED competition must NOT prune a +// removed team's lineups.yaml entry. +func TestSaveParticipants_DoesNotPruneTeamLineupsOnceStarted(t *testing.T) { + s, id := newTeamMemberTestStore(t, "team", 5, false) + require.NoError(t, s.SaveParticipants(id, []domain.Player{ + {Name: "Team Alpha", Dojo: "Alpha Dojo"}, + {Name: "Team Beta", Dojo: "Beta Dojo"}, + })) + stored, err := s.LoadParticipants(id, false) + require.NoError(t, err) + require.Len(t, stored, 2) + var alphaID, betaID string + for _, p := range stored { + switch p.Name { + case "Team Alpha": + alphaID = p.ID + case "Team Beta": + betaID = p.ID + } + } + require.NotEmpty(t, alphaID) + require.NotEmpty(t, betaID) + + require.NoError(t, s.SetTeamLineup(id, fiveStarter(alphaID, 0), 5)) + require.NoError(t, s.SetTeamLineup(id, fiveStarter(betaID, 0), 5)) + + before, err := s.LoadTeamLineups(id) + require.NoError(t, err) + require.Len(t, before, 2, "both teams' lineups must be recorded") + + // Move the competition past setup, as a generated draw and a started + // competition would (e.g. pools). + comp, err := s.LoadCompetition(id) + require.NoError(t, err) + comp.Status = CompStatusPools + require.NoError(t, s.SaveCompetition(comp)) + + // Replace the roster, dropping Team Beta and keeping Team Alpha under + // its SAME id. + require.NoError(t, s.SaveParticipants(id, []domain.Player{ + {ID: alphaID, Name: "Team Alpha", Dojo: "Alpha Dojo"}, + })) + + after, err := s.LoadTeamLineups(id) + require.NoError(t, err) + require.Len(t, after, 2, "Team Beta's lineup must survive once a draw exists: a fought bout may still reference it by id, so the prune must not run") + _, ok := after[teamLineupKey(alphaID, 0)] + assert.True(t, ok, "Team Alpha's lineup must still be present") + _, ok = after[teamLineupKey(betaID, 0)] + assert.True(t, ok, "Team Beta's lineup must still be present; the prune must not run once a draw exists") +} diff --git a/internal/state/team_result.go b/internal/state/team_result.go index bfa546ec5..d2b9a5596 100644 --- a/internal/state/team_result.go +++ b/internal/state/team_result.go @@ -38,7 +38,22 @@ func countScoringIppons(ippons []string) int { // are no countable sub-bouts (an individual match, or a slice containing // only the daihyosen placeholder with Position == DaihyosenSubPosition (-1)). // engine.ComputeTeamSummary delegates here. -func TeamResultFrom(subResults []SubMatchResult, sideAName, sideBName string) *TeamResultLine { +// +// credit is REQUIRED (bc-cse: a variadic credit let a caller silently skip +// the default-win rule, which handlers_daihyosen.go's tie check did until +// this change; see ComputeTeamSummary's own doc comment for that call +// site). A caller that knows the match's default-win crediting +// (MatchResult.TeamResult, BracketMatch.TeamResult, the Excel export) +// passes exactly one domain.MatchSide from DefaultWinCreditSide. A +// numbered bout (Position >= 1) with no result of its own is then routed +// through SubBoutEffectiveResult, which credits it to that side the same +// way a played bout with its own per-bout fusensho decision already counts +// (operator ruling: every bout of a non-kachinuki team match a default-win +// decision closes has a result or is credited one). Passing +// domain.MatchSideNone reproduces the pre-credit behaviour exactly: an +// unfought bout contributes nothing; use it explicitly, with a short +// comment, only where no default-win ruling can possibly be in force. +func TeamResultFrom(subResults []SubMatchResult, sideAName, sideBName string, credit domain.MatchSide) *TeamResultLine { if len(subResults) == 0 { return nil } @@ -60,14 +75,22 @@ func TeamResultFrom(subResults []SubMatchResult, sideAName, sideBName string) *T continue } hasBout = true - switch SubBoutWinnerSide(sub, sideAName, sideBName) { + // SubBoutEffectiveResult substitutes the FIK default-win maru for an + // unfought bout on a match a default-win ruling closed (credit names + // the side; DefaultWinCreditSide), so a kiken/fusenpai/fusensho + // declared before every numbered bout was scored still credits IV/PW + // for the positions nobody fought, the same way a played bout with + // its own per-bout fusensho decision already does. A real result on + // the row, or no credit to apply, returns it unchanged. + outcome := SubBoutEffectiveResult(sub, credit, sideAName, sideBName) + switch SubBoutWinnerSide(outcome, sideAName, sideBName) { case domain.MatchSideA: line.AkaIV++ case domain.MatchSideB: line.ShiroIV++ } - line.AkaPW += countScoringIppons(sub.IpponsA) - line.ShiroPW += countScoringIppons(sub.IpponsB) + line.AkaPW += countScoringIppons(outcome.IpponsA) + line.ShiroPW += countScoringIppons(outcome.IpponsB) } if !hasBout { return nil @@ -139,13 +162,188 @@ func SubBoutWinnerSide(sub SubMatchResult, sideAName, sideBName string) domain.M return domain.MatchSideNone } +// DefaultWinCreditSide reports which side a default-win ruling (any kiken, +// fusenpai, or fusensho: domain.IsDefaultWinDecisionStr) closing THIS match +// credits every unfought numbered bout to -- domain.MatchSideNone for +// anything else: an individual match, a running/reopened match (the ruling +// only ever credits a COMPLETED match), or a completed match some other +// decision closed (fought, hikiwake, daihyosen, kachinuki-exhaustion). +// +// decisionBy names the side that WITHDREW or was BARRED ("aka" = SideA, +// "shiro" = SideB; see engine.recordDecisionTx / keptWithdrawalScoreline), +// so the credited side is the OTHER one. decisionBy is empty only for a +// ruling recorded before it was captured (legacy data); the credited side +// then falls back to the match's own winner attribution +// (domain.AttributeWinnerSide, ids first, names second). +// +// att is built via MatchResult.Attribution() / BracketMatch.Attribution() +// so a caller never hand-transposes the six identity fields. +func DefaultWinCreditSide(status MatchStatus, decision, decisionBy string, att domain.WinnerAttribution) domain.MatchSide { + if status != MatchStatusCompleted || !domain.IsDefaultWinDecisionStr(decision) { + return domain.MatchSideNone + } + switch decisionBy { + case "aka": // SideA withdrew or was barred; the OTHER side (Shiro) is credited. + return domain.MatchSideB + case "shiro": // SideB withdrew or was barred; the OTHER side (Aka) is credited. + return domain.MatchSideA + } + return domain.AttributeWinnerSide(att) +} + +// SubBoutEffectiveResult returns sub's EFFECTIVE result for IV/PW and Excel +// export purposes: unchanged when it already carries one (HasResult), or, +// when it carries none and credit names a side (DefaultWinCreditSide), a +// synthetic row crediting that side the FIK default-win maru (Art. 32, +// domain.DefaultWinIppons) -- Winner set to the credited side's team name, +// SideA/SideB left exactly as the stored row held them (matching the +// existing quick-score / per-bout-fusensho shape: a fixed-order bout +// records no per-fighter identity, operator ruling bc-dnst). +// +// Decision is deliberately left untouched -- never set to the match's own +// decision: a reader shows the maru without a per-row Kiken/Fus. mark, +// because that mark names the ONE competitor who withdrew and already rides +// the match-level summary row's IV cell; repeating it on every credited +// bout would misname each fighter as having personally defaulted. +// +// credit is domain.MatchSideNone for anything but a completed default-win +// ruling (see DefaultWinCreditSide), and the daihyosen placeholder +// (Position < 1) is never synthesized, so a caller may call this +// unconditionally for every row in a match's SubResults. +func SubBoutEffectiveResult(sub SubMatchResult, credit domain.MatchSide, sideAName, sideBName string) SubMatchResult { + if sub.Position < 1 || sub.HasResult() || credit == domain.MatchSideNone { + return sub + } + out := sub + maru := domain.DefaultWinIppons(false) + switch credit { + case domain.MatchSideA: + out.Winner = sideAName + out.IpponsA = maru + case domain.MatchSideB: + out.Winner = sideBName + out.IpponsB = maru + } + return out +} + +// PadDefaultWinBoutPositions returns subResults with an EMPTY SubMatchResult +// row (Position only; no winner, decision or ippons) for every numbered +// position 1..teamSize it does not already carry. A position +// already present -- fought, previously padded, or otherwise recorded -- is +// left untouched, and the daihyosen row (Position < 1) is never added here. +// +// Both the live write path (engine.RecordMatchResultWithIneligibilityTx, on +// a completed default-win team-match write) and the legacy-load repair +// (state.EnsureLegacyUpgraded) call this ONE function, so a completed +// default-win team match ends up with one row per position whoever wrote it +// or when. That matters because SubBoutEffectiveResult / DefaultWinCreditSide +// can only credit a Position actually PRESENT in SubResults -- a position +// missing from the slice entirely is invisible to every reader that ranges +// over it (TeamResultFrom, engine.accrueTeamSubResults, the Excel export), +// credit side or no. +// +// When it pads, the numbered rows come back in Position order, because the +// team scoreboard names a bout's fighter by the row's array index +// (TeamScoreboard's pickFromLineup, match_scoreboard.jsx): a stored +// [Position 2] padded to [2, 1, 3, 4, 5] put the first fighter on the second +// bout. Every other row (the daihyosen row at DaihyosenSubPosition, or a +// malformed position) follows the numbered block in its original order, so +// the daihyosen row never sorts first. A call with nothing to pad returns +// subResults unchanged. +func PadDefaultWinBoutPositions(subResults []SubMatchResult, teamSize int) []SubMatchResult { + present := make(map[int]bool, len(subResults)) + for _, s := range subResults { + present[s.Position] = true + } + needsPadding := false + for pos := 1; pos <= teamSize; pos++ { + if !present[pos] { + needsPadding = true + break + } + } + if !needsPadding { + return subResults + } + + byPosition := make(map[int][]SubMatchResult, teamSize) + var trailing []SubMatchResult + for _, s := range subResults { + if s.Position >= 1 && s.Position <= teamSize { + byPosition[s.Position] = append(byPosition[s.Position], s) + continue + } + // The daihyosen row (Position == DaihyosenSubPosition, -1), any + // other non-positive row, or a malformed Position > teamSize: kept, + // in its original relative order, after the ordered numbered block + // below. + trailing = append(trailing, s) + } + + out := make([]SubMatchResult, 0, teamSize+len(trailing)) + for pos := 1; pos <= teamSize; pos++ { + if rows, ok := byPosition[pos]; ok { + out = append(out, rows...) + continue + } + out = append(out, SubMatchResult{Position: pos}) + } + return append(out, trailing...) +} + +// NeedsDefaultWinBoutPadding reports whether a stored match needs +// PadDefaultWinBoutPositions applied: completed, both sides named, closed by +// a default-win decision (domain.IsDefaultWinDecisionStr; a caller correcting +// a match while KEEPING a stored withdrawal ruling passes that stored +// decision here, since its own incoming Decision is blank until the ruling +// is reinstated downstream), not a pool daihyosen/tiebreaker row +// (IsPoolDaihyosenMatchID / IsTiebreakerMatchID), and missing at least one +// numbered position 1..teamSize. +// +// subResultsUnreadable must be the SAME row's flag (MatchResult's own field +// for a pool match; a bracket match carries no such flag because +// bracket.json parses as one atomic document, so pass false there). A cell +// that failed to parse loads with SubResults empty and the corrupt bytes +// retained separately (MatchResult.SubResultsRaw) for repair; padding it +// anyway would fill SubResults with placeholder rows, making it non-empty +// and so defeating the raw-bytes preservation on the next write -- the very +// data loss this predicate exists to avoid. Shared by the legacy-load repair +// (state.EnsureLegacyUpgraded) and the write-time gate +// (engine.RecordMatchResultWithIneligibilityTx) so the two never drift. +func NeedsDefaultWinBoutPadding(status MatchStatus, decision, sideA, sideB, id string, subResults []SubMatchResult, subResultsUnreadable bool, teamSize int) bool { + if status != MatchStatusCompleted || sideA == "" || sideB == "" { + return false + } + if subResultsUnreadable { + return false + } + if !domain.IsDefaultWinDecisionStr(decision) { + return false + } + if IsPoolDaihyosenMatchID(id) || IsTiebreakerMatchID(id) { + return false + } + present := make(map[int]bool, len(subResults)) + for _, sub := range subResults { + present[sub.Position] = true + } + for pos := 1; pos <= teamSize; pos++ { + if !present[pos] { + return true + } + } + return false +} + // TeamResult returns the team-match summary for this match, or nil for an // individual match. See TeamResultFrom. func (m *MatchResult) TeamResult() *TeamResultLine { if m == nil { return nil } - return TeamResultFrom(m.SubResults, m.SideA, m.SideB) + credit := DefaultWinCreditSide(m.Status, m.Decision, m.DecisionBy, m.Attribution()) + return TeamResultFrom(m.SubResults, m.SideA, m.SideB, credit) } // MarshalJSON augments the wire form of a MatchResult with the computed @@ -170,7 +368,8 @@ func (m *BracketMatch) TeamResult() *TeamResultLine { if m == nil { return nil } - return TeamResultFrom(m.SubResults, m.SideA, m.SideB) + credit := DefaultWinCreditSide(m.Status, m.Decision, m.DecisionBy, m.Attribution()) + return TeamResultFrom(m.SubResults, m.SideA, m.SideB, credit) } // MarshalJSON mirrors MatchResult.MarshalJSON for bracket (elimination) diff --git a/internal/state/team_result_test.go b/internal/state/team_result_test.go index d1546f225..04c9e967c 100644 --- a/internal/state/team_result_test.go +++ b/internal/state/team_result_test.go @@ -4,14 +4,15 @@ import ( "encoding/json" "testing" + "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTeamResultFrom(t *testing.T) { t.Run("nil for no sub-bouts (individual match)", func(t *testing.T) { - assert.Nil(t, TeamResultFrom(nil, "A", "B")) - assert.Nil(t, TeamResultFrom([]SubMatchResult{}, "A", "B")) + assert.Nil(t, TeamResultFrom(nil, "A", "B", domain.MatchSideNone)) + assert.Nil(t, TeamResultFrom([]SubMatchResult{}, "A", "B", domain.MatchSideNone)) }) t.Run("IV and PW per side, shiro=B aka=A", func(t *testing.T) { @@ -20,7 +21,7 @@ func TestTeamResultFrom(t *testing.T) { {Position: 1, Winner: "TeamA", SideA: "P3", SideB: "P4", IpponsA: []string{"M", "K"}, IpponsB: []string{"M"}}, {Position: 2, Winner: "TeamB", SideA: "P5", SideB: "P6", IpponsA: []string{"M"}, IpponsB: []string{}}, } - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) // IV: B(shiro)=2, A(aka)=1. PW: shiro=2+1+0=3, aka=1+2+1=4. assert.Equal(t, &TeamResultLine{ShiroIV: 2, AkaIV: 1, ShiroPW: 3, AkaPW: 4}, got) @@ -31,7 +32,7 @@ func TestTeamResultFrom(t *testing.T) { {Position: 0, Winner: "TeamB", SideA: "P1", SideB: "P2", IpponsA: []string{"M"}, IpponsB: []string{"M", "K"}}, {Position: -1, Winner: "TeamA", SideA: "P3", SideB: "P4", IpponsA: []string{"M", "K"}, IpponsB: []string{}}, } - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) // Only position 0 counts: shiroIV=1, akaIV=0, shiroPW=2, akaPW=1. assert.Equal(t, &TeamResultLine{ShiroIV: 1, AkaIV: 0, ShiroPW: 2, AkaPW: 1}, got) }) @@ -46,7 +47,7 @@ func TestTeamResultFrom(t *testing.T) { {Position: 0, Winner: "TeamB", SideA: "P1", SideB: "P2", IpponsA: []string{"M"}, IpponsB: []string{"M"}}, {Position: -2, Winner: "TeamA", SideA: "P3", SideB: "P4", IpponsA: []string{"M", "K"}, IpponsB: []string{"K"}}, } - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) // Only position 0 counts; the -2 row is skipped like the daihyosen. assert.Equal(t, &TeamResultLine{ShiroIV: 1, AkaIV: 0, ShiroPW: 1, AkaPW: 1}, got) }) @@ -57,7 +58,7 @@ func TestTeamResultFrom(t *testing.T) { subs := []SubMatchResult{ {Position: -1, Winner: "TeamA", SideA: "P1", SideB: "P2", IpponsA: []string{"M"}, IpponsB: []string{}}, } - assert.Nil(t, TeamResultFrom(subs, "TeamA", "TeamB")) + assert.Nil(t, TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone)) }) t.Run("placeholder plus real bout counts the real bout", func(t *testing.T) { @@ -65,7 +66,7 @@ func TestTeamResultFrom(t *testing.T) { {Position: -1, Winner: "TeamA", SideA: "P1", SideB: "P2", IpponsA: []string{"M"}, IpponsB: []string{}}, {Position: 0, Winner: "TeamB", SideA: "P3", SideB: "P4", IpponsA: []string{}, IpponsB: []string{"K"}}, } - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) // Placeholder skipped: shiroIV=1, akaIV=0, shiroPW=1, akaPW=0. assert.Equal(t, &TeamResultLine{ShiroIV: 1, AkaIV: 0, ShiroPW: 1, AkaPW: 0}, got) @@ -78,7 +79,7 @@ func TestTeamResultFrom(t *testing.T) { subs := []SubMatchResult{ {Position: 0, Winner: "", SideA: "P1", SideB: "P2", IpponsA: []string{"M", "•", ""}, IpponsB: []string{"•", "•"}}, } - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) assert.Equal(t, &TeamResultLine{ShiroIV: 0, AkaIV: 0, ShiroPW: 0, AkaPW: 1}, got) }) @@ -89,7 +90,7 @@ func TestTeamResultFrom(t *testing.T) { // Winner carries the sub-level side name, not the match-level team name. {Position: 1, Winner: "P4", SideA: "P3", SideB: "P4", IpponsA: []string{}, IpponsB: []string{"K"}}, } - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) assert.Equal(t, &TeamResultLine{ShiroIV: 1, AkaIV: 0, ShiroPW: 2, AkaPW: 1}, got) }) } @@ -203,7 +204,7 @@ func TestTeamResultFrom_SameNameBout(t *testing.T) { SideAMemberID: "m-aka", SideBMemberID: "m-shiro", WinnerMemberID: "m-shiro", IpponsB: []string{"M", "K"}, }} - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) assert.Equal(t, 1, got.ShiroIV, "the member id names shiro as the winner") assert.Equal(t, 0, got.AkaIV, "aka must not take it on name order") @@ -215,7 +216,7 @@ func TestTeamResultFrom_SameNameBout(t *testing.T) { SideAMemberID: "m-aka", SideBMemberID: "m-shiro", WinnerMemberID: "m-aka", IpponsA: []string{"M", "K"}, }} - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) assert.Equal(t, 1, got.AkaIV) assert.Equal(t, 0, got.ShiroIV) @@ -229,7 +230,7 @@ func TestTeamResultFrom_SameNameBout(t *testing.T) { Position: 1, SideA: "Yamada", SideB: "Yamada", Winner: "Yamada", IpponsA: []string{"M"}, IpponsB: []string{"M", "K"}, }} - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) assert.Equal(t, 0, got.AkaIV, "aka-first on a same-name bout is the coin flip this removes") assert.Equal(t, 0, got.ShiroIV) @@ -245,7 +246,7 @@ func TestTeamResultFrom_SameNameBout(t *testing.T) { SideAMemberID: "m-a", SideBMemberID: "m-b", WinnerMemberID: "m-gone", IpponsB: []string{"M"}, }} - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) assert.Equal(t, 1, got.ShiroIV) assert.Equal(t, 0, got.AkaIV) @@ -256,7 +257,7 @@ func TestTeamResultFrom_SameNameBout(t *testing.T) { Position: 1, SideA: "Sato", SideB: "Ito", Winner: "Sato", IpponsA: []string{"M"}, }} - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) assert.Equal(t, 1, got.AkaIV) assert.Equal(t, 0, got.ShiroIV) @@ -269,8 +270,260 @@ func TestTeamResultFrom_SameNameBout(t *testing.T) { Position: 0, SideA: "TeamA", SideB: "TeamB", Winner: "TeamB", IpponsB: []string{"M"}, }} - got := TeamResultFrom(subs, "TeamA", "TeamB") + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) require.NotNil(t, got) assert.Equal(t, 1, got.ShiroIV) }) } + +// TestDefaultWinCreditSide pins the bc-tmfn follow-up rule: which side a +// default-win ruling (any kiken, fusenpai, or fusensho) closing a match +// credits every unfought numbered bout to. +func TestDefaultWinCreditSide(t *testing.T) { + completedAtt := domain.WinnerAttribution{Winner: "TeamB", SideA: "TeamA", SideB: "TeamB"} + + t.Run("not completed credits nobody, whatever the decision", func(t *testing.T) { + got := DefaultWinCreditSide(MatchStatusRunning, "kiken-voluntary", "aka", completedAtt) + assert.Equal(t, domain.MatchSideNone, got) + }) + + t.Run("completed but not a default-win decision credits nobody", func(t *testing.T) { + for _, d := range []string{"", "fought", "hikiwake", "daihyosen", "kachinuki-exhaustion", "ippon-shobu"} { + got := DefaultWinCreditSide(MatchStatusCompleted, d, "aka", completedAtt) + assert.Equal(t, domain.MatchSideNone, got, "decision %q", d) + } + }) + + t.Run("decisionBy aka: SideA withdrew, SideB (shiro) is credited", func(t *testing.T) { + for _, d := range []string{"kiken-voluntary", "kiken-injury", "kiken", "fusenpai", "fusensho"} { + got := DefaultWinCreditSide(MatchStatusCompleted, d, "aka", domain.WinnerAttribution{}) + assert.Equal(t, domain.MatchSideB, got, "decision %q", d) + } + }) + + t.Run("decisionBy shiro: SideB withdrew, SideA (aka) is credited", func(t *testing.T) { + for _, d := range []string{"kiken-voluntary", "kiken-injury", "kiken", "fusenpai", "fusensho"} { + got := DefaultWinCreditSide(MatchStatusCompleted, d, "shiro", domain.WinnerAttribution{}) + assert.Equal(t, domain.MatchSideA, got, "decision %q", d) + } + }) + + t.Run("empty decisionBy (legacy) falls back to the match's own winner attribution, ids first", func(t *testing.T) { + att := domain.WinnerAttribution{ + WinnerID: "id-b", SideAID: "id-a", SideBID: "id-b", + Winner: "TeamA", SideA: "TeamA", SideB: "TeamB", // names disagree with the ids on purpose + } + got := DefaultWinCreditSide(MatchStatusCompleted, "fusensho", "", att) + assert.Equal(t, domain.MatchSideB, got, "the id must win over the drifted name") + }) + + t.Run("empty decisionBy, no winner info at all credits nobody", func(t *testing.T) { + got := DefaultWinCreditSide(MatchStatusCompleted, "fusenpai", "", domain.WinnerAttribution{SideA: "TeamA", SideB: "TeamB"}) + assert.Equal(t, domain.MatchSideNone, got) + }) +} + +// TestSubBoutEffectiveResult pins SubBoutEffectiveResult's three-way split: +// a bout with its own result is untouched, an unfought numbered bout is +// synthesized from the credit, and the daihyosen placeholder is never +// touched. +func TestSubBoutEffectiveResult(t *testing.T) { + t.Run("a fought bout keeps its own result, whatever the credit", func(t *testing.T) { + sub := SubMatchResult{Position: 1, Winner: "P1", SideA: "P1", SideB: "P2", IpponsA: []string{"M"}} + got := SubBoutEffectiveResult(sub, domain.MatchSideB, "TeamA", "TeamB") + assert.Equal(t, sub, got) + }) + + t.Run("a fusensho row entered before the kiken keeps its own result", func(t *testing.T) { + // This bout was decided on its own, per-bout, before the match-level + // kiken was ever recorded: it has its own Winner/Decision/ippons, so + // HasResult() is true and the match-level credit must not override it. + sub := SubMatchResult{Position: 2, Decision: "fusensho", Winner: "TeamB", IpponsB: []string{"○", "○"}} + require.True(t, sub.HasResult()) + got := SubBoutEffectiveResult(sub, domain.MatchSideA, "TeamA", "TeamB") + assert.Equal(t, sub, got, "the credit (TeamA) must not override this row's own fusensho (TeamB)") + }) + + t.Run("no credit leaves an unfought bout untouched", func(t *testing.T) { + sub := SubMatchResult{Position: 3} + got := SubBoutEffectiveResult(sub, domain.MatchSideNone, "TeamA", "TeamB") + assert.Equal(t, sub, got) + }) + + t.Run("credit A synthesizes the maru for the unfought bout", func(t *testing.T) { + sub := SubMatchResult{Position: 2} + got := SubBoutEffectiveResult(sub, domain.MatchSideA, "TeamA", "TeamB") + assert.Equal(t, "TeamA", got.Winner) + assert.Equal(t, []string{"○", "○"}, got.IpponsA) + assert.Empty(t, got.IpponsB) + assert.Equal(t, "", got.Decision, "no per-row Kiken/Fus. mark; the mark rides the match-level summary") + assert.Equal(t, 2, got.Position) + }) + + t.Run("credit B synthesizes the maru for the unfought bout", func(t *testing.T) { + sub := SubMatchResult{Position: 1} + got := SubBoutEffectiveResult(sub, domain.MatchSideB, "TeamA", "TeamB") + assert.Equal(t, "TeamB", got.Winner) + assert.Equal(t, []string{"○", "○"}, got.IpponsB) + assert.Empty(t, got.IpponsA) + }) + + t.Run("the daihyosen placeholder is never synthesized, whatever the credit", func(t *testing.T) { + sub := SubMatchResult{Position: DaihyosenSubPosition} + got := SubBoutEffectiveResult(sub, domain.MatchSideA, "TeamA", "TeamB") + assert.Equal(t, sub, got) + }) + + t.Run("position 0 is never synthesized either", func(t *testing.T) { + sub := SubMatchResult{Position: 0} + got := SubBoutEffectiveResult(sub, domain.MatchSideA, "TeamA", "TeamB") + assert.Equal(t, sub, got) + }) +} + +// TestPadDefaultWinBoutPositions pins the append-only, position-only padding +// rule. +func TestPadDefaultWinBoutPositions(t *testing.T) { + t.Run("empty input pads every position 1..teamSize", func(t *testing.T) { + got := PadDefaultWinBoutPositions(nil, 3) + require.Len(t, got, 3) + for i, want := range []int{1, 2, 3} { + assert.Equal(t, want, got[i].Position) + assert.False(t, got[i].HasResult()) + } + }) + + t.Run("only missing positions are appended; existing rows are untouched, output ordered by Position", func(t *testing.T) { + existing := []SubMatchResult{ + {Position: 1, Winner: "P1", IpponsA: []string{"M"}}, + {Position: 3, Winner: "P3", IpponsA: []string{"K"}}, + } + got := PadDefaultWinBoutPositions(existing, 3) + require.Len(t, got, 3) + // bc-cse finding 12: the output is ordered by Position ascending + // (1, 2, 3), not append order (1, 3, then the padded 2 tacked on at + // the end) -- TeamScoreboard reads a bout's lineup name by its ARRAY + // INDEX, so an out-of-position array put the wrong fighter's name on + // a scored row. + assert.Equal(t, existing[0], got[0], "position 1 is untouched") + assert.Equal(t, SubMatchResult{Position: 2}, got[1], "the missing position 2 is appended IN ORDER, not at the end") + assert.Equal(t, existing[1], got[2], "position 3 is untouched") + }) + + t.Run("the daihyosen row (position < 1) is never padded, never counted as present for a real position, and sorts AFTER every numbered position", func(t *testing.T) { + got := PadDefaultWinBoutPositions([]SubMatchResult{{Position: DaihyosenSubPosition, Winner: "P1"}}, 2) + require.Len(t, got, 3, "the daihyosen row plus positions 1 and 2 padded") + // bc-cse finding 12: never a plain Position sort of the whole slice, + // which would put -1 first; the daihyosen row keeps its place AFTER + // the numbered block instead. + assert.Equal(t, 1, got[0].Position) + assert.Equal(t, 2, got[1].Position) + assert.Equal(t, DaihyosenSubPosition, got[2].Position) + assert.Equal(t, "P1", got[2].Winner, "the daihyosen row's own data is untouched, only moved") + }) + + t.Run("bc-cse finding 12: a single stored row at position 2 of 5 is ordered 1..5, not [2,1,3,4,5]", func(t *testing.T) { + existing := []SubMatchResult{ + {Position: 2, Winner: "P2", IpponsA: []string{"M", "K"}}, + } + got := PadDefaultWinBoutPositions(existing, 5) + require.Len(t, got, 5) + for i, want := range []int{1, 2, 3, 4, 5} { + assert.Equal(t, want, got[i].Position, "row %d", i) + } + assert.Equal(t, existing[0], got[1], "position 2's own recorded row lands at array index 1, not index 0") + for i, idx := range []int{0, 2, 3, 4} { + assert.False(t, got[idx].HasResult(), "position %d is a placeholder", i) + } + }) + + t.Run("bc-cse finding 12: a stored numbered row plus a daihyosen row -- the DH row moves after the numbered block", func(t *testing.T) { + existing := []SubMatchResult{ + {Position: DaihyosenSubPosition, Winner: "TeamA"}, + {Position: 2, Winner: "P2", IpponsA: []string{"M", "K"}}, + } + got := PadDefaultWinBoutPositions(existing, 3) + require.Len(t, got, 4, "positions 1..3 plus the daihyosen row") + assert.Equal(t, []int{1, 2, 3, DaihyosenSubPosition}, []int{got[0].Position, got[1].Position, got[2].Position, got[3].Position}) + assert.Equal(t, existing[1], got[1], "position 2's own recorded row is untouched, only reordered") + assert.Equal(t, existing[0], got[3], "the daihyosen row's own data is untouched, only moved to the end") + }) + + t.Run("teamSize 0 pads nothing", func(t *testing.T) { + existing := []SubMatchResult{{Position: 1, Winner: "P1"}} + got := PadDefaultWinBoutPositions(existing, 0) + assert.Equal(t, existing, got) + }) + + t.Run("already fully present is a no-op: no padding needed means no reordering either", func(t *testing.T) { + existing := []SubMatchResult{{Position: 1}, {Position: 2}} + got := PadDefaultWinBoutPositions(existing, 2) + assert.Equal(t, existing, got) + }) +} + +// TestTeamResultFrom_DefaultWinCredit pins TeamResultFrom's crediting +// behaviour end to end: a match decided before any bout was fought (every +// position padded) and a match decided after one bout was fought (the rest +// padded) produce the SAME teamResult when the fought bout's own score +// matches what the maru would have given; a running match credits nothing; +// the daihyosen row is never credited. +func TestTeamResultFrom_DefaultWinCredit(t *testing.T) { + t.Run("decided before any bout: every position padded and credited", func(t *testing.T) { + subs := PadDefaultWinBoutPositions(nil, 3) + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideA) + require.NotNil(t, got) + assert.Equal(t, &TeamResultLine{AkaIV: 3, AkaPW: 6}, got) + }) + + t.Run("decided after bout 1 was fought (a clean 2-0 win for the same side): same teamResult", func(t *testing.T) { + subs := []SubMatchResult{ + {Position: 1, Winner: "TeamA", IpponsA: []string{"M", "K"}}, + } + subs = PadDefaultWinBoutPositions(subs, 3) + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideA) + require.NotNil(t, got) + assert.Equal(t, &TeamResultLine{AkaIV: 3, AkaPW: 6}, got, + "1 real 2-0 win + 2 credited default wins == 3 credited default wins on points and victories alike") + }) + + t.Run("kiken on the other side flips the credit", func(t *testing.T) { + subs := PadDefaultWinBoutPositions(nil, 2) + gotA := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideA) + gotB := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideB) + assert.Equal(t, &TeamResultLine{AkaIV: 2, AkaPW: 4}, gotA) + assert.Equal(t, &TeamResultLine{ShiroIV: 2, ShiroPW: 4}, gotB) + }) + + t.Run("a running (reopened) match credits nothing: unfought bouts stay uncounted", func(t *testing.T) { + subs := []SubMatchResult{{Position: 1}, {Position: 2}} + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideNone) + // Two real (non-daihyosen) positions exist, so hasBout is true and the + // line is non-nil -- just all zero, since neither bout has a result + // and there is no credit to fill the gap. + require.NotNil(t, got) + assert.Equal(t, &TeamResultLine{}, got, "no countable result on either bout") + }) + + t.Run("a fusensho row entered before the kiken keeps its own result", func(t *testing.T) { + subs := []SubMatchResult{ + {Position: 1, Decision: "fusensho", Winner: "TeamB", IpponsB: []string{"○", "○"}}, + {Position: 2}, + } + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideA) + require.NotNil(t, got) + // Bout 1: TeamB's own fusensho, unaffected by the TeamA credit. + // Bout 2: unfought, credited to TeamA. + assert.Equal(t, &TeamResultLine{ShiroIV: 1, ShiroPW: 2, AkaIV: 1, AkaPW: 2}, got) + }) + + t.Run("the daihyosen row is ignored, credit or no", func(t *testing.T) { + subs := []SubMatchResult{ + {Position: DaihyosenSubPosition, Winner: "TeamA", IpponsA: []string{"M"}}, + {Position: 1}, + } + got := TeamResultFrom(subs, "TeamA", "TeamB", domain.MatchSideB) + require.NotNil(t, got) + assert.Equal(t, &TeamResultLine{ShiroIV: 1, ShiroPW: 2}, got, "only position 1 counts, credited to TeamB") + }) +} diff --git a/internal/state/testdata/bracket_v1.1.0_knockout9.json b/internal/state/testdata/bracket_v1.1.0_knockout9.json new file mode 100644 index 000000000..de8828204 --- /dev/null +++ b/internal/state/testdata/bracket_v1.1.0_knockout9.json @@ -0,0 +1,247 @@ +{ + "rounds": [ + [ + { + "id": "m-r1-0", + "sideA": "Player01", + "sideB": "Player02", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:00", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 2, + "displayRound": 3, + "feeders": [ + "", + "" + ] + }, + { + "id": "m-r1-1", + "sideA": "Player03", + "sideB": "Player04", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:05", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 3, + "displayRound": 3, + "feeders": [ + "", + "" + ] + }, + { + "id": "m-r1-2", + "sideA": "", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:10", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r1-3", + "sideA": "", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:10", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r1-4", + "sideA": "Player05", + "sideB": "Player06", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:10", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 5, + "displayRound": 3, + "feeders": [ + "", + "" + ] + }, + { + "id": "m-r1-5", + "sideA": "", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:15", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r1-6", + "sideA": "Player07", + "sideB": "", + "winner": "Player07", + "status": "completed", + "court": "A", + "scheduledAt": "09:15", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r1-7", + "sideA": "Player08", + "sideB": "Player09", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:15", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 1, + "displayRound": 4, + "feeders": [ + "", + "" + ] + } + ], + [ + { + "id": "m-r2-0", + "sideA": "Winner of r4-m0", + "sideB": "Winner of r4-m1", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:20", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 6, + "displayRound": 2, + "feeders": [ + "m-r1-0", + "m-r1-1" + ] + }, + { + "id": "m-r2-1", + "sideA": "", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:25", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r2-2", + "sideA": "Winner of r4-m4", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:25", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r2-3", + "sideA": "Player07", + "sideB": "Winner of r4-m7", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:25", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 4, + "displayRound": 3, + "feeders": [ + "", + "m-r1-7" + ] + } + ], + [ + { + "id": "m-r3-0", + "sideA": "Winner of r3-m0", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:30", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r3-1", + "sideA": "Winner of r3-m2", + "sideB": "Winner of r3-m3", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:30", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 7, + "displayRound": 2, + "feeders": [ + "m-r1-4", + "m-r2-3" + ] + } + ], + [ + { + "id": "m-r4-0", + "sideA": "Winner of r2-m0", + "sideB": "Winner of r2-m1", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:35", + "scoreA": "", + "scoreB": "", + "isOverridden": false, + "matchNumber": 8, + "displayRound": 1, + "feeders": [ + "m-r2-0", + "m-r3-1" + ] + } + ] + ] +} \ No newline at end of file diff --git a/internal/state/testdata/bracket_v2.1.0_knockout5.json b/internal/state/testdata/bracket_v2.1.0_knockout5.json new file mode 100644 index 000000000..3eebdce56 --- /dev/null +++ b/internal/state/testdata/bracket_v2.1.0_knockout5.json @@ -0,0 +1,129 @@ +{ + "rounds": [ + [ + { + "id": "m-r1-0", + "sideA": "P1", + "sideB": "P2", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:00", + "sideAId": "dffe615e-c3af-4ae6-9592-620a6e409310", + "sideBId": "04c2b84c-2ac8-4a91-9ee7-e5f32d21934e", + "isOverridden": false, + "matchNumber": 1, + "displayRound": 3, + "feeders": [ + "", + "" + ] + }, + { + "id": "m-r1-1", + "sideA": "", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:05", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r1-2", + "sideA": "P3", + "sideB": "", + "winner": "P3", + "status": "completed", + "court": "A", + "scheduledAt": "09:05", + "sideAId": "d2bcc7a9-0254-474f-9bbe-016464d5a925", + "winnerId": "d2bcc7a9-0254-474f-9bbe-016464d5a925", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r1-3", + "sideA": "P4", + "sideB": "P5", + "winner": "P4", + "status": "completed", + "court": "A", + "scheduledAt": "09:05", + "sideAId": "41d48317-34bf-405a-9ec8-43f0e6547072", + "sideBId": "3c11f5d1-0503-4393-8e95-feee34ecd61c", + "winnerId": "41d48317-34bf-405a-9ec8-43f0e6547072", + "ipponsA": [ + "M", + "K" + ], + "isOverridden": false, + "matchNumber": 2, + "resultSource": "admin", + "displayRound": 3, + "feeders": [ + "", + "" + ], + "modifiedAt": 1790337238130 + } + ], + [ + { + "id": "m-r2-0", + "sideA": "Winner of r3-m0", + "sideB": "", + "winner": "", + "status": "completed", + "court": "A", + "scheduledAt": "09:10", + "isOverridden": false, + "hidden": true + }, + { + "id": "m-r2-1", + "sideA": "P3", + "sideB": "P4", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:10", + "sideAId": "d2bcc7a9-0254-474f-9bbe-016464d5a925", + "sideBId": "41d48317-34bf-405a-9ec8-43f0e6547072", + "isOverridden": false, + "matchNumber": 3, + "displayRound": 2, + "feeders": [ + "", + "m-r1-3" + ] + } + ], + [ + { + "id": "m-r3-0", + "sideA": "Winner of r2-m0", + "sideB": "Winner of r2-m1", + "winner": "", + "status": "scheduled", + "court": "A", + "scheduledAt": "09:15", + "isOverridden": false, + "matchNumber": 4, + "displayRound": 1, + "feeders": [ + "m-r1-0", + "m-r2-1" + ] + } + ] + ], + "drawOrder": [ + "dffe615e-c3af-4ae6-9592-620a6e409310", + "04c2b84c-2ac8-4a91-9ee7-e5f32d21934e", + "d2bcc7a9-0254-474f-9bbe-016464d5a925", + "41d48317-34bf-405a-9ec8-43f0e6547072", + "3c11f5d1-0503-4393-8e95-feee34ecd61c" + ] +} \ No newline at end of file diff --git a/internal/state/testdata/participants_v1.1.0_knockout9.csv b/internal/state/testdata/participants_v1.1.0_knockout9.csv new file mode 100644 index 000000000..df41d4679 --- /dev/null +++ b/internal/state/testdata/participants_v1.1.0_knockout9.csv @@ -0,0 +1,9 @@ +61f84cfd-791c-455f-b57a-94fcaf820d87,Player01,Dojo0 +9077befa-fac1-435b-a138-52845441b7f3,Player02,Dojo1 +3fe0817f-8129-4ad5-824f-a163016b3bad,Player03,Dojo2 +cbee6e7d-21d4-4f74-bd6a-394c356ef03d,Player04,Dojo3 +97609530-619b-4909-a64e-3b73b18fecfc,Player05,Dojo4 +b67ced27-609e-493c-b86d-ffacc6ee0ee4,Player06,Dojo5 +f14c1749-7dad-4186-ad31-bd674595fb8d,Player07,Dojo6 +eb0e30d1-27f2-4dab-9701-305544e26b0c,Player08,Dojo0 +d6a6676b-8e9d-48f8-9c09-fa9955199a29,Player09,Dojo1 diff --git a/internal/state/transactions.go b/internal/state/transactions.go index 5b0dad49e..cefe24c35 100644 --- a/internal/state/transactions.go +++ b/internal/state/transactions.go @@ -77,6 +77,17 @@ import ( // helpers for any other ID would perform unlocked I/O. var ErrMismatchedTxCompID = errors.New("compID does not match transaction's competition") +// ErrTxCommitted marks a WithTransaction error returned AFTER the +// transaction's WAL was committed: its Apply then failed part-way. Such a +// transaction is not dropped. The next startup replays the WAL, so its +// staged writes land. Every other WithTransaction error means nothing was +// committed. The difference matters to a caller that did work OUTSIDE the +// WAL inside fn (overrides.json, which serializes on the store-wide lock, see +// engine.OverridePoolRanks) and undoes it when the transaction fails: undoing +// it after a commit would leave the replayed files describing a change the +// undone file no longer holds. Test with errors.Is. +var ErrTxCommitted = errors.New("transaction committed; its writes are replayed on restart") + // StoreTx is the transactional handle passed to fn in WithTransaction. // Methods mirror the corresponding *Store methods but DO NOT re-acquire // the per-competition lock, that's already held by WithTransaction. @@ -179,10 +190,11 @@ type StoreTx interface { // call sites below, and the package header above, which has always // documented it this way. // -// This is load-bearing, not incidental: the mp-e2k1 guard inside -// RecordMatchResultWithIneligibilityTx re-reads pool-matches.csv via -// computeStandingsFrom AFTER staging the forward write, to compare the -// qualifying finishers before and after. If that read fell through to +// This is load-bearing, not incidental: the pool requalification check +// inside RecordMatchResultWithIneligibilityTx (engine/pool_requalify.go) +// re-reads pool-matches.csv via computeStandingsFrom AFTER staging the +// forward write, to compare the corrected finishers with the knockout slots +// they feed. If that read fell through to // disk it would compute post-write standings from pre-write bytes, // which is the stale-standings class the cache-invalidation notes // describe. The K3 rollback likewise re-enters UpdatePoolMatchByID @@ -236,8 +248,10 @@ func (s *Store) WithTransaction(compID string, fn func(tx StoreTx) error) error // disk; the next Store.NewStore startup will replay it. // Surface the error so the caller can react (e.g., HTTP // 500), the next process startup is what guarantees - // completion. - return fmt.Errorf("WithTransaction %q: Apply: %w", compID, err) + // completion. Marked ErrTxCommitted so a caller holding work + // done outside the WAL can tell this from a dropped + // transaction. + return fmt.Errorf("WithTransaction %q: Apply: %w (%w)", compID, err, ErrTxCommitted) } // Cache reconciliation. In WAL mode, the savers populated the diff --git a/internal/state/transactions_test.go b/internal/state/transactions_test.go index a46c37fc3..718510918 100644 --- a/internal/state/transactions_test.go +++ b/internal/state/transactions_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -1518,3 +1519,66 @@ func TestStoreTx_PendingPaths(t *testing.T) { "UpdateBracket pending-path must propagate mutate error") }) } + +// A transaction whose Apply fails AFTER its WAL committed is not dropped: the +// next startup replays it. Its error therefore carries ErrTxCommitted, so a +// caller that did work outside the WAL (engine.OverridePoolRanks' +// overrides.json) keeps that work for the replay instead of undoing it. A +// Commit that fails commits nothing and carries no such mark. +func TestWithTransaction_ApplyFailureAfterCommitIsMarked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod 0500 isn't enforced on Windows the same way") + } + if os.Getuid() == 0 { + t.Skip("Skipping permission test: root bypasses file permission restrictions") + } + dir := t.TempDir() + store, err := NewStore(dir) + require.NoError(t, err) + compID := "apply-fails" + require.NoError(t, store.SaveCompetition(&Competition{ID: compID, Name: "Apply Fails"})) + compDir := store.compPath(compID) + walDir := filepath.Join(dir, ".wal") + bracket := func(id string) *Bracket { + return &Bracket{Rounds: [][]BracketMatch{{{ID: id, SideA: "A", SideB: "B", Status: MatchStatusScheduled}}}} + } + walCount := func() int { + entries, rerr := os.ReadDir(walDir) + require.NoError(t, rerr) + return len(entries) + } + + // Commit writes into .wal/, which stays writable; Apply writes into the + // competition's own directory, which no longer is. + before := walCount() + err = store.WithTransaction(compID, func(tx StoreTx) error { + if serr := tx.SaveBracket(compID, bracket("m-applied-on-replay")); serr != nil { + return serr + } + return os.Chmod(compDir, 0500) + }) + require.NoError(t, os.Chmod(compDir, 0700)) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTxCommitted) + assert.Contains(t, err.Error(), "Apply:") + assert.Equal(t, before+1, walCount(), "the committed WAL stays on disk for the replay") + + restarted, err := NewStore(dir) + require.NoError(t, err) + got, err := restarted.LoadBracket(compID) + require.NoError(t, err) + require.NotNil(t, got, "the replay lands the committed transaction") + assert.Equal(t, "m-applied-on-replay", got.Rounds[0][0].ID) + + // A Commit that fails leaves nothing to replay. + err = restarted.WithTransaction(compID, func(tx StoreTx) error { + if serr := tx.SaveBracket(compID, bracket("m-never-committed")); serr != nil { + return serr + } + return os.Chmod(walDir, 0500) + }) + require.NoError(t, os.Chmod(walDir, 0700)) + require.Error(t, err) + assert.Contains(t, err.Error(), "Commit:") + assert.NotErrorIs(t, err, ErrTxCommitted) +} diff --git a/knockout-example-large-seeded.xlsx b/knockout-example-large-seeded.xlsx index aeee8b355..b619b186d 100644 Binary files a/knockout-example-large-seeded.xlsx and b/knockout-example-large-seeded.xlsx differ diff --git a/knockout-example-large.xlsx b/knockout-example-large.xlsx index 78688fdcd..d1af7b1b8 100644 Binary files a/knockout-example-large.xlsx and b/knockout-example-large.xlsx differ diff --git a/knockout-example-small.xlsx b/knockout-example-small.xlsx index fdd3e7cb6..1cef743fa 100644 Binary files a/knockout-example-small.xlsx and b/knockout-example-small.xlsx differ diff --git a/pools-example-large-seeded.xlsx b/pools-example-large-seeded.xlsx index b20f34364..e766459b8 100644 Binary files a/pools-example-large-seeded.xlsx and b/pools-example-large-seeded.xlsx differ diff --git a/pools-example-large-teams.xlsx b/pools-example-large-teams.xlsx index ebdb8055c..ac5db73f7 100644 Binary files a/pools-example-large-teams.xlsx and b/pools-example-large-teams.xlsx differ diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md index eec15a3cf..311a3ebae 100644 --- a/scripts/screenshots/README.md +++ b/scripts/screenshots/README.md @@ -3,7 +3,7 @@ Regenerates every screenshot and video the documentation embeds, by driving the real application in a browser. - make docs/screenshots # the 30 application screenshots + make docs/screenshots # the 31 application screenshots make docs/videos # the 3 application videos make docs/media # both @@ -15,7 +15,7 @@ real application in a browser. Screenshots and videos are split because you rarely want both at once. Neither is especially slow: measured on one machine, the three videos take about 60 -seconds and the 30 screenshots about 175. A video is recorded in real time and +seconds and the screenshots about 175 (measured when there were 30). A video is recorded in real time and paced for a human to follow, so it is the more expensive of the two per clip, but there are only three of them. @@ -186,7 +186,7 @@ both are load-bearing: Chromium's text rasterisation is pinned at launch animations finished and the text caret hidden. They do different jobs: - The rasteriser flags fix **which** rendering you get, not whether it repeats. - Drop them and all 30 captures change, because the committed images are this + Drop them and every capture changes, because the committed images are this configuration's output. Run-to-run drift without them is one capture at one grey level, so they are not what makes a run reproduce. - The caret and animation settings are what makes a run reproduce. Turn them diff --git a/scripts/screenshots/recipes/editors.mjs b/scripts/screenshots/recipes/editors.mjs index f318e2aa5..111af0d34 100644 --- a/scripts/screenshots/recipes/editors.mjs +++ b/scripts/screenshots/recipes/editors.mjs @@ -447,73 +447,6 @@ export const recipes = [ await page.locator('[data-testid="kachinuki-reopen-button"]').waitFor({ state: 'visible', timeout: 15000 }); }, }, - { - // Ending a REOPENED encounter by decision: the server refuses a - // finalization with no justification, so the prompt makes the reason box - // mandatory (admin_scoring_shared.jsx:637-694). - name: 'decision-reason-after-reopen', - family: 'editors', - // The prompt is as wide as the modal's body allows (modal - 2x14px), and - // the modal is min(760px, 96vw) (styles.css:5881), so its width is chosen - // by the viewport: 671 puts the committed 617px crop on the pixel. - viewport: { width: 671, height: 1100 }, - capture: { selector: '.decision-prompt' }, - auth: 'admin', - drive: async ({ page, base, fixture }) => { - await openScoreEditorRow(page, base, fixture.teams.a, fixture.teams.b); - const reopen = page.locator('[data-testid="kachinuki-reopen-button"]'); - if (await reopen.count()) { - // One tap, no prompt: the audit reason is owed on the way OUT - // (admin_scoring_team.jsx:3726-3744). Reopening closes the modal. - await reopen.first().click(); - await page.waitForTimeout(1500); - await openScoreEditorRow(page, base, fixture.teams.a, fixture.teams.b); - } - // admin_scoring_team.jsx:3481 - the decision controls live behind a - //
disclosure. - const disclosure = page.locator(EDITOR).locator('details.decision-disclosure').first(); - if (!(await disclosure.evaluate((d) => d.open))) { - await disclosure.locator('summary').click(); - await page.waitForTimeout(400); - } - await page.locator('[data-testid="scoring-modal-fusenpai-button"]').first().click(); - await page.locator('.decision-prompt').waitFor({ state: 'visible', timeout: 10000 }); - await page.waitForTimeout(400); - }, - assert: async ({ page }) => { - // The point of this shot is the MANDATORY reason, which only a reopened - // match asks for. Without it the prompt still renders, two rows shorter. - const note = page.locator('.decision-prompt', { hasText: 'This match was reopened' }); - if (!(await note.count())) { - throw new Error('the decision prompt is not asking for a reason: this encounter is ' - + 'not reopened. These recipes run in order against one fixture - run the family.'); - } - }, - }, - { - // The other way out of a reopen: End match asks for the audit reason - // instead of arming, and its Confirm IS the commit - // (admin_scoring_team.jsx:3825-3827). - name: 'kachinuki-reopen-reason', - family: 'editors', - // Same arithmetic as decision-reason-after-reopen: 693 gives the - // committed 638px crop. - viewport: { width: 693, height: 1100 }, - capture: { selector: '.reason-prompt' }, - auth: 'admin', - drive: async ({ page, base, fixture }) => { - await openScoreEditorRow(page, base, fixture.teams.a, fixture.teams.b); - // ONE tap: on a reopened encounter the prompt REPLACES the arm step. - await page.locator('[data-testid="kachinuki-end-match-button"]').first().click(); - try { - await page.locator('.reason-prompt').waitFor({ state: 'visible', timeout: 6000 }); - } catch { - throw new Error('End match armed instead of asking for a reason, so this encounter ' - + 'is not reopened. These recipes run in order against one fixture - run the family.'); - } - await page.waitForTimeout(400); - }, - }, { // The court console after a court has run dry: every match in the queue is // complete and each offers Correct. diff --git a/scripts/screenshots/recipes/index.mjs b/scripts/screenshots/recipes/index.mjs index 3a4b2493d..3940b6e9a 100644 --- a/scripts/screenshots/recipes/index.mjs +++ b/scripts/screenshots/recipes/index.mjs @@ -15,6 +15,7 @@ import * as publicViews from './public.mjs'; import * as videos from './videos.mjs'; import * as editors from './editors.mjs'; import * as scored from './scored.mjs'; +import * as knockout from './knockout.mjs'; // Each module paired with its file name, because a module cannot name its own // file and the SINCE scoping (lib/scope.mjs) needs to know which recipe files @@ -28,6 +29,7 @@ const groups = [ [videos, 'videos.mjs'], [editors, 'editors.mjs'], [scored, 'scored.mjs'], + [knockout, 'knockout.mjs'], ]; // family -> Set of repo-relative recipe file paths that touch it. diff --git a/scripts/screenshots/recipes/knockout.mjs b/scripts/screenshots/recipes/knockout.mjs new file mode 100644 index 000000000..fa1752a63 --- /dev/null +++ b/scripts/screenshots/recipes/knockout.mjs @@ -0,0 +1,226 @@ +// Knockout bracket captures: the admin Bracket page and the public Bracket tab. +// +// Two families, because the two subjects need different fixtures and neither +// should pay for the other's seed: +// +// knockoutDraw five competitors, draw generated and started, nothing scored, +// captured on the admin Bracket page. The subject is the SHAPE +// the draw produces: one first-round bout, three competitors +// who start a round later, and a pair that meets for the first +// time in round two. +// knockoutPlay ten competitors, the first-round bouts and two +// quarterfinals scored, so winners have moved on and the tree +// visibly fills in; both of its captures are the public +// Bracket tab (knockout-bracket-in-play says why the admin page +// cannot frame this tree). The results are entered by DRIVING THE +// SCORE EDITOR, not over HTTP, for the reason lib/api.mjs gives: +// a result written over the API is not what an operator +// produces, and a capture must show state the application made. +// +// Neither family claims web-mobile/js/bracket.jsx, although every capture here +// renders it. That module also exports the score-string and middle-mark +// primitives the encho, court-board and standings captures render, and a claim +// NARROWS a change to the claiming families (lib/scope.mjs): claiming it would +// let a bracket.jsx edit skip those captures. Left unclaimed, a change to it +// selects every family, these two included. +import fs from 'node:fs'; +import path from 'node:path'; +import { settle, withAdminPage } from '../lib/ui.mjs'; +import { EDITOR, finishMatch, startMatch } from '../lib/editor.mjs'; +import { SCORE_EDITOR_SOURCES, VIEWER_SOURCES } from '../lib/scope.mjs'; + +// Competition ids at module level so each recipe's `route` can name them +// (run.mjs concatenates `base + route` when the recipe is declared). +const FIVE_ID = 'open-knockout'; +const TEN_ID = 'mens-knockout'; + +const FIVE = [ + ['Kenta Morita', 'Mumeishi'], ['Laura Schmidt', 'Berlin Kenyu'], ['Tom Harris', 'Hizen Dojo'], + ['Sofia Bianchi', 'Milano Kendo'], ['Daichi Ono', 'Mumeishi'], +].map(([name, dojo]) => ({ name, dojo })); + +const TEN = [ + ['Hiroshi Kondo', 'Kenshinkan'], ['James Carter', 'Thames Dojo'], ['Anna Fischer', 'Berlin Kenyu'], + ['Mateo Garcia', 'Madrid Dojo'], ['Ren Aoki', 'Seishinkan'], ['Oliver Grant', 'Hizen Dojo'], + ['Lucas Martin', 'Paris Dojo'], ['Takumi Endo', 'Kenshinkan'], ['Erik Lindqvist', 'Nordic Kendo'], + ['Sam Whitfield', 'Thames Dojo'], +].map(([name, dojo]) => ({ name, dojo })); + +// How many results knockoutPlay records, in schedule order. A knockout is +// scheduled in match-number order, deepest round first, so this is the two +// first-round bouts (M1, M2) and the first two quarterfinals (M3, M4), whose +// winners then meet in a semifinal with both sides known. +const PLAYED = 4; + +// An ippon button is one CHARACTER (M/K/D/T/H), so match it exactly. +function ipponButton(scope, waza) { + return scope.locator('button.ipt-btn').filter({ hasText: new RegExp(`^${waza}$`) }).first(); +} + +// One bout: the winner takes two ippons, and every other bout the loser takes +// one first, so the tree carries a mix of 2-0 and 2-1 scorelines. The loser's +// point goes in FIRST: a side reaching two ippons decides the bout and the +// editor then disables the other side's buttons. +async function scoreBout(editor, page, i) { + const winner = i % 2 === 0 ? 'aka' : 'shiro'; + const loser = winner === 'aka' ? 'shiro' : 'aka'; + if (i % 2 === 1) { + await ipponButton(editor.locator(`.sb-side--${loser}`), 'K').click(); + await settle(page, 100); + } + const win = editor.locator(`.sb-side--${winner}`); + await ipponButton(win, i % 3 === 0 ? 'M' : 'K').click(); + await settle(page, 100); + await ipponButton(win, i % 3 === 1 ? 'D' : 'M').click(); + await settle(page, 100); +} + +// Open the competition's first scorable match and play `count` of them through +// ONE editor: every "Finish + Start Next" completes the open match and starts +// the next one on the same shiaijo, so the editor never has to be reopened from +// the list beneath its backdrop. The last Finish starts the next match too, +// which leaves it running: the capture shows a bracket mid-play. +async function playInOrder(page, base, compId, count) { + await page.goto(`${base}/admin/competition/${compId}/scores`, { waitUntil: 'domcontentloaded' }); + const row = page.locator('.score-edit-row') + .filter({ has: page.locator('button', { hasText: /^Score$/ }) }).first(); + await row.waitFor({ state: 'visible', timeout: 15000 }); + await row.locator('button').filter({ hasText: /^Score$/ }).first().click(); + const editor = page.locator(EDITOR).first(); + await editor.waitFor({ state: 'visible', timeout: 15000 }); + await startMatch(page); + for (let i = 0; i < count; i += 1) { + await scoreBout(editor, page, i); + await finishMatch(page); + } +} + +// The real bouts of a bracket.json: a structural bye is hidden or one-sided, +// and advances its competitor without a result, so it is not a bout. +function bouts(dataDir, compId) { + const p = path.join(dataDir, 'competitions', compId, 'bracket.json'); + if (!fs.existsSync(p)) throw new Error(`${compId}: no bracket.json`); + const { rounds = [] } = JSON.parse(fs.readFileSync(p, 'utf8')); + return rounds.flat().filter((m) => m && !m.hidden && m.sideA && m.sideB); +} + +// Refuse a fixture whose results carry no points: the scorelines are half of +// what the in-play captures show, and a completed match with no ippons renders +// a bare winner. +function assertPlayed(dataDir, compId) { + const completed = bouts(dataDir, compId).filter((m) => m.status === 'completed'); + if (completed.length < PLAYED) { + throw new Error(`${compId}: ${completed.length} bracket matches completed, expected ${PLAYED}`); + } + const bare = completed.filter((m) => !(m.ipponsA || []).length && !(m.ipponsB || []).length); + if (bare.length) { + throw new Error(`${compId}: ${bare.length} completed match(es) carry no ippons, so their ` + + 'scores would render blank'); + } +} + +// The admin crop is the bracket card, which scrolls sideways when the tree is +// wider than it. An element screenshot of a scrolled box silently drops the +// columns past its right edge, so refuse to take one. +async function assertBracketFits(page) { + const { scroll, client } = await page.locator('.bracket-canvas').first() + .evaluate((el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); + if (scroll > client) { + throw new Error(`the bracket card is ${client}px wide but its tree needs ${scroll}px, ` + + 'so the capture would cut off its right-hand rounds'); + } +} + +export const families = { + knockoutDraw: { + server: 'mobile', + // SINCE scoping inputs (lib/scope.mjs): the admin competition page and its + // Bracket section (admin_competition_bracket.jsx). bracket.jsx is left + // unclaimed on purpose; see the note at the top of this file. + sources: ['web-mobile/js/admin_competition'], + seed: async ({ api }) => { + await api.tournament({ name: 'Autumn Taikai 2026', date: '10-10-2026', courts: ['A'] }); + await api.competition(FIVE_ID, 'Open Knockout', { + format: 'knockout', courts: ['A'], date: '10-10-2026', numberPrefix: 'K', + }); + await api.participants(FIVE_ID, FIVE); + await api.generateDraw(FIVE_ID); + await api.start(FIVE_ID); + }, + }, + + knockoutPlay: { + server: 'mobile', + // SINCE scoping inputs (lib/scope.mjs): the public competition page whose + // Bracket tab both captures show, and the score editor the seed drives. + sources: [...VIEWER_SOURCES, ...SCORE_EDITOR_SOURCES], + seed: async ({ api, base, browser }) => { + await api.tournament({ name: 'Autumn Taikai 2026', date: '10-10-2026', courts: ['A'] }); + // K, not M: an M-prefixed competitor chip ("M1") reads as a match number + // beside the bracket's own M1/M2 match labels. + await api.competition(TEN_ID, "Men's Knockout", { + format: 'knockout', courts: ['A'], date: '10-10-2026', numberPrefix: 'K', + }); + await api.participants(TEN_ID, TEN); + await api.generateDraw(TEN_ID); + await api.start(TEN_ID); + await withAdminPage(browser, { width: 1600, height: 1000 }, async (page) => { + await playInOrder(page, base, TEN_ID, PLAYED); + }); + }, + }, +}; + +// The tree renders once unpositioned, measures its cards, then re-renders with +// every column absolutely placed and the connectors drawn. This class is the +// second render; waiting on anything earlier photographs the first. +const TREE_READY = '.bc-round-matches--abs'; + +export const recipes = [ + { + // The admin Bracket page, cropped to the bracket card. The scoring panel + // sits below the card, so the card takes the whole content width. + name: 'knockout-bracket-five', + family: 'knockoutDraw', + route: `/admin/competition/${FIVE_ID}/bracket`, + viewport: { width: 1200, height: 900 }, + capture: { selector: '.bracket-canvas' }, + auth: 'admin', + waitFor: TREE_READY, + drive: async ({ page }) => { + await settle(page, 400); + await assertBracketFits(page); + }, + }, + { + // The PUBLIC Bracket tab at a desktop width: a draw part-way through, as + // spectators follow it (the tab widens its shell to 1440px, so a + // sixteen-slot tree shows whole). 1265 is the width of + // mobile-pool-standings, the image this sits beside. + name: 'knockout-bracket-in-play', + family: 'knockoutPlay', + route: `/competition/${TEN_ID}/bracket`, + viewport: { width: 1265, height: 900 }, + capture: 'fullPage', + waitFor: TREE_READY, + drive: async ({ page }) => { + await settle(page, 400); + await assertBracketFits(page); + }, + assert: ({ dataDir }) => { assertPlayed(dataDir, TEN_ID); }, + }, + { + // The same Bracket tab at the width of the spectator page's other viewer + // captures. Here the tree is wider than the page and scrolls sideways + // behind the tab's right-edge fade, which is how a phone shows it; the tab + // opens scrolled to the running match. + name: 'viewer-bracket', + family: 'knockoutPlay', + route: `/competition/${TEN_ID}/bracket`, + viewport: { width: 815, height: 900 }, + capture: 'fullPage', + waitFor: TREE_READY, + drive: async ({ page }) => { await settle(page, 400); }, + assert: ({ dataDir }) => { assertPlayed(dataDir, TEN_ID); }, + }, +]; diff --git a/scripts/screenshots/recipes/videos.mjs b/scripts/screenshots/recipes/videos.mjs index a7fae4218..c9b66e4cf 100644 --- a/scripts/screenshots/recipes/videos.mjs +++ b/scripts/screenshots/recipes/videos.mjs @@ -336,26 +336,14 @@ export const recipes = [ await page.waitForTimeout(ms); }; const endMatch = async () => { - // Ending is normally a two-tap guard: the first tap arms the button, - // the second commits. A REOPENED encounter is different - there the - // audit-reason prompt REPLACES the arm step and appears on the first - // tap (admin_scoring_team.jsx:3825), so a blind second tap finds no - // button. Branch on which of the two happened rather than swallowing - // the failure: a clip whose encounter never ends is the wrong clip. + // Ending is a two-tap guard: the first tap arms the button, the + // second commits. A reopened encounter ends the same way: it asks for + // no reason. const b = page.locator('[data-testid="kachinuki-end-match-button"]'); - const conf = page.locator('button:has-text("Confirm")'); await b.click({ timeout: 5000 }); await page.waitForTimeout(650); - if (!(await conf.count())) { - await b.click({ timeout: 5000 }); - await page.waitForTimeout(900); - } - if (await conf.count()) { - await conf.first().click(); - await page.waitForTimeout(1300); - } else { - await page.waitForTimeout(600); - } + await b.click({ timeout: 5000 }); + await page.waitForTimeout(1500); }; // 1: winner stays on. Every fought bout reads "vs" in its centre. @@ -381,12 +369,12 @@ export const recipes = [ await endMatch(); // 4: a completed encounter reopens with its bouts intact, then ends - // again through the reason prompt. + // again. mark('P4 reopen'); await openScore(ko); await page.waitForTimeout(1200); - await click('[data-testid="kachinuki-reopen-button"]', 1600); // closes the modal - await openScore(ko); // reopen the now-running match + // The editor stays open: the encounter flips to running in place. + await click('[data-testid="kachinuki-reopen-button"]', 1600); await page.waitForTimeout(900); await endMatch(); mark('end'); diff --git a/scripts/screenshots/run.mjs b/scripts/screenshots/run.mjs index 2ec47ff73..3157f461e 100644 --- a/scripts/screenshots/run.mjs +++ b/scripts/screenshots/run.mjs @@ -50,9 +50,9 @@ const args = Object.fromEntries(process.argv.slice(2).map((token) => { // Chromium's text rasterisation follows the HOST's font and colour // configuration, so these pin it to one instead: greyscale antialiasing, no // hinting, sRGB. Load-bearing rather than tidy-up - removing them changes every -// one of the 30 captures (measured), so the committed images ARE this -// configuration's output and a host rendering any other way reports all 30 as -// changed. +// capture (measured when there were 30), so the committed images ARE this +// configuration's output and a host rendering any other way reports every +// capture as changed. const DETERMINISTIC_RENDERING = [ '--disable-lcd-text', '--disable-font-subpixel-positioning', @@ -108,7 +108,7 @@ function scoped(list, since) { // docs/screenshots holds this harness's own output: an unchanged surface // reproduces the committed file to within the tolerance png.mjs sets. So // "unchanged" means there is nothing to eyeball and nothing to copy, which is -// what makes a 30-capture run reviewable. Dimensions are reported only when the +// what makes a full run reviewable. Dimensions are reported only when the // pixels DID move, where they distinguish a layout change from a change in the // pixels inside it. function reportStill(recipe, file) { @@ -286,7 +286,7 @@ async function main() { const results = []; // Record a capture as failed and remove its output from an EARLIER run. The - // console FAILED line scrolls past in a 33-recipe run, and contributing.md + // console FAILED line scrolls past in a 31-recipe run, and contributing.md // tells the operator to copy what is in out/ across to docs/ - so a stale // image left behind is one they would copy believing it fresh. const fail = (recipe, why) => { @@ -383,7 +383,7 @@ async function main() { console.log(`\n${results.length} captured, ${failed.length} failed`); console.log(`${unchanged.length} unchanged, ${changed.length} changed, ${videos.length} video (not compared)`); // The point of the content compare: name the short list, so the operator - // reviews and copies those rather than re-checking all 30 by eye. + // reviews and copies those rather than re-checking every capture by eye. if (changed.length) { console.log('\nchanged - eyeball these, then copy them over docs/screenshots/:'); for (const [name, v] of changed) console.log(` ${name}: ${v.note}`); diff --git a/specs/007-ekc-draw/spec.md b/specs/007-ekc-draw/spec.md index 6ea98765b..2f80d70b6 100644 --- a/specs/007-ekc-draw/spec.md +++ b/specs/007-ekc-draw/spec.md @@ -128,19 +128,25 @@ cell (2026 Men F9) is deliberately flipped against the page because the 2025 and sheets are mutually inconsistent at that position (see the fixture comment). Round-1 pairings and byes below are transcribed exactly as recorded in the bead. They -are the observed sheet layout, not a derivation. +are the observed sheet layout, not a derivation. The Junior Individual Male table was +corrected on 2026-09-25 against the PDF itself (page 4): the bead had put a five-pool +court's second pair in the first column. ### A. Junior Individual Male 18 pools, **1 qualifier** per pool. Courts: **A**(1-5) **B**(6-10) **C**(11-14) **D**(15-18). -| Region | Home pools | Round-1 | +| Region | Home pools | First bouts | |---|---|---| -| Court A | 1, 2, 3, 4, 5 | **P1 byes**, P2 v P3, P4 v P5 | -| Court B | 6, 7, 8, 9, 10 | **P6 byes**, P7 v P8, P9 v P10 | -| Court C | 11, 12, 13, 14 | P11 v P12, P13 v P14 | -| Court D | 15, 16, 17, 18 | P15 v P16, P17 v P18 | +| Court A | 1, 2, 3, 4, 5 | P2 v P3 in the first column; **P1 byes** to meet its winner, level with P4 v P5 | +| Court B | 6, 7, 8, 9, 10 | P7 v P8 in the first column; **P6 byes** to meet its winner, level with P9 v P10 | +| Court C | 11, 12, 13, 14 | P11 v P12, P13 v P14 (second column) | +| Court D | 15, 16, 17, 18 | P15 v P16, P17 v P18 (second column) | + +The sheet's first column holds only P2 v P3 and P7 v P8. Every other first bout, +courts C and D included, is printed one column later, the column before each court's +final. **Demonstrates:** @@ -680,6 +686,22 @@ unextrapolated here; the per-pool phase landed as crossedBigBlockSlots/crossedHa went from NO byes to the sheet's 8, two per court in the h1/h2, h1/h3 pattern); `bracket_match_numbers.json` regenerated, the SPA's JS mirror suite passing. +**Superseded (2026-09-25): the second pass below misread the sheet and has been +reversed.** The 2026 Junior Male sheet (PDF page 4) prints P2 v P3 alone in court A's +first column and P4 v P5 in the second, level with P1 v Winner F1; court B prints P7 v +P8 and P9 v P10 the same way, and the 2025 Men Team five-team courts (page 14) agree. +Root-distance classification was right all along. `TraverseRounds` and +`BuildEliminationMatchRounds` again place every bout by its distance from the final, +the `Node.risen*` marks are slot geometry only (`SlotArray` and the tree pages' leaf +offsets), `applySlotDisplayRounds` and `helper.SlotRoundMatches` are deleted, and a +match's DisplayRound comes from its stored feeders (`state.Bracket. +StampRoundsFromFeeders`), both when the draw is generated and when a stored bracket +is loaded, so a bracket drawn while the second pass was in force (v2.0.0, v2.1.0) is +corrected on load. The parity suites (`match_numbering_parity_test.go`, +`excel_draw_parity_test.go`) pin the app's rounds and numbers to the printed workbook. +The paragraph and bullets below are kept as the record of what was tried; do not +restore them. + **The phantom-column divergence is FIXED (2026-08-18, second pass).** The 2026 Junior Male sheet prints its phantom-risen pair (F2, P4 v P5) in the ROUND-1 column; our root-distance classification scheduled it a round late, on every surface. The collapse @@ -950,8 +972,13 @@ already granted a bye count other than `q mod 2` before the pool set was subdivi half-blocks), and 61 do now. Per BLOCK the count is exact in both: 0 of 696. **Derivation.** EKC male court A has `q = 5` in an 8-slot region, so 3 empty slots. The -sheet shows **P1 bye, P2 v P3, P4 v P5**: two round-1 matches and ONE named bye, with -the other two empties consumed by a phantom pair and W(P4vP5) taking a round-2 bye. The +sheet shows **P1 bye, P2 v P3, P4 v P5**: ONE named bye (P1, paired with the winner of +P2 v P3), with the other two empties consumed by a phantom pair beside P4 v P5. +*Corrected 2026-09-25 against the PDF (page 4):* this paragraph first read the sheet +as two round-1 matches, but P4 v P5 is printed a column later, level with P1 v W(P2 v +P3), so P2 v P3 is the court's only first-column bout. Read that way, the columns alone +no longer separate the two constructions below: both put P4 v P5 and P1 v W(P2 v P3) +level. The alternative construction, "pad to `NextPow2` and spread the empties", would have produced **three** named byes and one round-1 match, which is not what the sheet shows. All five observed occupancies (`q` = 1, 2, 3, 4, 5 across the three draws) are diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 2a8289a0a..3333a08e6 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1652,10 +1652,17 @@ paths: tags: [competitions] summary: Override pool rank description: > - Manually set a player's rank within a pool, overriding the computed - value. Used, among other callers, by the chusen (drawing-lots) - resolver (see `GET .../chusen-candidates`) to record a team's - finishing position once the operator has drawn lots. + Manually set one or more players' ranks within a pool, overriding the + computed value. Used, among other callers, by the chusen + (drawing-lots) resolver (see `GET .../chusen-candidates`) to record + the finishing order once the operator has drawn lots. Send either + `playerId` and `rank` (one player) or `ranks` (several players), never + both. A `ranks` group is recorded as ONE change: every rank lands + together, the knockout check below answers for the final order only, + and a refusal records none of them. Record a chusen's whole order as + one group, since setting its ranks one at a time passes through + orders nobody chose, which can name, and reopen, a knockout match + whose place the final order keeps. security: - TournamentPassword: [] parameters: @@ -1671,14 +1678,13 @@ paths: application/json: schema: type: object - required: [playerId, rank] properties: playerId: type: string description: > - Required. A pool-rank override is a record resolved by id - only (operator ruling bc-pnum): playerId must name a - competitor actually in this pool. There is no + Required unless `ranks` is sent. A pool-rank override is a + record resolved by id only (operator ruling bc-pnum): + playerId must name a competitor actually in this pool. There is no playerName/playerDojo narrowing left to select with two pool members who share a display name from different dojos (competitor identity is (name, dojo), never bare @@ -1693,16 +1699,69 @@ paths: rank: type: integer description: > - 1-based finishing rank within the pool; rejected if it - exceeds the pool's player count. + Required unless `ranks` is sent. 1-based finishing rank + within the pool; rejected if it exceeds the pool's player + count. + ranks: + type: array + minItems: 1 + description: > + The group form, sent instead of `playerId` and `rank`: + one entry per player, each checked as the single form is. + No player and no rank may appear twice. + items: + type: object + required: [playerId, rank] + properties: + playerId: + type: string + description: > + A competitor in this pool, by id only. + rank: + type: integer + description: > + 1-based finishing rank within the pool; rejected if + it exceeds the pool's player count. + forceDownstreamReopen: + type: boolean + description: > + The operator's confirmation of a 409 + `downstream_knockout_played` refusal: records the rank(s) + and reopens the named knockout match(es) with the new + qualifier seated. Never gets past + `downstream_knockout_running`. responses: '200': - description: Rank override saved. + description: > + Rank override saved. In a mixed competition a new order that + moves who holds one of the pool's qualifying places repaints the + knockout slots that place feeds; `reopenedMatches` names the + knockout matches a confirmed override reopened (empty otherwise). + content: + application/json: + schema: + type: object + properties: + reopenedMatches: + type: array + items: + type: object + properties: + id: + type: string + number: + type: integer + label: + type: string + description: > + The operator's name for the match, shown as is: its number and knockout round, e.g. `Match 3 (Final)`; `knockout Match 3` when the bracket records no round, `the 3rd-place match` for the bronze, the id when it has no number. '400': description: > playerId is missing, blank, or does not name a competitor in this pool; or rank is not a positive integer, exceeds the pool's - player count, or exceeds the absolute override cap. + player count, or exceeds the absolute override cap. For `ranks`, + the same checks per entry, plus: a player or a rank given twice, + or `ranks` sent together with `playerId` or `rank`. content: application/json: schema: @@ -1716,7 +1775,49 @@ paths: schema: $ref: '#/components/schemas/Error' '409': - description: Rank overrides are only accepted while the competition is in the pools stage. + description: > + Rank overrides are only accepted while the competition is in the + pools stage, or, for a mixed competition, in the knockout stage + too (`Error`). In a mixed competition the new order is + also answered like a pool result correction, with `matchId` + empty because no match is corrected: + `downstream_knockout_played` when it moves a qualifier out of a + knockout match the old one already fought (retry with + `forceDownstreamReopen: true`), and `downstream_knockout_running` + (`runningMatches`, not confirmable) when such a match is being + fought now. A refused override is not recorded (for `ranks`, + none of the group is). + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/DownstreamKnockoutPlayedError' + - type: object + required: [error] + properties: + error: + type: string + enum: [downstream_knockout_running] + matchId: + type: string + runningMatches: + type: array + items: + type: object + properties: + id: + type: string + number: + type: integer + label: + type: string + description: > + The operator's name for the match, shown as is: its number and knockout round, e.g. `Match 3 (Final)`; `knockout Match 3` when the bracket records no round, `the 3rd-place match` for the bronze, the id when it has no number. + message: + type: string + '422': + description: overrides.json is corrupt (`corrupt_overrides`); repair it with `DELETE .../overrides`. content: application/json: schema: @@ -1736,24 +1837,41 @@ paths: `PUT .../pools/{poolId}/override-rank`. Only consequential ties (ones that affect advancement/seeding) are returned. Admin-gated, unlike the public league-tiebreak candidates endpoint. Empty (not an error) - for a non-team competition, a competition not in the pools stage, or - when no group currently needs chusen. + for a non-team competition, a competition not in the pools stage (a + mixed competition's knockout stage counts, since a pool correction + made then can reopen a tie), or when no group currently needs chusen. + `recorded` lists the ties a chusen already settled (every tied team + carries a recorded rank), with those ranks, so a chusen recorded in + the wrong order can be changed through the same + `PUT .../override-rank`. It follows the same visibility rules as + `candidates`. security: - TournamentPassword: [] parameters: - $ref: '#/components/parameters/CompetitionId' responses: '200': - description: Still-tied groups needing an operator lots-draw. + description: > + Still-tied groups needing an operator lots-draw, and the groups a + recorded chusen already settled. content: application/json: schema: type: object + required: [candidates, recorded] properties: candidates: type: array items: $ref: '#/components/schemas/ChusenCandidateGroup' + recorded: + type: array + description: > + Ties a recorded chusen settled. Each group's `teams` are + in the recorded order and `ranks` carries each team's + recorded finishing position. + items: + $ref: '#/components/schemas/ChusenCandidateGroup' '404': $ref: '#/components/responses/NotFound' '500': @@ -2199,26 +2317,64 @@ paths: required: [stale] '400': description: | - Invalid payload, or a completion that rewrites a result the - operator had already declared final without justifying it. The - latter needs a non-empty `correctionReason` in two cases: - - the match is already `completed` (a correction); - - the match carries `reopenPending`, i.e. it was reopened via - `POST .../reopen` without a reason. Its stored status is - `running`, so the write looks like a first completion, but the - finalized result it replaces was discarded by that reopen. A - successful completion records the reason and clears - `reopenPending`. + Invalid payload, or a correction of a match that is already + `completed` without a non-empty `correctionReason`. A match + reopened via `POST .../reopen` without a reason (`reopenPending`) + is NOT refused for one when it is completed again: a match can be + reopened without any reason, and nothing is gated on that. The + completion clears `reopenPending` and keeps a `correctionReason` + if one is sent. + + A `completed` write on a team match (not kachinuki) is also + refused while a numbered bout (1..teamSize) has no result in + `subResults`: no winner, decision, ippon, foul or encho. A bout + missing from the payload has no result. Every position is gated, + whatever the lineups name: a position neither team fields is + recorded as a Tie (`decision` hikiwake). A withdrawal + (`decision` kiken or fusenpai) is not gated. The + `error` names the bouts, e.g. "Bout 5 (Taisho) has no result. + Record a score, a Tie, or a Fusensho before finishing." + + A `completed` write whose `decision` is empty or `hikiwake` on a + match whose stored result is a completed withdrawal (kiken or + fusenpai) is a score sheet's correction, which cannot state a + decision: it keeps the withdrawal. It is not gated; its + `subResults`, `correctionReason` and stamp are saved; the recorded + withdrawal (`decision`, `decisionBy`, `decisionReason`, `winner`, + `winnerId`, `encho`) is kept as stored; and so is the winner's + default-win scoreline. On a single-bout match (no `subResults`) + the withdrawing side's `ipponsA`/`ipponsB` are taken from the write + (only real struck letters; an omitted side keeps the stored + letters), so a correction to the points struck before the + withdrawal is saved; a team write keeps the match-level scoreline + as stored. The competitor's eligibility is left untouched. To move + the withdrawal to the other side, use POST /decision; to clear a + withdrawal recorded by mistake, use POST .../reopen. + + Any other `decision` (e.g. `fought`) on a write that lands over a + stored withdrawal REPLACES it, and the finish gate above applies + to it. Every competitor-status entry that match recorded is + restored to eligible, whatever the withdrawal was (kiken-voluntary + included), and the restore is broadcast as + `competitor_status_updated`. A kiken or fusenpai naming the OTHER + side moves the withdrawal, as POST /decision does: the new + withdrawer is made ineligible and only the competitor the first + entry barred is restored. content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: | - Conflict; one of six codes returned in the `error` field: + Conflict; one of seven codes returned in the `error` field: - `court_busy`: court already has a running match in this or another competition. - Extra fields: `court`, `matchId`, `compId`, `message`. - - `ineligible_competitor`: a participant is ineligible (kiken/fusenpai in a prior match). + Extra fields: `court`, `matchId`, `compId`, `label`, `message`. + - `ineligible_competitor`: a participant is ineligible (kiken/fusenpai in a prior match, or barred by + a decision recorded on a different match). `reasonHuman` is a full operator sentence naming the + competitor, the match, and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight + again. Record the default win for Carol."; when BOTH sides of a knockout match are barred (the + only case a knockout cannot resolve with a draw), it instead reads "Neither Alice nor Bob can + fight Pool A · Match 1: correct the earlier withdrawal or the draw." Extra fields: `playerId`, `reason`, `reasonHuman`. - `already_ineligible`: participant was already marked ineligible before this match. Extra fields: `playerId`, `matchId`, `reason`, `reasonHuman`. @@ -2226,8 +2382,23 @@ paths: Extra fields: `message`. - `side_mismatch`: the submitted winner does not match either side of the match. Extra fields: `message`. - - `downstream_knockout_scored`: a downstream elimination match was already scored. - Extra fields: `pool`, `finisher`, `matchId`, `message`. + - `downstream_knockout_played`: the write would change who a + knockout match that has a result of its own was fought by: a + knockout correction changing a winner already propagated into + the next round, or a pool correction in a mixed competition + moving a qualifying place whose holder has already fought in the + knockout. Nothing is written. Retry with + `forceDownstreamReopen: true` to apply it and reopen the named + matches, with the new qualifier seated. Same body as + DownstreamKnockoutPlayedError; `qualifierChange` names the pool + places that move. + - `downstream_knockout_running`: a pool correction in a mixed + competition would move a qualifier out of a knockout match being + fought now. Nothing is written, and `forceDownstreamReopen` does + not get past it: finish that match or send it back to the queue, + then save again. Extra fields: `matchId` (this match), + `runningMatches` (`[{id, number, label}]`), `message` (the operator's + sentence). content: application/json: schema: @@ -2242,20 +2413,27 @@ paths: - already_ineligible - result_finalized - side_mismatch - - downstream_knockout_scored + - downstream_knockout_played + - downstream_knockout_running description: Machine-readable conflict code. court: type: string description: Court label (court_busy only). matchId: type: string - description: Conflicting match ID (court_busy, already_ineligible, downstream_knockout_scored). + description: Conflicting match ID (court_busy, already_ineligible); this match (downstream_knockout_played, downstream_knockout_running). compId: type: string description: Conflicting competition ID (court_busy only). + label: + type: string + description: > + The conflicting match's operator label (court_busy + only), e.g. `Pool A · Match 1`; see + `engine.OperatorMatchLabel`. message: type: string - description: Human-readable detail (court_busy, result_finalized, side_mismatch, downstream_knockout_scored). + description: Human-readable detail (court_busy, result_finalized, side_mismatch, downstream_knockout_played, downstream_knockout_running). playerId: type: string description: Ineligible participant ID (ineligible_competitor / already_ineligible). @@ -2265,17 +2443,25 @@ paths: reasonHuman: type: string description: Human-readable reason (ineligible_competitor / already_ineligible). - pool: - type: string - description: Pool ID containing the scored downstream match (downstream_knockout_scored only). - finisher: - type: string - description: Name of the pool finisher whose bracket placement is locked (downstream_knockout_scored only). + runningMatches: + type: array + description: The knockout matches being fought (downstream_knockout_running only). + items: + type: object + properties: + id: + type: string + number: + type: integer + label: + type: string + description: > + The operator's name for the match, shown as is: its number and knockout round, e.g. `Match 3 (Final)`; `knockout Match 3` when the bracket records no round, `the 3rd-place match` for the bronze, the id when it has no number. '422': description: | `corrupt_overrides`: this competition's overrides.json could not - be parsed, so pool standings (needed to check whether the write - would displace an already-scored bracket qualifier) cannot be + be parsed, so pool standings (needed to check whether a pool write + moves a qualifier the knockout has already seated) cannot be computed. This is a terminal, operator-recoverable state, not a transient failure: repair it via the reset-overrides endpoint (which resets overrides WITHOUT first parsing the corrupt file), @@ -2330,33 +2516,72 @@ paths: /api/competitions/{id}/matches/{mid}/reopen: post: tags: [matches] - summary: Reopen a completed kachinuki match + summary: Reopen a completed kachinuki match, or clear a withdrawal description: | - Sanctioned "Reopen match" for a COMPLETED kachinuki team match: - status returns to `running`, the match-level winner/decision is - cleared, and the bout log (subResults) is kept, so the operator can - add more bouts and end the match again. Kachinuki competitions only; - for every other competition type the correction path (a completed - write with a `correctionReason`) remains the sole sanctioned edit of - a finished result. For a bracket match whose winner was already - propagated but not yet fought, the downstream slot is retracted to - its "Winner of rX-mY" generation placeholder. Reopening a finalized - result is an organiser action: the route stays main-password-gated - even in self-run mode (same class as override-winner), so a request - without a valid `X-Tournament-Password` gets 401. Broadcasts a - `match_updated` event. - - Discarding a finalized result is audited, but NOT at this step: + Sanctioned "Reopen match" for a COMPLETED match that is either a + kachinuki team match, or any match (team or individual, pool or + knockout) whose stored `decision` is a withdrawal or default win + (kiken of any kind, fusenpai, or fusensho). Status returns to + `running` -- EXCEPT a match-level `fusensho` whose `decisionBy` side + is STILL barred by a decision recorded on a DIFFERENT match, which + returns to `scheduled` instead: going straight to `running` would + strand the operator behind the eligibility gate with no way to + start it. The identical fusensho reopens to `running` once that + competitor is reinstated. The match-level verdict + (winner, decision, default-win scoreline) is cleared, and what was + fought is kept: the bout log (subResults), or on a single-bout match + (an individual match, a pool daihyosen or tiebreaker bout) the letters + the withdrawing side had struck, the fouls, the overtime and, on a + team rep bout, who fought it (repPlayerA/B). The winner's letters from + before the withdrawal are not kept: recording the withdrawal replaced + them with the default-win maru, which the reopen clears. The operator + then scores the rest and completes the match through the score + endpoint. The reopen stamps the match's `modifiedAt` with the server's + time, so a write stamped before it (an offline-queued save replayed + afterwards) is refused as `superseded` rather than completing the + reopened match again. + This is how a withdrawal recorded by mistake is cleared: a withdrawal + gives the opponent the win by default, so without it the match was + never decided. For every other completed match the correction path (a + completed write with a `correctionReason`) remains the sole sanctioned + edit of a finished result. Reopening a finalized result is an + organiser action: the route stays main-password-gated even in self-run + mode (same class as override-winner), so a request without a valid + `X-Tournament-Password` gets 401. Broadcasts a `match_updated` event. + + Reopening a match a withdrawal (kiken, fusenpai) ended REMOVES the + withdrawal: every competitor-status entry that match recorded is + restored to eligible, in the same transaction as the reopen, and the + restore is broadcast as `competitor_status_updated`. + + Knockout matches: a winner already propagated into a next-round slot + that has not been fought is retracted to its "Winner of rX-mY" + generation placeholder (and a semifinal's loser is taken back out of + the bronze match). When that next match, or the bronze match, has + already been completed with a result of its own, the reopen is + refused with `downstream_knockout_played` naming it; a retry with + `forceDownstreamReopen: true` reopens that match too (back to + `scheduled`, its result and bouts cleared, to be fought again), and + the 200 response lists it in `reopenedMatches` with a `match_updated` + event for each. When a withdrawal had decided that reopened match, it + goes with its verdict: the competitor it barred is restored to + eligible and a `competitor_status_updated` event is broadcast, as for + the match being reopened. A next match that is in progress, or that a bye + resolved off this winner, is refused outright. A pool match is never + refused for the knockout its pool feeds: reopening it moves no + knockout slot. The write that finishes it is checked instead: the same + result is saved silently, and a different one that moves a qualifier + who has already fought in the knockout answers + `downstream_knockout_played` with `qualifierChange` (see PUT + .../score). + `reason` is optional and the request body may be omitted entirely, so reopening is one tap for an operator who ended a match by mistake at a - shiaijo. When no reason is given the match is flagged `reopenPending` - and the justification is collected on the next completion instead: a - `PUT .../score` that completes a `reopenPending` match is rejected with - 400 unless it carries a `correctionReason`, which then clears the flag. - The audit record is therefore written later than the action it - justifies, never skipped. A `reason` supplied here is recorded - immediately as the match's `correctionReason` and leaves nothing - outstanding (`reopenPending` stays false). + shiaijo, and ending the match again asks for no reason either. When no + reason is given the match is flagged `reopenPending`, which the next + completion clears (keeping a reason if that write sends one). A + `reason` supplied here is recorded immediately as the match's + `correctionReason` (`reopenPending` stays false). security: - TournamentPassword: [] parameters: @@ -2376,17 +2601,48 @@ paths: Optional. Why the recorded result is being discarded (e.g. "Ended too early"). Trimmed server-side; absent, empty or whitespace-only all mean "no reason given", which sets - `reopenPending` instead of rejecting the request. Same + `reopenPending`; nothing is refused for it. Same 200-character cap as `decisionReason` / `correctionReason`. + forceDownstreamReopen: + type: boolean + description: > + Optional. The operator's confirmation after a 409 + `downstream_knockout_played`: also reopen the later + knockout match(es) the refusal named. responses: '200': description: Match reopened (status back to `running`). + content: + application/json: + schema: + type: object + properties: + reopenedMatches: + type: array + description: > + Later knockout matches reopened by a confirmed + (`forceDownstreamReopen`) reopen; empty otherwise. + items: + type: object + properties: + id: + type: string + number: + type: integer + description: The match number; 0 when it has none. + label: + type: string + description: > + The operator's name for the match, shown as is: its number and knockout round, e.g. `Match 3 (Final)`; `knockout Match 3` when the bracket records no round, `the 3rd-place match` for the bronze, the id when it has no number. '400': description: > The `reason` is longer than 200 characters, the body is present but - malformed JSON, or the competition is not a kachinuki team - competition. + malformed JSON, or the match is neither a kachinuki team match nor + one a withdrawal or default win decided: "reopen is only for + kachinuki team matches and for matches decided by a withdrawal or + default win (kiken, kiken-injury, fusenpai, or fusensho); correct + other results via the score editor instead." content: application/json: schema: @@ -2404,18 +2660,28 @@ paths: $ref: '#/components/responses/NotFound' '409': description: | - Conflict. Two body shapes, matching the score endpoint's: + Conflict. Four body shapes, matching the score endpoint's where one exists: - Plain sentence in `error`: the match is not completed (only a - completed kachinuki match can be reopened), or a downstream - knockout match fed by this match's winner has already started - or recorded a result. + completed match can be reopened). + - `error: downstream_knockout_played`: a later knockout match fed + by this result has a result of its own. Extra fields: + `matchId` (this match), `blockingMatchId`, `blockingMatches` + (`[{id, number, label}]`, two for a semifinal feeding the final and the + bronze match), `displaced` (the competitor in the slot this match + fed), `message`. Retry with `forceDownstreamReopen: true` to + reopen them too. + - `error: downstream_knockout_running`: a later knockout match fed + by this result is being fought right now, so it cannot be + reopened with it. Nothing is written. Extra fields: `matchId` + (this match), `runningMatches` (`[{id, number, label}]`), + `message`. - `error: court_busy` (machine code, sentence in `message`): another match is already running on this match's court, in this or another competition. Reopening would put a second match in progress on that court, so the operator must finish or requeue the running one first. Extra fields: `court`, `matchId`, `compId`, - `message`. Same shape as the score endpoint's `court_busy`, so - clients keep one branch for it. + `label`, `message`. Same shape as the score endpoint's + `court_busy`, so clients keep one branch for it. content: application/json: schema: @@ -2437,19 +2703,50 @@ paths: compId: type: string description: Competition owning that running match (may differ from this one). + label: + type: string + description: The conflicting match's operator label, e.g. `Pool A · Match 1`. message: type: string description: Human-readable sentence; what clients display. + - $ref: '#/components/schemas/DownstreamKnockoutPlayedError' + - type: object + required: [error] + description: Later knockout match(es) fed by this result are being fought right now. + properties: + error: + type: string + enum: [downstream_knockout_running] + matchId: + type: string + runningMatches: + type: array + items: + type: object + properties: + id: + type: string + number: + type: integer + label: + type: string + description: The operator's name for the match; see `engine.OperatorMatchLabel`. + message: + type: string '500': $ref: '#/components/responses/InternalError' /api/competitions/{id}/matches/{mid}/requeue-blocker-and-reopen: post: tags: [matches] - summary: Atomically requeue a blocking match and reopen a kachinuki match + summary: Atomically requeue a blocking match and reopen a match description: | The atomic remedy for a court_busy reopen. When reopening a finished - kachinuki match ({id}/{mid}, the target) is refused because ANOTHER + match ({id}/{mid}, the target: any match the reopen endpoint accepts + -- including one decided by a match-level default win, fusensho -- + with the same gate and the same `downstream_knockout_played` / + `forceDownstreamReopen` / `downstream_knockout_running` handling, + checked BEFORE the blocker is touched) is refused because ANOTHER match holds its court, this requeues that blocking match AND reopens the target under ONE hold of the court-exclusivity lock, so no other match can take the freed court between the two steps. Because a court @@ -2461,7 +2758,9 @@ paths: raced. On success the target returns to `running` (with `reopenPending` set when no `reason` is given, exactly like reopen) and the blocker returns to `scheduled` with its partial score cleared; a `match_updated` - event is broadcast for BOTH matches. + event is broadcast for BOTH matches. A target a withdrawal ended has + that withdrawal removed and its eligibility restored exactly as on + reopen, broadcast as `competitor_status_updated`. Doubly destructive (it discards the target's finalized result AND the blocker's partial score), so it is main-password-gated in self-run mode @@ -2493,14 +2792,21 @@ paths: reopen endpoint's: absent/empty leaves the target `reopenPending`, a supplied reason is recorded as its `correctionReason`. + forceDownstreamReopen: + type: boolean + description: Optional. As on the reopen endpoint. responses: '200': - description: Blocker requeued and target reopened (both broadcast). + description: > + Blocker requeued and target reopened (both broadcast). Same body as + the reopen endpoint's (`reopenedMatches`). '400': description: > `blockerCompId` is missing or malformed, `blockerMatchId` is missing or too long, the `reason` exceeds 200 characters, the body is - malformed JSON, or the target competition is not kachinuki. + malformed JSON, or the target is neither a kachinuki team match nor + one a withdrawal or default win decided (same message as + `POST .../reopen`'s 400). content: application/json: schema: @@ -2522,10 +2828,15 @@ paths: '409': description: | Conflict. Plain sentence in `error` when the target is not completed, - a downstream knockout fed by the target has been fought, or the - BLOCKER was already completed (so it never held the court as running - and requeuing it is the wrong remedy). Or `error: court_busy` (same - shape as reopen: `court`, `matchId`, `compId`, `message`) when a + or the BLOCKER was already completed (so it never held the court as + running and requeuing it is the wrong remedy). `error: + downstream_knockout_played` (same shape as reopen's) when a later + knockout match has its own result and `forceDownstreamReopen` was + not set; the blocker is left untouched. `error: + downstream_knockout_running` (same shape as reopen's, + `runningMatches`) when a later knockout match fed by the target + is being fought now. Or `error: court_busy` (same shape as reopen: + `court`, `matchId`, `compId`, `label`, `message`) when a match OTHER than the one requeued has since taken the court — the client re-offers the remedy for that new blocker. content: @@ -2545,6 +2856,32 @@ paths: type: string compId: type: string + label: + type: string + description: The conflicting match's operator label, e.g. `Pool A · Match 1`. + message: + type: string + - $ref: '#/components/schemas/DownstreamKnockoutPlayedError' + - type: object + required: [error] + description: Later knockout match(es) fed by the target are being fought right now. + properties: + error: + type: string + enum: [downstream_knockout_running] + matchId: + type: string + runningMatches: + type: array + items: + type: object + properties: + id: + type: string + number: + type: integer + label: + type: string message: type: string '500': @@ -2724,6 +3061,20 @@ paths: Records multiple match results in a single request. Processes each entry independently; partial success is allowed. Returns a count of successful updates and a list of errors for any that failed. + + An entry that would complete a team match (not kachinuki) while a numbered bout + (1..teamSize) has no result is refused by the same rule as PUT /score's 400: no + winner, decision, ippon, foul or encho on that bout, a bout missing from + `subResults` counting as no result, every position gated whatever the lineups name + (a position neither team fields is recorded as a Tie), a withdrawal (`decision` + kiken or fusenpai) not gated, and a + correction whose `decision` is empty or `hikiwake` on a match a withdrawal ended not + gated either (it keeps the withdrawal, as on PUT /score; any other `decision` + replaces the withdrawal, is gated, and restores the competitor's eligibility). The + refusal is reported in `errors` (not as an HTTP 400, since this endpoint is always 200 + with partial success) with that entry's `matchId` and an `error` naming the bouts, e.g. + "Bout 5 (Taisho) has no result. Record a score, a Tie, or a Fusensho before finishing." + Nothing is written for that entry, and other entries are unaffected. security: - TournamentPassword: [] parameters: @@ -2763,9 +3114,9 @@ paths: classes `reason` names below. reason: type: string - enum: [superseded, clock_skew, corrupt_overrides] + enum: [superseded, clock_skew, corrupt_overrides, downstream_knockout_played, downstream_knockout_running] description: > - Machine-readable discriminator, present only for the three + Machine-readable discriminator, present only for the classes named here and omitted for every other failure, so its presence always means the same thing. `superseded`: a newer result for that match is already stored. `clock_skew`: @@ -2780,9 +3131,16 @@ paths: schema; unlike them, this endpoint never fails the whole request over a corrupt overrides file (no terminal 422 the way the single-result endpoints answer with, since bulk-score - is always HTTP 200 with partial success). In all three cases - the entry is excluded from `succeeded` and never broadcast. - Other entries in the batch are unaffected. + is always HTTP 200 with partial success). + `downstream_knockout_played`: the entry would change who a + knockout match with its own result was fought by (see PUT + /score); retry that entry with `forceDownstreamReopen: true` + to apply it and reopen those matches. + `downstream_knockout_running`: the entry is a pool correction + that would move a qualifier out of a knockout match being + fought now; finish or requeue that match, then retry. In + every case the entry is excluded from `succeeded` and never + broadcast. Other entries in the batch are unaffected. /api/competitions/{id}/matches/{mid}/quick-score: put: @@ -2855,11 +3213,23 @@ paths: or representative bout rather than ippon scoring. - A decision FINALIZES the match, so it is subject to the same audit - rule as `/score`: when the match carries `reopenPending` (it was - reopened without a reason), `decisionReason` becomes REQUIRED and the - request is rejected with 400 without it. The supplied reason is - persisted to `correctionReason` and `reopenPending` is cleared. + A decision on a match reopened without a reason (`reopenPending`) + needs no reason either: it clears `reopenPending`, and a + `decisionReason` it carries is also kept as the match's + `correctionReason`. + + + `decision: "hikiwake"` is accepted here ONLY as the one-tap remedy + for a SCHEDULED pool or league match (never a Swiss or knockout + match) whose BOTH sides are already barred by a decision recorded on + a different match: neither can fight, so it is recorded as a draw + with no winner and no new eligibility change. `decisionBy` is not + required for this one shape, unlike every other `decision` value; + any other `hikiwake` request is rejected with 400 ("use /score for + fought/hikiwake"). The equivalent knockout case, where the bracket + needs a winner and a draw cannot stand in, is refused instead with + 409 `ineligible_competitor` naming both sides and pointing at the + earlier withdrawal or the draw. security: - TournamentPassword: [] parameters: @@ -2871,21 +3241,27 @@ paths: application/json: schema: type: object - required: [decision, decisionBy] + required: [decision] properties: decision: type: string - enum: [kiken, kiken-voluntary, kiken-injury, fusenpai, fusensho, daihyosen] + enum: [kiken, kiken-voluntary, kiken-injury, fusenpai, fusensho, hikiwake, daihyosen] decisionBy: type: string enum: [shiro, aka] + description: > + Required for every `decision` except the both-sides-barred + `hikiwake` described above, which needs no side: neither + competitor can fight, so the write names no winner. Same + semantics as `MatchResult.decisionBy`: names the side + that withdrew, did not appear, or is barred, never the + side favoured. decisionReason: type: string maxLength: 200 description: > - Free-text operator note. REQUIRED when the target match has - `reopenPending: true`; optional otherwise. Whitespace-only - does not satisfy the requirement. + Free-text operator note. Optional, a reopened match + included. encho: $ref: '#/components/schemas/EnchoMetadata' modifiedAt: @@ -2917,14 +3293,26 @@ paths: - $ref: '#/components/schemas/RefusedWrite' '400': description: > - Invalid payload, or a missing `decisionReason` on a match that was - reopened without one (`reopenPending: true`). + Invalid payload. content: application/json: schema: $ref: '#/components/schemas/Error' '409': - description: Competitor not eligible (ineligible_competitor). + description: > + Competitor not eligible (`ineligible_competitor`; `reasonHuman` + names the competitor, the match, and what to do, e.g. "Alice + withdrew in Pool A · Match 1 and cannot fight again. Record the + default win for Carol."; the knockout both-sides-barred case + reads "Neither Alice nor Bob can fight Pool A · Match 1: correct + the earlier withdrawal or the draw."), or a decision + that would change who a knockout match with its own result was + fought by (`downstream_knockout_played`, retry with + `forceDownstreamReopen: true`; `qualifierChange` names the pool + places a decision on a mixed competition's pool match moves), or + one that would move a qualifier out of a knockout match being + fought now (`downstream_knockout_running`, not confirmable). Same + bodies as PUT .../score. content: application/json: schema: @@ -2932,8 +3320,8 @@ paths: '422': description: | `corrupt_overrides`: this competition's overrides.json could not - be parsed, so pool standings (needed by the mixed-pool - displaced-finisher guard) cannot be computed. Same + be parsed, so pool standings (needed by the pool + requalification check of a mixed competition) cannot be computed. Same operator-recoverable shape as `PUT .../score`'s 422 response: repair via `DELETE /api/competitions/{id}/overrides`, then resubmit the same decision. @@ -3976,6 +4364,82 @@ components: error: type: string + DownstreamKnockoutPlayedError: + type: object + description: > + 409 body when a write or reopen would change a result already + propagated into a later knockout match that has a result of its own, + or when a pool correction in a mixed competition would move a + qualifying place whose holder has already fought in the knockout + (`qualifierChange` names each place that moves). Retry with + `forceDownstreamReopen: true` to go ahead and reopen the named + match(es) for re-entry, with the new qualifier seated. + required: [error] + properties: + error: + type: string + enum: [downstream_knockout_played] + matchId: + type: string + description: The match being corrected or reopened; empty for a pool-rank override, which corrects none. + blockingMatchId: + type: string + description: The first blocking match (bronze before the next round). + blockingMatches: + type: array + description: Every blocking match, each with the number the operator knows it by. + items: + type: object + properties: + id: + type: string + number: + type: integer + label: + type: string + description: > + The operator's name for the match, shown as is: its number and knockout round, e.g. `Match 3 (Final)`; `knockout Match 3` when the bracket records no round, `the 3rd-place match` for the bronze, the id when it has no number. + displaced: + type: string + description: The competitor in the slot this match fed, when there is one blocking match. + qualifierChange: + type: array + description: > + The pool places a pool correction moves, by rank. Always present; + empty for a knockout correction. + items: + type: object + properties: + pool: + type: string + description: Pool name, e.g. "Pool A". + rank: + type: integer + place: + type: string + description: The rank as the operator reads it, e.g. "1st". + from: + type: object + description: The competitor seated for this place now. + properties: + name: + type: string + id: + type: string + to: + type: object + description: The competitor the corrected standings put there; empty when `tied`. + properties: + name: + type: string + id: + type: string + tied: + type: boolean + description: The corrected standings leave this place tied until a tie-break is fought; its slot returns to its draw label. + message: + type: string + Sponsor: type: object description: A single sponsor logo entry in the tournament's sponsor list (mp-c38). @@ -4595,6 +5059,29 @@ components: items: $ref: '#/components/schemas/Player' + IneligibleSidesAnnotation: + type: object + readOnly: true + description: > + Read-only stamp attached to a SCHEDULED match, naming which side(s) + cannot be started because they are barred by a decision recorded on + a DIFFERENT match (a competitor already re-scoring their own prior + kiken/fusenpai is exempt, so that match is never stamped against + itself). Computed at request time only, never persisted: `a`/`b` are + each the barring decision code (`kiken`, `kiken-voluntary`, + `kiken-injury`, or `fusenpai`) when that side is barred, omitted + otherwise. Absent entirely on a running or completed match, and on a + scheduled match with neither side barred. A match carrying this + stamp also has `queuePosition` omitted; refer to + `MatchResult.queuePosition`. + properties: + a: + type: string + enum: [kiken, kiken-voluntary, kiken-injury, fusenpai] + b: + type: string + enum: [kiken, kiken-voluntary, kiken-injury, fusenpai] + MatchResult: type: object properties: @@ -4685,7 +5172,12 @@ components: enum: [none, fought, hikiwake, kiken, kiken-voluntary, kiken-injury, fusenpai, fusensho, daihyosen, kachinuki-exhaustion] decisionBy: type: string - description: Which side the decision favours (shiro = white = left, aka = red = right). + description: > + The side the decision names as the loser: who withdrew, did not + appear, or (for a match-level `fusensho`) is barred by a + decision recorded elsewhere (shiro = white = left, aka = red = + right). The write then makes the OTHER side the winner. NOT the + side the decision favours. enum: [shiro, aka] decisionReason: type: string @@ -4697,9 +5189,10 @@ components: description: > Audit justification for rewriting a result the operator had already declared final. Required when the write completes a match that is - already `completed` (a correction) or that carries `reopenPending`; - otherwise it is dropped, so the field always means "this rewrote a - finalized result". A stored reason is carried forward across + already `completed` (a correction). Kept, but not required, when + the write completes a match that carries `reopenPending`. + Otherwise it is dropped, so the field always means "this rewrote + a finalized result". A stored reason is carried forward across subsequent writes. A correction does NOT exempt the write from the timestamp @@ -4716,16 +5209,27 @@ components: readOnly: true description: > True when the match was reopened via `POST .../reopen` without a - reason and still owes its audit justification. While set, a write - that completes the match must carry a `correctionReason` (400 - otherwise); a successful one records the reason and clears this - flag. Server-owned: a client-supplied value is ignored on every - write. + reason and has not been completed since. Nothing is refused for + it: the write that completes the match clears it, and keeps a + `correctionReason` if one is sent. Server-owned: a client-supplied + value is ignored on every write. encho: $ref: '#/components/schemas/EnchoMetadata' status: type: string enum: [scheduled, running, completed] + queuePosition: + type: integer + readOnly: true + description: > + 1-based position in this court's scheduled queue; 0/absent when + not queued (not scheduled, or `ineligibleSides` is set: a barred + match is skipped exactly like an unqueued one, so it never + occupies a queue slot). + ineligibleSides: + readOnly: true + allOf: + - $ref: '#/components/schemas/IneligibleSidesAnnotation' court: type: string maxLength: 32 @@ -4774,8 +5278,12 @@ components: individual victories (IV) and points won (PW) per side. Shiro is sideB (rendered left), Aka is sideA (rendered right). The daihyosen (position -1) sub-bout is excluded, so a representative-bout-decided - tie reports the tied totals (for example IV 0-0). Absent on individual - matches. Never sent by clients. + tie reports the tied totals (for example IV 0-0). For a non-kachinuki + match decided by a match-level default win, these totals already + include the credit for every unfought numbered bout (one victory and + two points per bout, to the side `decisionBy` did not name); refer to + `MatchResult.decisionBy`. Absent on individual matches. Never sent by + clients. properties: shiroIV: type: integer @@ -4905,6 +5413,10 @@ components: status: type: string enum: [scheduled, running, completed] + ineligibleSides: + readOnly: true + allOf: + - $ref: '#/components/schemas/IneligibleSidesAnnotation' court: type: string maxLength: 32 @@ -4950,6 +5462,12 @@ components: type: boolean queuePosition: type: integer + readOnly: true + description: > + 1-based position in this court's scheduled queue; 0/absent when + not queued (not scheduled, or `ineligibleSides` is set: a barred + match is skipped exactly like an unqueued one, so it never + occupies a queue slot). matchNumber: type: integer readOnly: true @@ -4964,6 +5482,7 @@ components: decisionBy: type: string enum: [shiro, aka] + description: The elimination-stage counterpart of `MatchResult.decisionBy`. Same semantics. decisionReason: type: string maxLength: 200 @@ -5004,9 +5523,8 @@ components: readOnly: true description: > Mirrors `MatchResult.reopenPending` for elimination-stage matches: - the match was reopened without a reason and still owes its audit - justification, so the next completing write must carry a - `correctionReason`. Persisted in bracket.json. + the match was reopened without a reason and has not been + completed since. Persisted in bracket.json. subResults: type: array description: > @@ -5139,6 +5657,18 @@ components: bracket generated before this field existed (a legacy bracket.json); a competitor whose id is missing from this list was not drawn (e.g. excluded by check-in) and carries no number. + timesSettled: + type: boolean + readOnly: true + description: > + True once the scheduled times need no repair on load: set when the + draw is generated (each shiaijo is then scheduled in match-number + order), and when a bracket drawn by an earlier release is first + loaded (its times are handed out again in match-number order if + nothing has been played, and otherwise kept). Once set, the + times are never changed on load, so a match the operator moved + stays where it was put. Absent on a bracket not yet loaded by a + release that records it. PlayerStanding: type: object @@ -5270,7 +5800,17 @@ components: type: object additionalProperties: type: string - description: Map of position label to player ID. + description: > + Map of position label to the fighter's display NAME (not an id). + The member id for each position goes in `memberIds`. + memberIds: + type: object + additionalProperties: + type: string + description: > + Map of position label to the team member's id, keyed by the same + positions as `positions`. Optional: a position without an entry + here is identified by its name alone. ScheduleEstimate: type: object @@ -5308,7 +5848,8 @@ components: description: > One consequential team-pool tie that the daihyosen (representative bout) could not settle, so the operator must draw lots (chusen) and - record the finishing order via `PUT .../pools/{poolId}/override-rank`. + record the finishing order, as one `ranks` group, via + `PUT .../pools/{poolId}/override-rank`. properties: poolName: type: string @@ -5349,6 +5890,14 @@ components: minPosition: type: integer description: 1-based finishing position of the best-placed member of this tied group. + ranks: + type: array + description: > + Present only on a group in `recorded`: each team's recorded + chusen finishing position, positionally parallel to teams. Absent + on a group still waiting for its chusen. + items: + type: integer LeagueTiebreakCandidateGroup: type: object diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 7bf56644a..916e05974 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -1244,10 +1244,51 @@ a:hover { min-width: max-content; } -/* The 3rd-place (bronze) section wrapper (.bracket-bronze-section) has no CSS - of its own: its width, horizontal offset (UNDER the final match card), and - top gap are set inline by bronzeUnderFinalStyle (bracket.jsx), since the - offset depends on the bracket's column count. The 3rd-place bout is a +/* The admin Bracket page's tree narrows to fit the width it is given: each + column is 230px while there is room and shrinks to a 180px floor, with + tighter gaps and padding, so a four-round tree (9 to 16 competitors) fits + an iPad in landscape (1180px) without scrolling sideways. Below the floor + the canvas scrolls, as before. The connectors measure every gap and card + from the page, so they follow whatever width the columns take. */ +.bracket-canvas--fit .bracket-canvas__inner { + min-width: 0; + padding-left: 24px; + padding-right: 24px; +} +.bracket-canvas--fit .bc-tree { + gap: 32px; +} +.bracket-canvas--fit .bc-round { + flex: 0 1 230px; + min-width: 180px; +} + +/* The admin page's 3rd-place (bronze) card sits in a row of column slots + sized by the same rules as the tree's columns, so the last slot is always + under the final whatever width the columns narrow to. */ +.bracket-bronze-row { + display: flex; + gap: 32px; + margin-top: 28px; +} +.bracket-bronze-row__slot { + flex: 0 1 230px; + min-width: 180px; +} +/* The label and the smaller card share one 210px box, centred in the slot + (display: block, because the card is an inline-block button and auto + margins do not centre one). */ +.bracket-bronze-row .bracket-bronze-section > * { + display: block; + width: min(210px, 100%); + margin-inline: auto; +} + +/* On the public Bracket tab the 3rd-place (bronze) section wrapper + (.bracket-bronze-section) takes its width, horizontal offset (UNDER the + final match card), and top gap inline from bronzeUnderFinalStyle + (bracket.jsx), since the offset depends on the bracket's column count. On + the admin page it is the last slot of .bracket-bronze-row (above). The 3rd-place bout is a consolation match run alongside the final, so it renders smaller and beneath it, separated from the ladder above by the top gap + the label below. */ @@ -1397,42 +1438,6 @@ a:hover { margin-left: auto; } -/* Structural-bye placeholder rendered in the upstream bracket column so the - tree gap is visible, matches the Excel Tree sheet's empty-slot treatment. */ -.bc-bye-slot { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px 8px 13px; - min-height: 44px; - border: 1.5px dashed var(--line-strong); - border-radius: var(--r); - background: var(--line); - pointer-events: none; - user-select: none; - position: relative; - z-index: 1; -} - -.bc-bye-slot__name { - flex: 1; - font-size: 13.5px; - font-weight: 500; - color: var(--ink-2); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.bc-bye-slot__tag { - font-size: 9px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--ink-4); - flex-shrink: 0; -} - .bc-side { display: flex; align-items: center; @@ -2218,45 +2223,14 @@ a:hover { } /* ---------- Running match panel (admin) ---------- */ -/* Bracket page layout: the tree and the winner-picker / scoring panel sit - side by side when there's room. The panel keeps a minimum width so the - reused inline scoring editor never squashes or clips; when the bracket and - that minimum can't both fit, the panel wraps to a full-width row BELOW the - bracket (flex line-break on basis 560 + 520 > available). */ +/* Bracket page layout: the tree takes the full width and the winner-picker / + scoring panel sits BELOW it (operator decision), since beside it the panel + left a four-round tree too little width at any desktop size. Picking a + match scrolls the panel into view (admin_competition_bracket.jsx). */ .bracket-layout { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 14px; - align-items: flex-start; -} -.bracket-layout__bracket { - /* grows to fill; min-width:0 lets it shrink so .bracket-canvas scrolls - (overflow-x:auto) instead of forcing page overflow. */ - flex: 1 1 560px; - min-width: 0; -} -.bracket-layout__panel { - /* Grows to fill its flex line. When it wraps below the bracket (the common - case at desktop widths) it fills the whole row, so its card lines up edge- - to-edge with the bracket card above, no max-width cap that would leave a - ragged right edge. min-width keeps the inline editor from squashing when - it DOES sit beside the bracket. */ - flex: 1 1 520px; - /* min(500px, 100%): keep the 500px floor that stops the inline editor from - squashing, but never demand more than the row actually has. A flat 500px - pushed the panel outside its grid track on ~800px tablets (the track is - narrower than 500 there) and re-introduced page-level sideways scroll. */ - min-width: min(500px, 100%); -} -/* Phones / small tablets: drop the minimum so the panel fills the single - column instead of overflowing; the editor's own coarse-pointer rules handle - compact sizing from here. */ -@media (max-width: 760px) { - .bracket-layout__panel { - flex-basis: 100%; - min-width: 0; - max-width: none; - } } .running-panel { @@ -2313,7 +2287,7 @@ a:hover { color: var(--ink-2); } -.ipt-btn:hover { +.ipt-btn:hover:not(:disabled) { border-color: var(--ink-4); } @@ -3047,6 +3021,13 @@ a.vlist-item { text-overflow: ellipsis; display: block; } +/* A name carrying a side mark is a flex row (.msb-name--labelled) so the mark + keeps its width and only the name ellipsises. The display:block above + outranks that one-class rule, so it is restated here for this host. */ +.vsched-item__side .n.msb-name--labelled { display: flex; } +/* The Shiro side's name sits toward the centre (text-align: right above), + which a flex row ignores, so it is restated as the row's alignment. */ +.vsched-item__side--shiro .n.msb-name--labelled { justify-content: flex-end; } .vsched-item__side .d { font-size: 11px; @@ -3885,14 +3866,12 @@ button.vsched-item:not([data-clickable]) { display: grid; /* minmax(0, 1fr), NOT a bare 1fr. `1fr` means minmax(auto, 1fr), and that `auto` floors the content track at the item's MIN-CONTENT width. On the - bracket page the item is .bracket-layout, whose min-content is the two - flex bases side by side (~1150px), so the track grew to 1150 no matter - how narrow the window was: the row could then always fit on one line, so - flex-wrap never fired, the scoring panel never dropped below the bracket, - and .bracket-canvas was left the ~590px remainder while the whole admin - page scrolled sideways. Pinning the minimum to 0 lets the track take the - space that is actually available and leaves inner scroll containers - (.bracket-canvas has overflow:auto) to scroll their own overflow. + bracket page that was once the bracket and the scoring panel side by side + (~1150px), so the track grew to 1150 no matter how narrow the window was + and the whole admin page scrolled sideways. Pinning the minimum to 0 lets + the track take the space that is actually available and leaves inner + scroll containers (.bracket-canvas has overflow:auto) to scroll their own + overflow. 200px: the rail's widest content is the "Other competitions" header at ~175px including its paddings; 240 was 65px of nothing on every page. */ grid-template-columns: 200px minmax(0, 1fr); @@ -5198,7 +5177,21 @@ button.vsched-item:not([data-clickable]) { } /* Keyboard-shortcut reminder (←/→ · Esc): meaningless on touch devices, so - it hides under coarse pointers (critique minor). */ + it hides under coarse pointers (critique minor). The layout lives here and + NOT in an inline style: an inline display:flex beat the media query below + and showed the hint on the iPad (bc-kbhn). Keep this base rule BEFORE the + coarse-pointer one so the latter wins on equal specificity. */ +.scoring-shortcut-hint { + display: flex; + gap: 4px; + justify-content: center; + align-items: center; + flex-wrap: wrap; + margin-top: 6px; + font-size: 12px; + color: var(--ink-3); + text-align: center; +} @media (pointer: coarse) { .scoring-shortcut-hint { display: none; } } @@ -5289,6 +5282,42 @@ button.vsched-item:not([data-clickable]) { overflow-wrap: anywhere; } +/* bc-kcsh: a recorded withdrawal's result mark (Kiken / Fus.) beside the + withdrawn side's name in both editor headers (WithdrawalMarkedName). The + same word the score strings put in that side's cell, set as a quieter + tag so it reads as a mark on the name rather than part of it. */ +.sb-result-mark { + font: 800 13px/1 var(--font-strong); + letter-spacing: 0.02em; + color: var(--ink-2); + white-space: nowrap; +} + +/* bc-tmfn: the "Withdrawn" chip beside a barred competitor's name on a + still-scheduled match (ineligible_match.jsx's isBarredMatch / BARRED_CHIP, + composed in barred_chip.jsx). This names a STATE, not a side, so unlike + .num-prefix it is not tinted from currentColor; it borrows that chip's + radius/size convention (muted ink, small mono-ish label, border + faint + fill) instead. text-transform is set explicitly rather than left to + inherit: .bc-match-meta (the bracket card's meta strip) forces uppercase + on its own text, and this chip must read "Withdrawn" wherever it lands, + not "WITHDRAWN" -- which is also why it never renders inside that strip + (a fifth value there would violate the closed middle-mark set). */ +.barred-chip { + display: inline-block; + white-space: nowrap; + font-family: var(--font-strong); + font-size: 11px; + font-weight: 700; + text-transform: none; + letter-spacing: 0.02em; + color: var(--ink-3); + background: color-mix(in srgb, var(--ink-3) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--ink-3) 35%, transparent); + padding: 1px 5px; + border-radius: 4px; +} + /* Option D (harden): on the INDIVIDUAL scoring board only, scoped with :has(.sb-slots) so the team header (.sb-match/.sb-side carrying just a badge + name, no slots) is untouched, lay each side out as a SUBGRID that @@ -5430,6 +5459,22 @@ button.vsched-item:not([data-clickable]) { color: var(--accent-fg); } +/* A mark or a letter the operator cannot change right now -- the bout is + already decided, a hantei stands, or it is the winner's default-win maru + while a recorded withdrawal is in force -- takes the disabled look every + other disabled control on this board already has (.btn:disabled, + .sb-draw-toggle:disabled). Without it the winner's read-only side looked + exactly like the withdrawer's side the operator was correcting, pointer + cursor and all (UAT, bc-tmfn). Opacity only: the fills and the side tint + keep their colours, so Shiro and Aka are still told apart by tint and + hatch, and the maru stays readable. The two hover rules skip a disabled + button, so it does not react to the pointer either. */ +.ipt-btn:disabled, +.sb-slot:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .sb-vs { font-family: var(--font-display); font-size: 14px; @@ -5698,7 +5743,9 @@ button.vsched-item:not([data-clickable]) { } /* Kachinuki running headline content: a bout-log fact ("X beat Y · stays - on"), never a verdict — the verdict appears only on completion. */ + on"), never a verdict — the verdict appears only on completion. Also the + recorded decision ("Kiken – Voluntary") under the verdict of a match a + withdrawal ended (bc-kcsh). */ .team-summary__fact { font-size: 13.5px; font-weight: 700; @@ -6832,19 +6879,42 @@ button.vsched-item:not([data-clickable]) { .msb-name { flex: 1; min-width: 0; font-weight: 600; color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .msb-name--aka { text-align: right; color: var(--red); } -/* A bout row's name cell carries a member label on the OUTER side, so the - cell holds TWO inline children. Ellipsis truncates the END of the run, - which on Aka is the label: a long name clipped Aka's label away while - Shiro's leading one always survived. Same mechanism the competitor-number - clip fix addressed (bc-dnst). Flex makes the NAME the only shrinkable - child, so the label survives on both sides and the name ellipsises - instead. Scoped to --labelled so the single-child name cells elsewhere - (and the --stacked name-over-dojo variant) keep the plain block flow. */ -.msb-name--labelled { display: flex; align-items: center; } +/* A bout row's name cell carries a member label, or a Kiken/Fus./Withdrawn + mark, on the OUTER side, so the cell holds TWO inline children. Ellipsis + truncates the END of the run, which on Aka is the badge: a long name + clipped Aka's badge away while Shiro's leading one always survived. Same + mechanism the competitor-number clip fix addressed (bc-dnst). Flex makes + the NAME (or the NumberedName clip wrapper, when the name carries its own + number chip) the only shrinkable child, so the badge survives on both + sides and the name ellipsises instead. Scoped to --labelled so the + single-child name cells elsewhere (and the --stacked name-over-dojo + variant) keep the plain block flow. + + gap (bc-cse), not the teamNameMark/barredNameMark {" "} text-node spacer + those two leaf functions still emit for their OTHER (non-flex) callers: + CSS Flexbox treats a whitespace-only text node between flex items as + ZERO-SIZED rather than a real item (it would collapse under ordinary + inline layout too), so once a host adopts --labelled that spacer renders + with no width at all and the badge touched the name. gap replaces it here; + the leaf functions are left alone since the SAME spacer is still load- + bearing for a caller that keeps the plain (non-flex) block/inline flow + (e.g. viewer_standings.jsx's wrapping, non-ellipsis name cell). */ +.msb-name--labelled { display: flex; align-items: center; gap: 0.35em; } .msb-name--labelled.msb-name--aka { justify-content: flex-end; } .msb-name--labelled .msb-name__text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.msb-name--labelled .msb-member-label { flex-shrink: 0; } +/* The NumberedName clip wrapper (numbered_name.jsx) is itself display: + inline-flex with no min-width/flex-basis of its own, so as a CHILD of this + flex row it would size to its content and never actually shrink -- its own + internal ellipsis (.numbered-name--clip > .numbered-name__text) only ever + fires once ITS box is smaller than its content. This is what lets it. + It shrinks but never GROWS: a growing wrapper fills the row, so the row's + own alignment (justify-content) no longer places the name and a + right-aligned side reads left-aligned. */ +.msb-name--labelled .numbered-name--clip { min-width: 0; flex: 0 1 auto; } +.msb-name--labelled .msb-member-label, +.msb-name--labelled .sb-result-mark, +.msb-name--labelled .barred-chip { flex-shrink: 0; } /* The decided winner's name. .msb-slots--win marks only an ippon-LESS result (hantei, or a default win), so without this an ordinary 2-0 or 1-0 win is emphasised nowhere. Set by IndividualScore from the same id-first winner key @@ -7202,13 +7272,6 @@ abbr.msb-lab { text-decoration: none; cursor: help; } margin-top: 2px; color: var(--danger-ink); } -/* The server's own words, kept verbatim underneath ours so nothing it said is - swallowed by the friendlier heading. */ -.reopen-conflict__msg { - margin-top: 4px; - color: var(--ink-3); - font-size: var(--fs-xs); -} .reopen-conflict__actions { display: flex; gap: 6px; @@ -7216,15 +7279,6 @@ abbr.msb-lab { text-decoration: none; cursor: help; } margin-top: 8px; } -/* The verdict about to be committed, shown above the reopen-reason prompt: - that prompt's Confirm IS the commit, and it hides the End button whose - armed label would otherwise carry this. */ -.reopen-reason__verdict { - font-size: var(--fs-sm); - color: var(--ink-2); - margin-bottom: 4px; -} - /* F5: pending-write banner, offline / transient queued terminal write */ .pending-write-banner { display: flex; @@ -7287,7 +7341,7 @@ abbr.msb-lab { text-decoration: none; cursor: help; } border-color: rgba(220, 38, 38, 0.3); font-weight: 800; } -.ipt-btn--h:hover { background: rgba(220, 38, 38, 0.12); } +.ipt-btn--h:hover:not(:disabled) { background: rgba(220, 38, 38, 0.12); } /* Touch: the "Send back to queue" control and the kiken/fusenpai disclosure toggles were the only operator controls that fell below the 44px touch target diff --git a/web-mobile/js/__tests__/api.test.jsx b/web-mobile/js/__tests__/api.test.jsx index 4e3bb896e..26fde08aa 100644 --- a/web-mobile/js/__tests__/api.test.jsx +++ b/web-mobile/js/__tests__/api.test.jsx @@ -1083,10 +1083,12 @@ describe('API Utils', () => { // end of JSON input") right after a *successful* save. The fix is // `return true;`, verified here by mocking a response with no .json // method (any call to it would throw, which the test would see). - describe('overridePoolRank (empty-body success)', () => { + describe('overridePoolRanks (empty-body success)', () => { + const order = [{ playerId: 'id-alice', rank: 2 }, { playerId: 'id-bob', rank: 1 }]; + it('resolves to true on 200 with no body', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); - await expect(API.overridePoolRank('c1', 'p1', 'Alice', 2, 'pw')) + await expect(API.overridePoolRanks('c1', 'p1', order, 'pw')) .resolves.toBe(true); }); @@ -1099,7 +1101,7 @@ describe('API Utils', () => { status: 200, json: async () => { throw new SyntaxError('Unexpected end of JSON input'); }, }); - await expect(API.overridePoolRank('c1', 'p1', 'Alice', 2, 'pw')) + await expect(API.overridePoolRanks('c1', 'p1', order, 'pw')) .resolves.toBe(true); }); @@ -1109,13 +1111,19 @@ describe('API Utils', () => { status: 400, json: async () => ({ error: 'rank must be a positive integer ≤ 1000' }), }); - await expect(API.overridePoolRank('c1', 'p1', 'Alice', 2000, 'pw')) + await expect(API.overridePoolRanks('c1', 'p1', [{ playerId: 'id-alice', rank: 2000 }], 'pw')) .rejects.toThrow('rank must be a positive integer ≤ 1000'); }); - it('PUTs JSON body with playerName + rank', async () => { + // The whole order goes in ONE request, as the group form ({ranks}), + // so the server answers for the final order only. playerId is REQUIRED + // server-side (operator ruling bc-pnum: a pool member is resolved by id + // only), and neither a name nor a dojo is sent: the server never reads + // them. + it('PUTs the whole order as one ranks body', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true }); - await API.overridePoolRank('comp1', 'pool-A', 'Alice', 3, 'secret'); + await API.overridePoolRanks('comp1', 'pool-A', order, 'secret'); + expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledWith( '/api/competitions/comp1/pools/pool-A/override-rank', expect.objectContaining({ @@ -1124,22 +1132,40 @@ describe('API Utils', () => { 'Content-Type': 'application/json', 'X-Tournament-Password': 'secret', }), - body: JSON.stringify({ playerName: 'Alice', rank: 3 }), }), ); + const [, opts] = global.fetch.mock.calls[0]; + expect(JSON.parse(opts.body)).toEqual({ ranks: order }); }); - // playerId is REQUIRED server-side (operator ruling bc-pnum: - // resolvePoolOverrideTarget resolves a pool member by id only). This - // pins that the client forwards it verbatim, and that playerDojo is - // NEVER sent -- the server never read it, so there is nothing to gain - // from including it, unlike the pre-bc-pnum client which sent it - // whenever the caller happened to have one. - it('includes playerId when the caller passes one, and never sends playerDojo', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true }); - await API.overridePoolRank('comp1', 'pool-A', 'Alice', 3, 'secret', 'id-alice'); + // An order that moves a qualifier out of a knockout match already + // fought is refused like a pool correction. The refusal must reach the + // caller as the structured error attemptScoreWrite reads (marked as a + // ranking for the dialog copy), and the confirmed retry must carry the + // confirmation; the terminal running refusal keeps its operator copy. + it('parses a downstream refusal and forwards the confirmation', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ + error: 'downstream_knockout_played', matchId: '', blockingMatchId: 'm9', + blockingMatches: [{ id: 'm9', number: 9 }], displaced: 'Alpha', + qualifierChange: [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Alpha' }, to: { name: 'Beta' } }], + }), + }); + const err = await API.overridePoolRanks('c1', 'Pool A', order, 'pw').catch((e) => e); + expect(err.downstreamKnockoutPlayed).toMatchObject({ ranking: true, blockingMatchId: 'm9' }); + expect(err.downstreamKnockoutPlayed.qualifierChange).toHaveLength(1); + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ error: 'downstream_knockout_running', matchId: '', runningMatches: [{ id: 'm9', number: 9 }] }), + }); + await expect(API.overridePoolRanks('c1', 'Pool A', order, 'pw', true)) + .rejects.toThrow('Match 9 is being fought now. Finish it or send it back to the queue, then save again.'); const [, opts] = global.fetch.mock.calls[0]; - expect(JSON.parse(opts.body)).toEqual({ playerName: 'Alice', rank: 3, playerId: 'id-alice' }); + expect(JSON.parse(opts.body)).toEqual({ ranks: order, forceDownstreamReopen: true }); }); }); @@ -1410,7 +1436,7 @@ describe('API Utils', () => { // endpoint. The client must: // - send the new password in a JSON body // - return true on a 204 success (no res.json(); same empty-body - // pattern as overridePoolRank above) + // pattern as overridePoolRanks above) // - throw an Error whose .status is the HTTP status, so the // ResetPasswordForm can branch on 404 ("disabled by operator") // vs other errors. @@ -1450,7 +1476,7 @@ describe('API Utils', () => { it('does not call res.json() on the success path', async () => { // Empty 204 body; any call to .json() would throw // SyntaxError per the Fetch spec. Mirrors the - // overridePoolRank regression coverage above. + // overridePoolRanks regression coverage above. global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204, @@ -1705,31 +1731,34 @@ describe('API Utils', () => { }); describe('chusenCandidates', () => { - it('returns the candidates array from the response', async () => { + it('returns the candidates and recorded arrays from the response', async () => { const payload = { candidates: [ { poolName: 'Pool A', teamNames: ['Team 1', 'Team 2'], minPosition: 2 }, ], + recorded: [ + { poolName: 'Pool B', teamNames: ['Team 4', 'Team 3'], minPosition: 1, ranks: [1, 2] }, + ], }; global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => payload, }); const result = await API.chusenCandidates('comp-1', 'pw'); - expect(result).toEqual(payload.candidates); + expect(result).toEqual({ candidates: payload.candidates, recorded: payload.recorded }); expect(global.fetch).toHaveBeenCalledWith( '/api/competitions/comp-1/chusen-candidates', { headers: { 'X-Tournament-Password': 'pw' } } ); }); - it('returns [] when the candidates field is absent', async () => { + it('returns empty lists when the fields are absent', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}), }); const result = await API.chusenCandidates('comp-1'); - expect(result).toEqual([]); + expect(result).toEqual({ candidates: [], recorded: [] }); }); it('throws with the status when the body has no error field', async () => { diff --git a/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx b/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx index 7a1950681..59229df8c 100644 --- a/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx +++ b/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx @@ -51,6 +51,8 @@ describe('API.recordScore: downstream_knockout_played (bc-kcdg)', () => { // both the final and the bronze match. Shape is {id, number}. blockingMatches: [{ id: 'm5' }], displaced: 'Aoki Taro', + // A knockout correction moves no pool place. + qualifierChange: [], }); }); @@ -140,6 +142,8 @@ describe('API.overrideBracketWinner: downstream_knockout_played (bc-kcdg)', () = // both the final and the bronze match. Shape is {id, number}. blockingMatches: [{ id: 'm-r1-0' }], displaced: 'Bob', + // A knockout correction moves no pool place. + qualifierChange: [], }); }); @@ -231,6 +235,8 @@ describe('API.recordDecision: downstream_knockout_played (bc-cse)', () => { // both the final and the bronze match. Shape is {id, number}. blockingMatches: [{ id: 'm5' }], displaced: 'Aoki Taro', + // A knockout correction moves no pool place. + qualifierChange: [], }); }); @@ -266,3 +272,145 @@ describe('API.recordDecision: downstream_knockout_played (bc-cse)', () => { expect(sentBody.forceDownstreamReopen).toBe(true); }); }); + +// bc-rawm: recordDecision mirrors recordScore's mp-dc52 Phase 3 preference +// order for a 409 the eligibility gate returns -- reasonHuman, then reason, +// then the bare code -- instead of the fallback `new Error(err.error ...)` +// this branch used before, which surfaced the raw "ineligible_competitor" / +// "already_ineligible" token with the server's actual sentence discarded. +describe('API.recordDecision: ineligible_competitor / already_ineligible (bc-rawm)', () => { + let originalFetch; + afterEach(() => { if (originalFetch) global.fetch = originalFetch; }); + + it.each(['ineligible_competitor', 'already_ineligible'])('prefers reasonHuman for a 409 %s', async (code) => { + originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ error: code, reason: 'raw reason', reasonHuman: 'Tanaka Ichiro is already ineligible: withdrew earlier in Pool A.' }), + }); + + const err = await API.recordDecision('c1', 'm1', { decision: 'fusensho', decisionBy: 'aka' }, 'pw').then( + () => { throw new Error('expected a rejection'); }, + (e) => e, + ); + expect(err.message).toBe('Tanaka Ichiro is already ineligible: withdrew earlier in Pool A.'); + expect(err.message).not.toBe(code); + }); + + it('falls back to reason, then the bare code, when reasonHuman is absent', async () => { + originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ error: 'already_ineligible', reason: 'raw reason only' }), + }); + const err = await API.recordDecision('c1', 'm1', { decision: 'fusensho', decisionBy: 'aka' }, 'pw').then( + () => { throw new Error('expected a rejection'); }, + (e) => e, + ); + expect(err.message).toBe('raw reason only'); + }); + + it('falls back to the bare code when neither reasonHuman nor reason is sent', async () => { + originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ error: 'already_ineligible' }), + }); + const err = await API.recordDecision('c1', 'm1', { decision: 'fusensho', decisionBy: 'aka' }, 'pw').then( + () => { throw new Error('expected a rejection'); }, + (e) => e, + ); + expect(err.message).toBe('already_ineligible'); + }); +}); + +// bc-rawm: recordScore's 409 court_busy refusal is built into an operator +// sentence via write_result.jsx's courtBusyMessage (naming the shiaijo and +// the blocking match's operator label), never the bare "court_busy" token +// the fallback `new Error(data.error || ...)` used to throw. +describe('API.recordScore: court_busy (bc-rawm)', () => { + let originalFetch; + afterEach(() => { if (originalFetch) global.fetch = originalFetch; }); + + it('throws the operator sentence built from court + label, not the bare code', async () => { + originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ error: 'court_busy', court: 'A', matchId: 'm-blk', compId: 'c1', label: 'Pool A · Match 2' }), + }); + const err = await API.recordScore('c1', 'm1', { status: 'completed' }, 'pw').then( + () => { throw new Error('expected a rejection'); }, + (e) => e, + ); + expect(err.message).toBe('Shiaijo A is running Pool A · Match 2. Finish it or send it back to the queue first.'); + expect(err.message).not.toBe('court_busy'); + expect(err.code).toBe('court_busy'); + expect(err.court).toBe('A'); + expect(err.matchId).toBe('m-blk'); + expect(err.compId).toBe('c1'); + expect(err.label).toBe('Pool A · Match 2'); + }); + + // bc-cse: no "This shiaijo"/"another match" fallback -- respondCourtBusy + // (handlers_match.go) always sends both `court` and a `label` + // (matchLabelOrID falls back to the raw match id rather than omitting it), + // so the two fallback strings courtBusyMessage used to carry for a missing + // court/label were dead code and were removed with them. +}); + +// A POOL correction in a mixed competition: the same played refusal carries +// qualifierChange (who moves), and a knockout match being fought answers with +// the terminal downstream_knockout_running, whose thrown message is the +// operator's copy rather than the bare code and which is NOT a confirmable +// refusal (downstreamKnockoutPlayedRefusal reads null, so attemptScoreWrite +// never offers "Apply and reopen" for it). +describe('pool corrections that move a qualifier', () => { + let originalFetch; + afterEach(() => { if (originalFetch) global.fetch = originalFetch; }); + + const qc = [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'A1', id: 'a1' }, to: { name: 'A2', id: 'a2' } }]; + + it('recordScore parses qualifierChange onto the refusal', async () => { + originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ + error: 'downstream_knockout_played', matchId: 'Pool A-0', blockingMatchId: 'm-r1-0', + blockingMatches: [{ id: 'm-r1-0', number: 1 }], displaced: 'A1', qualifierChange: qc, message: 'x', + }), + }); + const err = await API.recordScore('c1', 'Pool A-0', { status: 'completed' }, 'pw').then( + () => { throw new Error('expected a rejection'); }, + (e) => e, + ); + expect(downstreamKnockoutPlayedRefusal(err).qualifierChange).toEqual(qc); + }); + + it.each([ + ['recordScore', () => API.recordScore('c1', 'Pool A-0', { status: 'completed' }, 'pw')], + ['recordDecision', () => API.recordDecision('c1', 'Pool A-0', { decision: 'kiken-voluntary', decisionBy: 'aka' }, 'pw')], + ])('%s throws the running refusal as the operator copy, never confirmable', async (_door, send) => { + originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ + error: 'downstream_knockout_running', matchId: 'Pool A-0', + runningMatches: [{ id: 'm-r1-0', number: 1 }], message: 'server copy', + }), + }); + const err = await send().then( + () => { throw new Error('expected a rejection'); }, + (e) => e, + ); + expect(err.message).toBe('Match 1 is being fought now. Finish it or send it back to the queue, then save again.'); + expect(err.code).toBe('downstream_knockout_running'); + expect(err.downstreamKnockoutRunning).toEqual({ matchId: 'Pool A-0', runningMatches: [{ id: 'm-r1-0', number: 1 }] }); + expect(downstreamKnockoutPlayedRefusal(err)).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/api_client_reopen.test.jsx b/web-mobile/js/__tests__/api_client_reopen.test.jsx index 888b0a41c..780544718 100644 --- a/web-mobile/js/__tests__/api_client_reopen.test.jsx +++ b/web-mobile/js/__tests__/api_client_reopen.test.jsx @@ -5,7 +5,9 @@ // by the server via reopenPending). Pin the URL/method/headers, the reasonless // body, the verbatim error surfacing the editor relies on (the server's 409s // are full sentences the operator reads unchanged), and the blocking-match -// identity the court-busy remedy is built on. +// identity the court-busy remedy is built on. Clear withdrawal and reopen +// (bc-tmfn) adds the optional reason and downstream confirmation, and the +// structured downstream_knockout_played refusal. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { API } from '../api_client.jsx'; @@ -26,9 +28,9 @@ describe('API.reopenMatch', () => { afterEach(() => { global.fetch = originalFetch; }); it('POSTs a reasonless JSON body with the password header', async () => { - global.fetch = mockFetch(200, {}); - const ok = await API.reopenMatch('c42', 'm-r1-0', 'secret'); - expect(ok).toBe(true); + global.fetch = mockFetch(200, { reopenedMatches: [] }); + const res = await API.reopenMatch('c42', 'm-r1-0', 'secret'); + expect(res).toEqual({ reopenedMatches: [] }); const [url, opts] = global.fetch.mock.calls[0]; expect(url).toBe('/api/competitions/c42/matches/m-r1-0/reopen'); expect(opts.method).toBe('POST'); @@ -40,6 +42,57 @@ describe('API.reopenMatch', () => { expect(JSON.parse(opts.body)).toEqual({}); }); + // bc-tmfn: Clear withdrawal and reopen collects its reason before the tap + // posts, and a retry after the downstream confirm carries the force flag. + it('sends the reason and the downstream confirmation only when given', async () => { + global.fetch = mockFetch(200, { reopenedMatches: [{ id: 'm-r2-0', number: 2 }] }); + const res = await API.reopenMatch('c1', 'm1', 'secret', { reason: 'Withdrawal recorded by mistake', force: true }); + expect(res.reopenedMatches).toEqual([{ id: 'm-r2-0', number: 2 }]); + expect(JSON.parse(global.fetch.mock.calls[0][1].body)) + .toEqual({ reason: 'Withdrawal recorded by mistake', forceDownstreamReopen: true }); + }); + + it('parses the downstream_knockout_played refusal the way the score path does', async () => { + global.fetch = mockFetch(409, { + error: 'downstream_knockout_played', + matchId: 'm-r1-0', + blockingMatchId: 'm-r2-0', + blockingMatches: [{ id: 'm-r2-0', number: 2 }], + displaced: 'Ryu', + message: 'correcting match "m-r1-0" would change the winner ...', + }); + const err = await API.reopenMatch('c1', 'm-r1-0', 'secret').then( + () => { throw new Error('expected a rejection'); }, + (e) => e + ); + // `reopen` marks the refusal as met by a reopen rather than a corrected + // result, for the confirm dialog's copy (the way overridePoolRanks marks + // `ranking`). + expect(err.downstreamKnockoutPlayed).toEqual({ + matchId: 'm-r1-0', + blockingMatchId: 'm-r2-0', + blockingMatches: [{ id: 'm-r2-0', number: 2 }], + displaced: 'Ryu', + qualifierChange: [], + reopen: true, + }); + }); + + it('marks the refusal from the atomic court-busy remedy as a reopen too', async () => { + global.fetch = mockFetch(409, { + error: 'downstream_knockout_played', + matchId: 'm-r1-0', + blockingMatchId: 'm-r2-0', + blockingMatches: [{ id: 'm-r2-0', number: 2 }], + displaced: 'Ryu', + }); + const err = await API.requeueBlockerAndReopen('c1', 'm-r1-0', 'c1', 'm-r1-1', 'secret').then( + () => { throw new Error('expected a rejection'); }, + (e) => e + ); + expect(err.downstreamKnockoutPlayed.reopen).toBe(true); + }); + it('surfaces the court-busy 409 sentence, not the machine code, and keeps the blocking match', async () => { // The court-busy conflict is new (mp-gmcg review): reopening would put a // second live match on the court. It reuses the score path's STRUCTURED @@ -68,6 +121,65 @@ describe('API.reopenMatch', () => { expect(err.compId).toBe('c1'); }); + // bc-rawm: the court-busy conflict reuses the score path's structured + // payload (see the test above), which now carries `label` too -- the + // blocking match's operator name. Carried through onto the thrown Error so + // ReopenFeedback (admin_scoring_shared.jsx) can prefer it over its own + // best-effort fetched blockerLabel and never fall back to the raw matchId. + it('carries the server label onto the court-busy Error for the reopen panel heading', async () => { + global.fetch = mockFetch(409, { + error: 'court_busy', + court: 'A', + matchId: 'm-r1-1', + compId: 'c1', + label: 'Pool A · Match 2', + message: 'Court A already has a running match (m-r1-1). Finish that match before reopening this one.', + }); + const err = await API.reopenMatch('c1', 'm1', 'secret').then( + () => { throw new Error('expected a rejection'); }, + (e) => e + ); + expect(err.label).toBe('Pool A · Match 2'); + }); + + it('carries no label when the server sends none', async () => { + global.fetch = mockFetch(409, { + error: 'court_busy', court: 'A', matchId: 'm-r1-1', compId: 'c1', message: 'x', + }); + const err = await API.reopenMatch('c1', 'm1', 'secret').then( + () => { throw new Error('expected a rejection'); }, + (e) => e + ); + expect(err.label).toBeUndefined(); + }); + + // bc-rawm: reopenFailureError now routes through the shared + // _downstreamRefusalError (both downstream_knockout_played AND + // downstream_knockout_running), not _downstreamKnockoutPlayedError alone, + // so a kachinuki reopen blocked by a downstream match STILL RUNNING (not + // yet played) gets its OWN copy instead of falling through to the bare + // "downstream_knockout_running" token below. bc-cse: that copy is now the + // REOPEN variant ("...then reopen again"), not the score path's "...then + // save again" -- a reopen has no save step to retry. + it('parses the downstream_knockout_running refusal with the REOPEN copy, not the score path\'s', async () => { + global.fetch = mockFetch(409, { + error: 'downstream_knockout_running', + matchId: 'm-r1-0', + runningMatches: [{ id: 'm-r2-0', label: 'Match 7 (Final)' }], + message: 'server copy', + }); + const err = await API.reopenMatch('c1', 'm-r1-0', 'secret').then( + () => { throw new Error('expected a rejection'); }, + (e) => e + ); + expect(err.message).toBe('Match 7 (Final) is being fought now. Finish it or send it back to the queue, then reopen again.'); + expect(err.code).toBe('downstream_knockout_running'); + expect(err.downstreamKnockoutRunning).toEqual({ matchId: 'm-r1-0', runningMatches: [{ id: 'm-r2-0', label: 'Match 7 (Final)' }] }); + // Not marked as a "played" refusal: the running shape carries nothing to + // mark .reopen on, and its own copy already says what to do. + expect(err.downstreamKnockoutPlayed).toBeUndefined(); + }); + it('surfaces the plain-sentence 409s (not completed, downstream fought) verbatim', async () => { global.fetch = mockFetch(409, { error: 'cannot reopen: a downstream knockout match has already been fought' }); await expect(API.reopenMatch('c1', 'm1', 'secret')) @@ -97,9 +209,9 @@ describe('API.requeueBlockerAndReopen', () => { afterEach(() => { global.fetch = originalFetch; }); it('POSTs the blocker identity to the atomic endpoint with the password header', async () => { - global.fetch = mockFetch(200, {}); - const ok = await API.requeueBlockerAndReopen('tgt', 'm-r1-0', 'blk', 'm-r1-1', 'secret'); - expect(ok).toBe(true); + global.fetch = mockFetch(200, { reopenedMatches: [] }); + const res = await API.requeueBlockerAndReopen('tgt', 'm-r1-0', 'blk', 'm-r1-1', 'secret'); + expect(res).toEqual({ reopenedMatches: [] }); const [url, opts] = global.fetch.mock.calls[0]; expect(url).toBe('/api/competitions/tgt/matches/m-r1-0/requeue-blocker-and-reopen'); expect(opts.method).toBe('POST'); diff --git a/web-mobile/js/__tests__/bracket_connector_elbow.test.jsx b/web-mobile/js/__tests__/bracket_connector_elbow.test.jsx index bf8b4c9f4..9fbed40c6 100644 --- a/web-mobile/js/__tests__/bracket_connector_elbow.test.jsx +++ b/web-mobile/js/__tests__/bracket_connector_elbow.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { elbowXFor, connectorPath } from '../bracket.jsx'; +import { elbowXFor, connectorPath, connectorTargetY } from '../bracket.jsx'; // bc-pnum: in an uneven effective-round bracket a feeder can skip a column // (a bye auto-advance elides the hidden column between it and its parent). @@ -8,6 +8,12 @@ import { elbowXFor, connectorPath } from '../bracket.jsx'; // landed the vertical run inside the skipped column's card, so the skipping // feeder read as feeding the skipped match instead of its real parent. // +// A bracket generated now never skips: DisplayRound is the distance from the +// final, so every feeder sits one column before its parent (pinned by +// TestBracketDisplayMetadata_Feeders, internal/engine). The draw below was +// generated while rounds were classified by slot level, and a bracket stored +// then keeps those rounds, so this routing is still reachable. +// // Numbers below are measured on the real render (tree-relative px) for a // 10-entrant, 2-shiaijo draw: M1 (round 1) and M5 (the quarterfinal) both // feed M7 (the semifinal); M1's winner skips the quarterfinal column @@ -60,3 +66,55 @@ describe('connector elbow: routes through the gap before the PARENT column', () expect(elbowX).toBe(286 - 28); }); }); + +// bc-tmfn (operator ruling, option A): a competitor who skips a round gets no +// placeholder card, so a card can be fed by ONE match, its other side a +// competitor who comes straight in. That connector ends on the row the match +// fills, at the row's centre: feeders[0] fills sideA, the Aka row +// (.bc-side--a); feeders[1] fills sideB, the Shiro row (.bc-side--b). A card +// fed by TWO matches keeps the single join at its anchor, the seam between the +// rows. Pure: the row centre comes from a callback, called only when needed. +// That BracketConnectorsMeta actually routes through this helper is pinned on +// a real mount in render/bracket_structural_bye.render.test.jsx. +describe('connectorTargetY: where a feeder connector ends on its parent card', () => { + // A card whose seam (anchor) is at 150, Aka row centre 135, Shiro row centre 165. + const cardAnchorY = 150; + const ROW = { a: 135, b: 165 }; + const recorder = () => { + const calls = []; + const rowMidY = (side) => { calls.push(side); return ROW[side]; }; + return { calls, rowMidY }; + }; + + it("one feeder in sideB (Shiro): ends at the Shiro row's centre", () => { + const { calls, rowMidY } = recorder(); + expect(connectorTargetY({ feeders: ['', 'm-r1-3'], fid: 'm-r1-3', cardAnchorY, rowMidY })).toBe(165); + expect(calls).toEqual(['b']); + }); + + it("one feeder in sideA (Aka): ends at the Aka row's centre", () => { + const { calls, rowMidY } = recorder(); + expect(connectorTargetY({ feeders: ['m-r1-3', ''], fid: 'm-r1-3', cardAnchorY, rowMidY })).toBe(135); + expect(calls).toEqual(['a']); + }); + + it('two feeders: both connectors join at the card anchor, and no row is measured', () => { + const { calls, rowMidY } = recorder(); + const feeders = ['m-r1-0', 'm-r2-1']; + expect(connectorTargetY({ feeders, fid: 'm-r1-0', cardAnchorY, rowMidY })).toBe(150); + expect(connectorTargetY({ feeders, fid: 'm-r2-1', cardAnchorY, rowMidY })).toBe(150); + expect(calls).toEqual([]); + }); + + it('falls back to the anchor when the fed row cannot be measured', () => { + expect(connectorTargetY({ feeders: ['', 'm-r1-3'], fid: 'm-r1-3', cardAnchorY, rowMidY: () => null })).toBe(150); + }); + + it('falls back to the anchor for a feeder that is not the lone one on the card', () => { + // Defensive: the effect only asks about ids in the card's own feeders. + const { calls, rowMidY } = recorder(); + expect(connectorTargetY({ feeders: ['', 'm-r1-3'], fid: 'm-other', cardAnchorY, rowMidY })).toBe(150); + expect(connectorTargetY({ feeders: undefined, fid: 'm-r1-3', cardAnchorY, rowMidY })).toBe(150); + expect(calls).toEqual([]); + }); +}); diff --git a/web-mobile/js/__tests__/bracket_display_model.test.jsx b/web-mobile/js/__tests__/bracket_display_model.test.jsx index b68c91c1b..77707e827 100644 --- a/web-mobile/js/__tests__/bracket_display_model.test.jsx +++ b/web-mobile/js/__tests__/bracket_display_model.test.jsx @@ -81,24 +81,32 @@ describe('buildDisplayModel', () => { const model = buildDisplayModel(fivePlayerRounds()); expect(model.hasMeta).toBe(true); // Columns ordered first round → final: [QF, SF, Final]. - expect(model.columns.map((c) => c.filter((m) => !m.isByeSlot).length)).toEqual([1, 2, 1]); + expect(model.columns.map((c) => c.length)).toEqual([1, 2, 1]); const ids = (col) => col.map((m) => m.id).sort(); - expect(ids(model.columns[0].filter((m) => !m.isByeSlot))).toEqual(['m-r1-3']); // QF: Dave/Eve + // The first column holds ONLY the one first-round bout. Alice, Bob and + // Carol skip it and get no placeholder card there (bc-tmfn): they first + // appear in their semifinal cards. + expect(ids(model.columns[0])).toEqual(['m-r1-3']); // QF: Dave/Eve expect(ids(model.columns[1])).toEqual(['m-r1-0', 'm-r2-1']); // SF: Alice/Bob + Carol expect(ids(model.columns[2])).toEqual(['m-r3-0']); // Final // No hidden/phantom match leaks into any column. const all = model.columns.flat(); expect(all.some((m) => m.hidden)).toBe(false); - expect(all.filter((m) => !m.isByeSlot)).toHaveLength(4); // N-1 real matches + expect(all).toHaveLength(4); // N-1 real matches, and nothing else }); - it('exposes a feeder graph keyed by match id (byes carry no feeder)', () => { + it('exposes a feeder graph of real matches only (a skipped side has no feeder)', () => { const model = buildDisplayModel(fivePlayerRounds()); expect(model.feedersById['m-r3-0']).toEqual(['m-r1-0', 'm-r2-1']); // final ← Alice/Bob, Carol SF - expect(model.feedersById['m-r2-1']).toEqual(['bye-m-r2-1-0', 'm-r1-3']); // Carol SF ← bye slot + Dave/Eve - expect(model.feedersById['m-r1-0']).toEqual(['bye-m-r1-0-0', 'bye-m-r1-0-1']); // Alice/Bob seeded in; both sides are byes - expect(model.feedersById['bye-m-r1-0-0']).toEqual([]); // bye slot is a leaf - expect(model.feedersById['m-r1-3']).toEqual([]); // Dave/Eve seeded in + expect(model.feedersById['m-r2-1']).toEqual(['m-r1-3']); // Carol SF ← Dave/Eve only; Carol comes straight in + expect(model.feedersById['m-r1-0']).toEqual([]); // Alice/Bob both come straight into the SF + expect(model.feedersById['m-r1-3']).toEqual([]); // Dave/Eve: the first-round bout + // Keyed by exactly the drawn matches: nothing synthesized. + expect(Object.keys(model.feedersById).sort()).toEqual(['m-r1-0', 'm-r1-3', 'm-r2-1', 'm-r3-0']); + // The raw [sideA, sideB] pair survives on the column entry, so the + // connector can tell WHICH side a lone feeder fills (connectorTargetY). + const carolSF = model.columns.flat().find((m) => m.id === 'm-r2-1'); + expect(carolSF.feeders).toEqual(['', 'm-r1-3']); }); it('assigns sequential match numbers left-to-right, top-to-bottom (matchNumById)', () => { @@ -180,54 +188,75 @@ describe('buildDisplayModel', () => { }); describe('computeMetaTops', () => { - it('centres each parent on the mean of its feeders and stacks bye leaves', () => { + // bc-tmfn: with no placeholder cards, a parent can have 0, 1 or 2 feeders. + // 100px cards with the seam at 50: the Aka row's centre sits at 25, the + // Shiro row's at 75, so a one-fed card shifts 25px against its feeder, more + // than the 16px gap between stacked cards. + const heights = { 'm-r1-0': 100, 'm-r1-3': 100, 'm-r2-1': 100, 'm-r3-0': 100 }; + const offsets = { 'm-r1-0': 50, 'm-r1-3': 50, 'm-r2-1': 50, 'm-r3-0': 50 }; + const GAP = 16; + + it('puts a one-fed card\'s Shiro row level with its feeder, and keeps the card clear of the one above', () => { + const model = buildDisplayModel(fivePlayerRounds()); // Carol's SF is fed on sideB + const tops = computeMetaTops(model.columns, model.feedersById, heights, offsets, { 'm-r2-1': 75 }); + const anchor = (id) => tops[id] + offsets[id]; + expect(Object.keys(tops).sort()).toEqual(['m-r1-0', 'm-r1-3', 'm-r2-1', 'm-r3-0']); + // Alice/Bob's SF, fed by nothing, is stacked first. + expect(tops['m-r1-0']).toBe(0); + // Carol's SF: its Shiro row centre is level with Dave/Eve's anchor, so the + // line between them is straight. + expect(tops['m-r2-1'] + 75).toBe(anchor('m-r1-3')); + // It rose 25px against Dave/Eve, and still keeps the full gap below + // Alice/Bob in their shared column: Dave/Eve was stacked lower instead. + expect(tops['m-r2-1']).toBe(tops['m-r1-0'] + heights['m-r1-0'] + GAP); + expect(tops['m-r1-3']).toBe(tops['m-r2-1'] + 25); + // The final is centred on the two semifinal anchors. + expect(anchor('m-r3-0')).toBeCloseTo((anchor('m-r1-0') + anchor('m-r2-1')) / 2, 5); + }); + + it('puts a one-fed card\'s Aka row level with its feeder, and keeps what is stacked after it clear', () => { + // Carol's SF fed on sideA, and first under the final, so Alice/Bob's SF is + // stacked after it in the same column. + const rounds = fivePlayerRounds(); + rounds[1][1] = { ...rounds[1][1], sideA: 'Winner of r3-m3', sideB: 'Carol', feeders: ['m-r1-3', ''] }; + rounds[2][0] = { ...rounds[2][0], feeders: ['m-r2-1', 'm-r1-0'] }; + const model = buildDisplayModel(rounds); + const tops = computeMetaTops(model.columns, model.feedersById, heights, offsets, { 'm-r2-1': 25 }); + const anchor = (id) => tops[id] + offsets[id]; + expect(tops['m-r1-3']).toBe(0); + // The Aka row centre is level with Dave/Eve's anchor: the card dropped 25px. + expect(tops['m-r2-1'] + 25).toBe(anchor('m-r1-3')); + expect(tops['m-r2-1']).toBe(25); + // Alice/Bob's SF, stacked next in the same column, keeps the full gap. + expect(tops['m-r1-0']).toBe(tops['m-r2-1'] + heights['m-r2-1'] + GAP); + }); + + it('levels a one-fed card at its seam when no fed-row centre is given', () => { const model = buildDisplayModel(fivePlayerRounds()); - const heights = { 'm-r1-0': 100, 'm-r1-3': 100, 'm-r2-1': 100, 'm-r3-0': 100 }; - const tops = computeMetaTops(model.columns, model.feedersById, heights); - // bye slots have no entry in heights; use the DEFAULT_H fallback (110). - const centre = (id) => tops[id] + (heights[id] ?? 110) / 2; - // Alice/Bob (leaf) is stacked first, Dave/Eve second. - expect(centre('m-r1-0')).toBeLessThan(centre('m-r1-3')); - // Carol's SF is centred on its two feeders: bye-m-r2-1-0 (Carol side) + Dave/Eve. - expect(centre('m-r2-1')).toBeCloseTo((centre('bye-m-r2-1-0') + centre('m-r1-3')) / 2, 5); - // Final is centred between Alice/Bob and Carol's SF. - expect(centre('m-r3-0')).toBeCloseTo((centre('m-r1-0') + centre('m-r2-1')) / 2, 5); + const tops = computeMetaTops(model.columns, model.feedersById, heights, offsets); + expect(tops['m-r2-1'] + offsets['m-r2-1']).toBe(tops['m-r1-3'] + offsets['m-r1-3']); }); // mp-ydk7: connectors anchor at each card's sides-block midline, which sits - // BELOW the geometric centre for a match card (the meta-header offset) but AT - // the centre for a bye-slot card (no .bc-side). When a parent is fed by one - // match card + one bye-slot, centring it on feeder GEOMETRIC CENTRES leaves its - // seam ~6px off the feeder-anchor midpoint (the elbow misses the seam). Passing - // per-card `offsets` must centre each parent so its OWN anchor equals the mean - // of its feeders' anchors → delta 0 for every parent, including the asymmetric - // match+bye case. Guards the regression the centre-of-mass layout shipped on - // unbalanced (mp-5ng7) brackets. - it('zeroes feeder-vs-child delta under asymmetric match/bye anchor offsets', () => { + // BELOW the geometric centre by the meta header, and cards differ in height + // (a filled name+dojo card is taller than a TBD one). Centring a parent on + // its feeders' GEOMETRIC centres would leave its seam off the feeders' + // seams. Passing per-card `offsets` must place each two-fed parent so its + // OWN anchor equals the mean of its feeders' anchors, and a one-fed parent + // so its fed row equals its feeder's anchor. + it('zeroes feeder-vs-child delta under asymmetric card heights and anchor offsets', () => { const model = buildDisplayModel(fivePlayerRounds()); - const MATCH_H = 114, BYE_H = 44; - // Match cards: sides-midline 63px from top (12px header + ~half the sides). - // Bye-slot cards: centred, so offset = height / 2. - const MATCH_OFF = 63, BYE_OFF = BYE_H / 2; - const isBye = (id) => id.startsWith('bye-'); - const heights = {}, offsets = {}; - for (const col of model.columns) { - for (const m of col) { - heights[m.id] = isBye(m.id) ? BYE_H : MATCH_H; - offsets[m.id] = isBye(m.id) ? BYE_OFF : MATCH_OFF; - } - } - const tops = computeMetaTops(model.columns, model.feedersById, heights, offsets); - const anchor = (id) => tops[id] + offsets[id]; // the y the SVG connectors join at - // For every parent: its anchor (seam) == mean of its feeders' anchors → delta 0. - for (const [childId, feeders] of Object.entries(model.feedersById)) { - const fs = feeders.filter(Boolean); - if (!fs.length) continue; - const mean = fs.reduce((s, fid) => s + anchor(fid), 0) / fs.length; - expect(anchor(childId)).toBeCloseTo(mean, 5); - } - // Sanity: the case under test really is asymmetric; Carol's SF mixes a bye - // (centre-anchored) with a match feeder (seam-anchored, larger offset). - expect(offsets['bye-m-r2-1-0']).not.toBe(offsets['m-r1-3']); + const h = { 'm-r1-0': 118, 'm-r1-3': 118, 'm-r2-1': 104, 'm-r3-0': 104 }; + const off = { 'm-r1-0': 66, 'm-r1-3': 66, 'm-r2-1': 59, 'm-r3-0': 59 }; + const tops = computeMetaTops(model.columns, model.feedersById, h, off, { 'm-r2-1': 81 }); + const anchor = (id) => tops[id] + off[id]; // the y the SVG connectors join at + // The final, fed by two: its anchor is the mean of theirs. + expect(anchor('m-r3-0')).toBeCloseTo((anchor('m-r1-0') + anchor('m-r2-1')) / 2, 5); + // Carol's SF, fed by one on its Shiro row: that row is level with it. + expect(tops['m-r2-1'] + 81).toBeCloseTo(anchor('m-r1-3'), 5); + // Sanity: the case under test really is asymmetric; the final's two + // feeders carry different offsets. + expect(off['m-r1-0']).not.toBe(off['m-r2-1']); }); }); + diff --git a/web-mobile/js/__tests__/bracket_match_numbers.test.jsx b/web-mobile/js/__tests__/bracket_match_numbers.test.jsx index f8ad28bda..33269c186 100644 --- a/web-mobile/js/__tests__/bracket_match_numbers.test.jsx +++ b/web-mobile/js/__tests__/bracket_match_numbers.test.jsx @@ -17,8 +17,9 @@ import { buildDisplayModel } from '../bracket.jsx'; // 1. The SPA surfaces exactly the served numbers. Its REAL-match filter (not // hidden && displayRound > 0) has to select exactly the set the engine // numbered (not Hidden && not empty-vs-empty) — those are different -// predicates over the same data — and the bye-slot cards it synthesizes -// itself must stay out of the numbering. +// predicates over the same data. And the columns hold exactly those bouts: +// a competitor who skips a round gets no card of their own (bc-tmfn), so +// nothing unnumbered is drawn between the numbered cards. // 2. The legacy FALLBACK walk still reproduces the engine's numbering exactly. // It runs for brackets persisted before matchNumber existed, which still // render, and now that the primary path consumes the served value it is the @@ -98,21 +99,26 @@ describe('bracket match numbers: served numbering, and the legacy fallback walk' expect(matchNumById).toEqual(expected); expectDense(matchNumById); - // Bye-slot placeholders are cards the model SYNTHESIZES so a structural - // bye reads spatially; they are not bouts, no referee can be sent to one, - // and neither side numbers them. Numbering them would shift every card - // after them out of step with the sheet. - allCards(rounds).filter((m) => m.isByeSlot).forEach((slot) => { - expect(matchNumById[slot.id]).toBeUndefined(); - }); + // The cards drawn are exactly the numbered bouts. A competitor who skips + // a round appears only in the card of the match they first fight, as + // the printed sheets show them (bc-tmfn): the model draws no placeholder + // for the skipped round. A synthesized card would be a box no referee + // can be sent to, drawn among the numbered ones. + expect(allCards(rounds).map((m) => m.id).sort()).toEqual(Object.keys(expected).sort()); }); }); - // The bye-slot assertion above is vacuous on a table of only balanced shapes, - // and it is the model's own invention, so nothing else would notice. - it('the table exercises bye-slot cards at all', () => { - const byes = table.cases.reduce((n, c) => n + allCards(c.rounds).filter((m) => m.isByeSlot).length, 0); - expect(byes, 'no case in the golden produces a structural-bye card: the unnumbered-bye check asserts nothing').toBeGreaterThan(0); + // The cards-are-bouts assertion above is vacuous on a table of only balanced + // shapes: a card that skips nothing never had a placeholder to draw. At + // least one case needs a structural skip, a real match past the first + // column with a side that no match feeds. + it('the table exercises structural skips at all', () => { + const skips = table.cases.reduce((n, c) => { + const real = c.rounds.flat().filter((m) => !m.hidden && (m.displayRound || 0) > 0); + const maxDR = Math.max(...real.map((m) => m.displayRound)); + return n + real.filter((m) => m.displayRound < maxDR && (m.feeders || []).some((f) => f === '')).length; + }, 0); + expect(skips, 'no case in the golden has a structural skip: the cards-are-bouts check asserts nothing').toBeGreaterThan(0); }); // The one case that proves the model CONSUMES the served numbering rather diff --git a/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx b/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx index ced83571d..3555fbc31 100644 --- a/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx +++ b/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx @@ -51,7 +51,7 @@ const columnLabelByMatchId = (rounds) => { const out = {}; model.columns.forEach((col, ci) => { const label = bracketRoundLabel(col[0], ci, model.columns.length); - col.forEach((m) => { if (!m.isByeSlot) out[m.id] = label; }); + col.forEach((m) => { out[m.id] = label; }); }); return out; }; diff --git a/web-mobile/js/__tests__/display_placeholders.test.jsx b/web-mobile/js/__tests__/display_placeholders.test.jsx index d03bacd8d..350045bdc 100644 --- a/web-mobile/js/__tests__/display_placeholders.test.jsx +++ b/web-mobile/js/__tests__/display_placeholders.test.jsx @@ -67,3 +67,51 @@ describe('display helpers reject pool-origin placeholders (mp-turx)', () => { expect(findActiveCourts(tournament, [comp])).toContain('B'); }); }); + +// bc-tmfn: findUpcomingOnCourt is a "what is next" picker (like the placeholder +// rejection above), so it must skip a barred scheduled match (ineligible_match.jsx) +// exactly like it skips a pool/"Winner of" placeholder -- neither can be fought +// as scheduled. +describe('findUpcomingOnCourt skips a barred match (bc-tmfn)', () => { + it('excludes a barred pool match, leaving its unbarred sibling', () => { + const comp = { + id: 'c1', name: 'Comp', format: 'mixed', + poolMatches: [ + { id: 'p1', court: 'A', status: 'scheduled', sideA: 'Alice', sideB: 'Bob', ineligibleSides: { a: 'kiken-voluntary' } }, + { id: 'p2', court: 'A', status: 'scheduled', sideA: 'Carol', sideB: 'Dave' }, + ], + bracket: { rounds: [] }, + }; + const ids = findUpcomingOnCourt([comp], 'A', 10).map((m) => m.id); + expect(ids).not.toContain('p1'); + expect(ids).toContain('p2'); + }); + + it('excludes a barred bracket match', () => { + const comp = { + id: 'c2', name: 'Comp2', format: 'knockout', + poolMatches: [], + bracket: { + preview: false, + rounds: [[ + { id: 'b0', court: 'A', status: 'scheduled', sideA: 'Alice', sideB: 'Bob', ineligibleSides: { b: 'fusenpai' } }, + ]], + }, + }; + expect(findUpcomingOnCourt([comp], 'A', 10).map((m) => m.id)).not.toContain('b0'); + }); + + it('does NOT exclude a match whose stamp is on a non-scheduled status (barredSides gates on status)', () => { + // Defensive: isBarredMatch itself already returns false off `scheduled`, + // so this just pins that findUpcomingOnCourt never sees a running/ + // completed match here in the first place (its own status filter). + const comp = { + id: 'c3', name: 'Comp3', format: 'mixed', + poolMatches: [ + { id: 'p1', court: 'A', status: 'running', sideA: 'Alice', sideB: 'Bob', ineligibleSides: { a: 'kiken-voluntary' } }, + ], + bracket: { rounds: [] }, + }; + expect(findUpcomingOnCourt([comp], 'A', 10).map((m) => m.id)).not.toContain('p1'); + }); +}); diff --git a/web-mobile/js/__tests__/ineligible_match.test.jsx b/web-mobile/js/__tests__/ineligible_match.test.jsx new file mode 100644 index 000000000..6ebac3e22 --- /dev/null +++ b/web-mobile/js/__tests__/ineligible_match.test.jsx @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { + barredSides, isBarredMatch, awaitedDefaultWin, defaultWinDecisionBody, + barredNote, defaultWinActionLabel, bothBarredDrawable, bothBarredDrawAction, +} from '../ineligible_match.jsx'; + +// The server stamps `ineligibleSides` on scheduled matches only; the sides +// arrive normalized ({id, name}) on every surface that reads them. +const match = (over = {}) => ({ + id: 'Pool A-2', compId: 'c1', status: 'scheduled', + sideA: { id: 'u', name: 'Umi E' }, sideB: { id: 'y', name: 'Yama C' }, + ...over, +}); + +describe('ineligible_match', () => { + it('reads nothing from a match with no stamp', () => { + const m = match(); + expect(isBarredMatch(m)).toBe(false); + expect(awaitedDefaultWin(m)).toBeNull(); + expect(defaultWinDecisionBody(m)).toBeNull(); + expect(barredNote(m)).toBe(''); + }); + + it('ignores a stamp on a match that is not scheduled', () => { + for (const status of ['running', 'completed']) { + const m = match({ status, ineligibleSides: { b: 'kiken-voluntary' } }); + expect(barredSides(m)).toEqual({ a: '', b: '' }); + expect(isBarredMatch(m)).toBe(false); + } + }); + + it('records the default win for the opponent as fusensho naming the barred side', () => { + const onB = match({ ineligibleSides: { b: 'kiken-voluntary' } }); + expect(isBarredMatch(onB)).toBe(true); + expect(defaultWinDecisionBody(onB)).toEqual({ + decision: 'fusensho', decisionBy: 'shiro', decisionReason: 'auto: Yama C withdrawn', + }); + expect(defaultWinActionLabel(onB)).toBe('Record default win for Umi E'); + + const onA = match({ ineligibleSides: { a: 'fusenpai' } }); + expect(defaultWinDecisionBody(onA).decisionBy).toBe('aka'); + expect(defaultWinActionLabel(onA)).toBe('Record default win for Yama C'); + }); + + it('says who cannot fight and what to do, by the decision that barred them', () => { + expect(barredNote(match({ ineligibleSides: { b: 'kiken-voluntary' } }))) + .toBe('Yama C withdrew: record the default win.'); + expect(barredNote(match({ ineligibleSides: { b: 'kiken' } }))) + .toBe('Yama C withdrew: record the default win.'); + expect(barredNote(match({ ineligibleSides: { b: 'fusenpai' } }))) + .toBe('Yama C did not appear earlier: record the default win.'); + const injured = match({ ineligibleSides: { b: 'kiken-injury' } }); + expect(barredNote(injured)).toBe('Yama C withdrew injured: reinstate them or record the default win.'); + expect(awaitedDefaultWin(injured).reinstateable).toBe(true); + }); + + it('offers no default win when both sides are barred', () => { + const m = match({ ineligibleSides: { a: 'fusenpai', b: 'kiken-voluntary' } }); + expect(isBarredMatch(m)).toBe(true); + expect(awaitedDefaultWin(m)).toBeNull(); + expect(defaultWinDecisionBody(m)).toBeNull(); + expect(defaultWinActionLabel(m)).toBe(''); + expect(barredNote(m)).toBe('Both withdrew earlier: neither can fight this match.'); + }); + + // bc-cse: "If both competitors withdraw, neither receives a win or + // points" -- a pool/league match can be RECORDED as drawn; a knockout + // bracket match cannot (it needs a winner), so it names the problem + // instead and offers no action. + describe('both sides barred: the draw action (bc-cse)', () => { + const both = (over = {}) => match({ ineligibleSides: { a: 'fusenpai', b: 'kiken-voluntary' }, ...over }); + + // The server accepts the draw for a pool or league id only + // (engine.IsPoolMatchID: "Pool " prefix), so the offer follows the id. + it('bothBarredDrawable is true for a pool/league id, false for a knockout or Swiss id', () => { + expect(bothBarredDrawable(match({ phase: 'pool' }))).toBe(true); + expect(bothBarredDrawable(match({ id: 'm-r1-0', phase: 'bracket' }))).toBe(false); + expect(bothBarredDrawable(match({ id: 'Swiss-R2-0', phase: 'pool' }))).toBe(false); + }); + + it('offers the draw action for a pool/league match, naming both sides in the reason', () => { + const action = bothBarredDrawAction(both({ phase: 'pool' })); + expect(action).toEqual({ + label: 'Record as drawn (neither can fight)', + body: { decision: 'hikiwake', decisionReason: 'auto: Umi E and Yama C withdrawn' }, + }); + }); + + it('offers no draw action for a knockout bracket match, and the note says why', () => { + const m = both({ id: 'm-r1-0', phase: 'bracket' }); + expect(bothBarredDrawAction(m)).toBeNull(); + expect(barredNote(m)).toBe('Neither can fight: correct the earlier withdrawal or the draw.'); + }); + + it('offers no draw action when only one side is barred', () => { + expect(bothBarredDrawAction(match({ ineligibleSides: { b: 'kiken-voluntary' }, phase: 'pool' }))).toBeNull(); + }); + }); +}); diff --git a/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx b/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx index 8890d2525..18b559225 100644 --- a/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx +++ b/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx @@ -103,13 +103,22 @@ describe('koTieBlocked does not gate the bout submit', () => { describe('canReopenKachinukiMatch', () => { it('renders only on a COMPLETED kachinuki match', () => { - expect(canReopenKachinukiMatch({ isKachinuki: true, isComplete: true })).toBe(true); + expect(canReopenKachinukiMatch({ isKachinuki: true, isComplete: true, recordedWithdrawal: false })).toBe(true); }); it('is false on a running kachinuki match (nothing to reopen)', () => { - expect(canReopenKachinukiMatch({ isKachinuki: true, isComplete: false })).toBe(false); + expect(canReopenKachinukiMatch({ isKachinuki: true, isComplete: false, recordedWithdrawal: false })).toBe(false); }); it('is false for non-kachinuki (backend 400s the endpoint; button must not render)', () => { - expect(canReopenKachinukiMatch({ isKachinuki: false, isComplete: true })).toBe(false); + expect(canReopenKachinukiMatch({ isKachinuki: false, isComplete: true, recordedWithdrawal: false })).toBe(false); + }); + // bc-tmfn: a withdrawal-decided encounter reopens through RecordedWithdrawal, + // which states that the withdrawn team becomes eligible again and asks why; + // the silent one-tap Reopen must not be offered beside it. + it('is false on a kachinuki encounter a withdrawal decided', () => { + expect(canReopenKachinukiMatch({ isKachinuki: true, isComplete: true, recordedWithdrawal: true })).toBe(false); + }); + it('refuses a call that does not say whether a withdrawal is recorded', () => { + expect(() => canReopenKachinukiMatch({ isKachinuki: true, isComplete: true })).toThrow(TypeError); }); }); diff --git a/web-mobile/js/__tests__/match_labels_golden.test.jsx b/web-mobile/js/__tests__/match_labels_golden.test.jsx new file mode 100644 index 000000000..e73869f97 --- /dev/null +++ b/web-mobile/js/__tests__/match_labels_golden.test.jsx @@ -0,0 +1,114 @@ +// JS half of the shared Go/JS golden table for operator match labels. Go +// half: TestOperatorMatchLabel_GoldenTable (internal/engine/match_label_test.go). +// Schema and rationale: internal/engine/testdata/match_labels.json's own +// "_comment" field. +// +// Four label-producing surfaces are pinned against the ONE golden table: +// - pool/league/swiss rows: scoreRowMatchLabel (pool_ids.jsx), the score +// editor's own composition of poolNameOf + poolMatchNumberOf + +// window.poolLabel-equivalent naming. +// - pool daihyosen/tiebreaker rows: poolMatchNumberOf returns 0 for them, +// so scoreRowMatchLabel renders no match-number label at all (the JS +// side shows a separate "DH" tag badge instead -- see the fixture's own +// comment for why this file does not try to reproduce the Go-only +// " daihyosen"/" tiebreaker" spelling as a render). +// - knockout rows: matchLabel (write_result.jsx) is the number/id prefix +// of the golden label; the round qualifier ("(Semifinals)", "(Final)") +// is composed server-side (engine.MatchLabel) and carried in `label` +// verbatim, which matchLabel does not add. +// - bronze: the one knockout row with NO round qualifier to strip, so +// matchLabel matches the golden label exactly. +// +// Also pinned: poolPosition (viewer_utils.jsx's compMatches, derived by +// array-index within a poolName group) agrees with poolMatchNumberOf (a pure +// id-parse) once matches are supplied to compMatches in id order -- the +// precondition the server always meets in production. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { scoreRowMatchLabel, poolMatchNumberOf, poolNameOf } from '../pool_ids.jsx'; +import { matchLabel } from '../write_result.jsx'; +import { compMatches } from '../viewer_utils.jsx'; + +describe('match labels: the JS surfaces mirror the Go operator label (golden table)', () => { + const table = JSON.parse( + readFileSync( + resolve(__dirname, '..', '..', '..', 'internal', 'engine', 'testdata', 'match_labels.json'), + 'utf8' + ) + ); + + // Load-bearing: it.each over an empty array silently produces zero tests + // (no red), so a degraded table needs its own failure. + it('the shared golden table is present and non-empty', () => { + expect( + table.cases?.length, + 'internal/engine/testdata/match_labels.json parsed to zero cases: the mirror would assert nothing' + ).toBeGreaterThan(0); + }); + + // bc-cse: poolMatchNumberOf alone is NOT the right filter -- it matches any + // id ending in "-" (pool_ids.jsx's own documented caveat), which + // also matches a knockout id like "m-r2-0". Gate on format too. + describe('pool/league/swiss rows: scoreRowMatchLabel matches the golden label exactly', () => { + const rows = table.cases.filter((c) => c.format !== 'knockout' && poolMatchNumberOf(c.id) > 0); + + it('the table exercises at least one pool/league/swiss row', () => { + expect(rows.length).toBeGreaterThan(0); + }); + + it.each(rows)('$id ($format)', ({ id, format, label }) => { + const m = { phase: 'pool', id, compFormat: format, poolName: poolNameOf(id) }; + expect(scoreRowMatchLabel(m)).toBe(label); + }); + }); + + describe('pool daihyosen/tiebreaker rows: no match-number label on the JS side', () => { + const rows = table.cases.filter((c) => c.id.startsWith('Pool ') && poolMatchNumberOf(c.id) === 0); + + it('the table exercises at least one DH/TB row', () => { + expect(rows.length).toBeGreaterThan(0); + }); + + it.each(rows)('$id', ({ id, format }) => { + expect(poolMatchNumberOf(id)).toBe(0); + const m = { phase: 'pool', id, compFormat: format, poolName: poolNameOf(id) }; + expect(scoreRowMatchLabel(m)).toBe(''); + }); + }); + + describe('knockout rows: matchLabel is the golden label\'s number/id prefix', () => { + const rows = table.cases.filter((c) => c.format === 'knockout' && c.id !== 'm-bronze'); + + it('the table exercises at least one knockout row', () => { + expect(rows.length).toBeGreaterThan(0); + }); + + it.each(rows)('$id', ({ id, label, matchNumber }) => { + const prefix = matchLabel({ number: matchNumber || 0, id }); + expect(label.startsWith(prefix)).toBe(true); + }); + }); + + it('bronze: matchLabel matches the golden label exactly (no round qualifier to strip)', () => { + const bronze = table.cases.find((c) => c.id === 'm-bronze'); + expect(bronze).toBeTruthy(); + expect(matchLabel({ id: bronze.id })).toBe(bronze.label); + }); + + describe('poolPosition (compMatches) agrees with poolMatchNumberOf over an id-ordered list', () => { + const rows = table.cases.filter((c) => c.format !== 'knockout' && poolMatchNumberOf(c.id) > 0); + + it.each(rows)('$id', ({ id }) => { + const pool = poolNameOf(id); + const n = poolMatchNumberOf(id); + // The id's own siblings, 0-indexed, in id order -- the shape + // compMatches receives from a real server payload. + const poolMatches = Array.from({ length: n }, (_, i) => ({ id: `${pool}-${i}` })); + const matches = compMatches({ id: 'c1', name: 'Cup', status: 'active', poolMatches }); + const target = matches.find((x) => x.id === id); + expect(target).toBeTruthy(); + expect(target.poolPosition).toBe(poolMatchNumberOf(id)); + }); + }); +}); diff --git a/web-mobile/js/__tests__/match_scoreboard.test.jsx b/web-mobile/js/__tests__/match_scoreboard.test.jsx index e0537ad03..8bd896843 100644 --- a/web-mobile/js/__tests__/match_scoreboard.test.jsx +++ b/web-mobile/js/__tests__/match_scoreboard.test.jsx @@ -831,4 +831,91 @@ describe('match_scoreboard components', () => { expect(dhRow.sub.teamB).toBe('White Team'); expect(dhRow.sub.teamA).toBe('Red Team'); }); + + // bc-tmfn: a team match a match-level default-win decision closed credits + // every numbered bout with no result of its own to the OTHER side from + // decisionBy. Kachinuki is unaffected (see kachinuki_scoreboard.test.jsx). + describe('TeamScoreboard: bc-tmfn default-win credit', () => { + const withdrawal = { status: 'completed', decision: 'kiken-voluntary', decisionBy: 'shiro' }; + + it('substitutes a synthesised fusensho row for an unfought numbered bout, "done" state', () => { + const subResults = [ + { position: 1, ipponsB: ['M'], ipponsA: [] }, // actually fought: untouched + ]; + const tree = runtime.mount(TeamScoreboard, { + subResults, lineupA: null, lineupB: null, teamSize: 3, showDH: false, + matchSideA: 'TeamA', matchSideB: 'TeamB', ...withdrawal, + }); + const rows = boutRows(tree); + expect(rows.length).toBe(3); + // Bout 1 was fought for real: not touched. + expect(rows[0].sub).toMatchObject({ position: 1 }); + expect(rows[0].sub.decision).not.toBe('fusensho'); + expect(rows[0].state).toBe('done'); + // Bouts 2 and 3 carry no result: credited to TeamA (Aka), the side + // OPPOSITE decisionBy="shiro" — synthesised as a fusensho row and + // rendered "done", exactly like a real per-bout fusensho. + expect(rows[1].sub).toMatchObject({ position: 2, decision: 'fusensho', winner: 'TeamA' }); + expect(rows[1].state).toBe('done'); + expect(rows[2].sub).toMatchObject({ position: 3, decision: 'fusensho', winner: 'TeamA' }); + expect(rows[2].state).toBe('done'); + }); + + it('does not credit while the match is still running', () => { + const tree = runtime.mount(TeamScoreboard, { + subResults: [], lineupA: null, lineupB: null, teamSize: 2, showDH: false, + matchSideA: 'TeamA', matchSideB: 'TeamB', + status: 'running', decision: 'kiken-voluntary', decisionBy: 'shiro', + }); + const rows = boutRows(tree); + expect(rows.every(r => r.sub.decision !== 'fusensho')).toBe(true); + expect(rows.every(r => r.state !== 'done')).toBe(true); + }); + + it('does not credit a kachinuki match even with a matching decision', () => { + const tree = runtime.mount(TeamScoreboard, { + subResults: [], lineupA: null, lineupB: null, teamSize: 2, showDH: false, + matchSideA: 'TeamA', matchSideB: 'TeamB', kachinuki: true, ...withdrawal, + }); + const rows = boutRows(tree); + expect(rows.every(r => r.sub.decision !== 'fusensho')).toBe(true); + }); + + it('does not credit a decision outside the default-win class', () => { + const tree = runtime.mount(TeamScoreboard, { + subResults: [], lineupA: null, lineupB: null, teamSize: 2, showDH: false, + matchSideA: 'TeamA', matchSideB: 'TeamB', + status: 'completed', decision: 'fought', decisionBy: 'shiro', + }); + const rows = boutRows(tree); + expect(rows.every(r => r.sub.decision !== 'fusensho')).toBe(true); + }); + + it('the summary row places the withdrawn team\'s mark on the INNER side, a plain string prop the caller computed', () => { + const tree = runtime.mount(TeamScoreboard, { + subResults: [{ position: 1 }], lineupA: null, lineupB: null, teamSize: 1, showDH: false, + shiroName: 'White Team', akaName: 'Red Team', + shiroMark: 'Kiken', + }); + const shiroMarkEl = findInTree(tree, n => n?.props?.['data-testid'] === 'team-summary-mark-shiro'); + expect(shiroMarkEl).toBeTruthy(); + expect(collectText(shiroMarkEl)).toBe('Kiken'); + // Aka got no mark prop: nothing renders there. + const akaMarkEl = findInTree(tree, n => n?.props?.['data-testid'] === 'team-summary-mark-aka'); + expect(akaMarkEl).toBeFalsy(); + }); + + it('IV/PW aggregate (fallback, no server teamResult) includes the credited bouts', () => { + const subResults = [{ position: 1 }, { position: 2 }]; + const tree = runtime.mount(TeamScoreboard, { + subResults, lineupA: null, lineupB: null, teamSize: 2, showDH: false, + matchSideA: 'TeamA', matchSideB: 'TeamB', ...withdrawal, + }); + // Both bouts unfought → credited to TeamA (Aka): IV 0-2, PW 0-4. + const summary = findInTree(tree, n => n?.props?.['data-testid'] === 'team-summary'); + expect(collectText(summary)).toContain('IV0'); + expect(collectText(summary)).toContain('2'); + expect(collectText(summary)).toContain('4'); + }); + }); }); diff --git a/web-mobile/js/__tests__/name_mark_survives_clipping.test.jsx b/web-mobile/js/__tests__/name_mark_survives_clipping.test.jsx new file mode 100644 index 000000000..98ec9a89c --- /dev/null +++ b/web-mobile/js/__tests__/name_mark_survives_clipping.test.jsx @@ -0,0 +1,151 @@ +// bc-cse (fix #4): teamNameMark (match_scoreboard.jsx) and barredNameMark +// (barred_chip.jsx) wrap a NumberedName clip element inside a +// BLOCK-ELLIPSIS cell (VSchedItem's .n, TWMatch's .tw-match__name, the admin +// Scores row's .name, the TV headline -- see the sibling render suite for the +// latter two). A long Shiro name used to clip the trailing mark/chip away; +// on Aka, whose mark leads, it pushed the mark past the visible width. +// +// jsdom cannot measure the actual clip (see vsched_number_survives_truncation +// .test.jsx, which pins the identical class of bug for the competitor number +// chip). What a regression here actually undoes is the STRUCTURE this file +// pins: the cell becomes a flex row (msb-name--labelled) so only the name +// shrinks/ellipsises, and the mark is a real, flex:none SIBLING element -- +// never text baked into the same run. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { makeReactive } from './helpers/reactive_react.js'; +import { findAll, hasClass } from './helpers/vdom.js'; +import { readStylesheet, cssBlock } from './helpers/source.js'; + +const realReact = global.React; + +describe('a team-match name mark survives clipping (bc-cse #4)', () => { + describe('CSS: .msb-name--labelled supplies the spacing and the flex sizing', () => { + const css = readStylesheet(); + + it('is a flex row with a gap -- not the {" "} text-node spacer teamNameMark/barredNameMark still emit, which CSS Flexbox treats as ZERO-SIZED between flex items', () => { + const block = cssBlock(css, '.msb-name--labelled'); + expect(block).toMatch(/display:\s*flex/); + expect(block).toMatch(/gap:\s*[^;]+;/); + }); + + it('the mark/chip classes are flex:none so they never shrink', () => { + const block = cssBlock(css, '.msb-name--labelled .msb-member-label,\n.msb-name--labelled .sb-result-mark,\n.msb-name--labelled .barred-chip') + || cssBlock(css, '.msb-name--labelled .sb-result-mark'); + expect(block).toMatch(/flex-shrink:\s*0/); + }); + + it('the NumberedName clip wrapper can actually shrink inside the row (min-width: 0)', () => { + const block = cssBlock(css, '.msb-name--labelled .numbered-name--clip'); + expect(block).toMatch(/min-width:\s*0/); + }); + }); + + describe('VSchedItem (viewer_match.jsx): .n becomes a flex row', () => { + let runtime, VSchedItem; + + beforeEach(async () => { + runtime = makeReactive(); + global.React = runtime.React; + global.window = global.window || {}; + global.window.matchScoreStr = vi.fn(() => ''); + global.window.boutMiddle = vi.fn(() => 'vs'); + global.window.queueLabelCompact = null; + global.window.teamMatchMarks = vi.fn(() => ({ shiro: 'Kiken', aka: '' })); + vi.resetModules(); + ({ VSchedItem } = await import('../viewer_match.jsx')); + }); + + afterEach(() => { + runtime.unmount(); + global.React = realReact; + delete global.window.matchScoreStr; + delete global.window.boutMiddle; + delete global.window.queueLabelCompact; + delete global.window.teamMatchMarks; + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it('both .n cells carry msb-name--labelled, and the mark is a real sb-result-mark sibling, not joined into the name string', () => { + const m = { + id: 'm1', status: 'completed', court: 'A', decision: 'kiken-voluntary', + subResults: [{ position: 1 }], + sideA: { id: 'p-aka', name: 'AOKI TARO' }, + sideB: { id: 'p-shiro', name: 'A VERY LONG SHIRO COMPETITOR NAME' }, + }; + const tree = runtime.mount(VSchedItem, { m, tweaks: {} }); + + const cells = findAll(tree, n => hasClass(n, 'n')); + expect(cells).toHaveLength(2); + for (const cell of cells) expect(hasClass(cell, 'msb-name--labelled')).toBe(true); + + // Shiro carries the Kiken mark (bc-tmfn convention: shiro=sideB wins). + const marks = findAll(tree, n => hasClass(n, 'sb-result-mark')); + expect(marks).toHaveLength(1); + expect(marks[0].props.children).toBe('Kiken'); + + // The mark is a SIBLING, never inside the name prop the ellipsis targets. + const clipNames = findAll(tree, n => n.props && n.props.clip === true); + expect(clipNames.length).toBeGreaterThan(0); + for (const n of clipNames) { + expect(typeof n.props.name).toBe('string'); + expect(n.props.name).not.toContain('Kiken'); + } + }); + }); + + describe('TWMatch (viewer_schedule.jsx): .tw-match__name becomes a flex row, the mark its own element', () => { + let runtime, TWMatch; + + beforeEach(async () => { + runtime = makeReactive(); + global.React = runtime.React; + global.window = global.window || {}; + global.window.matchScoreStr = vi.fn(() => ''); + global.window.queueLabelCompact = null; + global.window.teamMatchMarks = vi.fn(() => ({ shiro: '', aka: 'Fus.' })); + vi.resetModules(); + ({ TWMatch } = await import('../viewer_schedule.jsx')); + }); + + afterEach(() => { + runtime.unmount(); + global.React = realReact; + delete global.window.matchScoreStr; + delete global.window.queueLabelCompact; + delete global.window.teamMatchMarks; + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it('carries msb-name--labelled, and the mark is a real sb-result-mark sibling of an msb-name__text span -- not appended to the string', () => { + const m = { + id: 'm2', status: 'completed', court: 'A', decision: 'fusenpai', decisionBy: 'aka', + subResults: [{ position: 1 }], + sideA: { id: 'p-aka', name: 'A VERY LONG AKA COMPETITOR NAME', number: 'K1' }, + sideB: { id: 'p-shiro', name: 'ITO KEN', number: 'K2' }, + }; + const tree = runtime.mount(TWMatch, { m }); + + const cells = findAll(tree, n => hasClass(n, 'tw-match__name')); + expect(cells.length).toBeGreaterThanOrEqual(2); + for (const cell of cells) expect(hasClass(cell, 'msb-name--labelled')).toBe(true); + + // Aka carries the Fus. mark. It must be its own element (this is the + // regression fix #4 closes for THIS host specifically: baking the mark + // into the plain string, rather than a separate sb-result-mark + // element, used to clip it away). + const marks = findAll(tree, n => hasClass(n, 'sb-result-mark')); + expect(marks).toHaveLength(1); + expect(marks[0].props.children).toBe('Fus.'); + + // The name+number STRING sits in its own msb-name__text sibling, so it + // alone ellipsises; the mark never shares that text run. + const textEls = findAll(tree, n => hasClass(n, 'msb-name__text')); + expect(textEls.length).toBeGreaterThan(0); + const akaText = textEls.find(n => String(n.children || n.props?.children).includes('AKA COMPETITOR')); + expect(akaText).toBeTruthy(); + expect(String(akaText.children || akaText.props?.children)).not.toContain('Fus.'); + }); + }); +}); diff --git a/web-mobile/js/__tests__/patch.test.jsx b/web-mobile/js/__tests__/patch.test.jsx index d39a5591e..1e1d8d7c0 100644 --- a/web-mobile/js/__tests__/patch.test.jsx +++ b/web-mobile/js/__tests__/patch.test.jsx @@ -292,6 +292,60 @@ describe('applyPatch', () => { expect(next.poolMatches[1]).not.toBe(prev.poolMatches[1]); expect(next.poolMatches[2]).toBe(prev.poolMatches[2]); }); + + // bc-tmfn: the server derives `ineligibleSides` from eligibility, which + // only a competitor_status_updated refetch changes (see + // ineligible_match.jsx). An SSE match_updated patch for that match is not + // that refetch, so it may arrive without the field -- and when it does, + // the stored stamp must survive the merge. + describe('ineligibleSides survives a patch', () => { + const prevWithStamp = () => ({ + poolMatches: [ + { id: "p1", court: "A", scheduledAt: "09:30", status: "scheduled", ineligibleSides: { b: "fusenpai" } }, + ], + }); + + it('keeps the stamp when the patch omits the field entirely', () => { + const next = applyPatch(prevWithStamp(), { data: { result: { id: "p1", court: "A" } } }); + expect(next.poolMatches[0].ineligibleSides).toEqual({ b: "fusenpai" }); + }); + + // bc-cse: the sibling test that sent `ineligibleSides: null` explicitly + // is gone -- the field is a Go pointer with `omitempty` + // (internal/state/models.go), so an absent stamp is OMITTED from the + // wire, never sent as a literal `null`; that payload shape cannot occur + // and the wrapper that used to defend against it was removed with it. + + it('a patch carrying a real ineligibleSides value wins (server says nobody is barred any more)', () => { + const next = applyPatch(prevWithStamp(), { data: { result: { id: "p1", ineligibleSides: {} } } }); + expect(next.poolMatches[0].ineligibleSides).toEqual({}); + }); + + it('a patch carrying a different ineligibleSides value wins', () => { + const next = applyPatch(prevWithStamp(), { data: { result: { id: "p1", ineligibleSides: { a: "kiken-injury" } } } }); + expect(next.poolMatches[0].ineligibleSides).toEqual({ a: "kiken-injury" }); + }); + }); + + // bc-tmfn: a patch that flips whether a still-scheduled match is barred + // must re-rank the per-court queue, same as a status/court/scheduledAt + // change does -- recomputeQueuePositions gives a barred match position 0 + // and skips it, so its sibling must move up to take that slot. + it('re-ranks per-court siblings when a patch flips a still-scheduled match to barred', () => { + const prev = { + poolMatches: [ + // p1 was next-up (qp 1) before the withdrawal that bars it. + { id: "p1", court: "A", scheduledAt: "09:00", status: "scheduled", queuePosition: 1 }, + { id: "p1b", court: "A", scheduledAt: "09:45", status: "scheduled" }, + ], + }; + const next = applyPatch(prev, { + data: { result: { id: "p1", status: "scheduled", ineligibleSides: { a: "fusenpai" } } }, + }); + const byId = Object.fromEntries(next.poolMatches.map(m => [m.id, m])); + expect(byId.p1.queuePosition).toBe(0); + expect(byId.p1b.queuePosition).toBe(1); + }); }); describe('recomputeQueuePositions', () => { @@ -370,6 +424,34 @@ describe('recomputeQueuePositions', () => { expect(out[0].queuePosition).toBe(0); expect(out[1].queuePosition).toBe(0); }); + + // bc-tmfn: a barred match (ineligible_match.jsx -- a scheduled match whose + // competitor withdrew earlier) cannot be fought, so it holds position 0 + // and is skipped by the counter: later matches on the same court move up + // to take the slot it would otherwise have consumed. + it('gives a barred scheduled match position 0 and does not count it', () => { + const matches = [ + // Stale qp 5 (as if the withdrawal barred it after it already held a + // real position): the recompute must zero it, not just leave it be. + { id: "a1", court: "A", status: "scheduled", queuePosition: 5, ineligibleSides: { a: "kiken-voluntary" } }, + { id: "a2", court: "A", status: "scheduled" }, + { id: "a3", court: "A", status: "scheduled" }, + ]; + const out = recomputeQueuePositions(matches); + expect(out[0].queuePosition).toBe(0); + expect(out[1].queuePosition).toBe(1); + expect(out[2].queuePosition).toBe(2); + }); + + it('a match with an ineligibleSides stamp that is NOT scheduled counts normally (barredSides gates on status)', () => { + const matches = [ + { id: "a1", court: "A", status: "running", queuePosition: 3, ineligibleSides: { a: "kiken-voluntary" } }, + { id: "a2", court: "A", status: "scheduled" }, + ]; + const out = recomputeQueuePositions(matches); + expect(out[0].queuePosition).toBe(0); // running is never counted anyway + expect(out[1].queuePosition).toBe(1); + }); }); // FR-025: bracket-side queue position recompute. Mirrors the pool helper @@ -472,6 +554,25 @@ describe('recomputeBracketQueuePositions', () => { expect(out.rounds[0][1].queuePosition).toBe(0); }); + // bc-tmfn: mirrors the pool helper's barred-skip test above. + it('gives a barred scheduled bracket match position 0 and does not count it', () => { + const bracket = { + rounds: [ + [ + { id: "r1m1", court: "A", status: "scheduled", queuePosition: 5, ineligibleSides: { b: "fusenpai" } }, + { id: "r1m2", court: "A", status: "scheduled" }, + ], + [ + { id: "r2m1", court: "A", status: "scheduled" }, + ], + ], + }; + const out = recomputeBracketQueuePositions(bracket); + expect(out.rounds[0][0].queuePosition).toBe(0); + expect(out.rounds[0][1].queuePosition).toBe(1); + expect(out.rounds[1][0].queuePosition).toBe(2); + }); + it('handles nil / empty / malformed bracket gracefully', () => { expect(recomputeBracketQueuePositions(null)).toBeNull(); expect(recomputeBracketQueuePositions(undefined)).toBeUndefined(); diff --git a/web-mobile/js/__tests__/queue_label.test.jsx b/web-mobile/js/__tests__/queue_label.test.jsx index a1d7e525f..8bc0219c9 100644 --- a/web-mobile/js/__tests__/queue_label.test.jsx +++ b/web-mobile/js/__tests__/queue_label.test.jsx @@ -90,6 +90,19 @@ describe('queueLabel (full form)', () => { } } }); + + // bc-tmfn: a scheduled match whose competitor is barred (withdrew earlier; + // ineligible_match.jsx) cannot be fought, so it never reads "Next up" / + // "N before yours" whatever its queuePosition says. queuePosition: 1 here + // is a realistic transient -- the client state before the recompute in + // patch.jsx catches up -- not just a defensive belt-and-braces value. + it('returns "" for a barred scheduled match even with queuePosition 1', () => { + expect(queueLabel({ status: 'scheduled', queuePosition: 1, ineligibleSides: { a: 'kiken-voluntary' } })).toBe(''); + }); + + it('returns "" for a barred scheduled match falling back to scheduledAt', () => { + expect(queueLabel({ status: 'scheduled', scheduledAt: '10:30', ineligibleSides: { b: 'fusenpai' } })).toBe(''); + }); }); describe('queueLabelCompact (pill form)', () => { @@ -130,4 +143,10 @@ describe('queueLabelCompact (pill form)', () => { it('returns null for null input (defensive)', () => { expect(queueLabelCompact(null)).toBeNull(); }); + + // bc-tmfn: mirrors queueLabel's barred-match guard above -- TWMatch's pill + // and VSchedItem both read queueLabelCompact. + it('returns null for a barred scheduled match even with queuePosition 1', () => { + expect(queueLabelCompact({ status: 'scheduled', queuePosition: 1, ineligibleSides: { a: 'kiken-voluntary' } })).toBeNull(); + }); }); diff --git a/web-mobile/js/__tests__/render/admin_bracket_layout.render.test.jsx b/web-mobile/js/__tests__/render/admin_bracket_layout.render.test.jsx new file mode 100644 index 000000000..8689b0c23 --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_bracket_layout.render.test.jsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +// The admin Bracket page (operator decision): the bracket takes the full width +// and its columns narrow to fit (.bracket-canvas--fit), the scoring panel sits +// BELOW it, and picking a match scrolls that panel into view. The 3rd-place +// card sits in a row of column slots sized like the tree's columns, the last +// one under the final. + +const STUBBED_GLOBALS = { + ScoreEditorModal: () =>
, + BracketTree: () => null, // per-test override drives the pick + MatchCard: ({ match }) =>
, + bracketColumnCount: () => 3, + CourtPicker: () => null, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + subscribeToEvents: () => () => {}, + }, + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let AdminBracket; +let scrolled; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_competition_bracket.jsx'); + AdminBracket = window.AdminBracket; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + scrolled = []; + // jsdom has no scrollIntoView: record which element asked to be shown. + Element.prototype.scrollIntoView = function scrollIntoView() { scrolled.push(this); }; +}); + +const match = (id) => ({ + id, compId: 'c1', status: 'scheduled', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Suzuki' }, +}); + +// BracketTree probe: expose the tree's onMatchClick as a button. +const pickable = (m) => (props) => ( + +); + +const renderPage = (bracket) => render( + +); + +describe('admin Bracket page layout', () => { + it('narrows the tree to fit and scrolls the panel below it into view when a match is picked', async () => { + const m = match('m1'); + window.BracketTree = pickable(m); + let utils; + await act(async () => { utils = renderPage({ rounds: [[m]] }); }); + expect(utils.container.querySelector('.bracket-canvas').classList.contains('bracket-canvas--fit')).toBe(true); + expect(scrolled).toHaveLength(0); + + await act(async () => { fireEvent.click(screen.getByTestId('pick')); }); + expect(scrolled).toHaveLength(1); + expect(scrolled[0].classList.contains('bracket-layout__panel')).toBe(true); + + // A later update of the same pick (an SSE refresh) does not scroll again. + await act(async () => { + utils.rerender( + + ); + }); + expect(scrolled).toHaveLength(1); + }); + + it('puts the 3rd-place card in the last of the column slots, under the final', async () => { + window.BracketTree = () => null; + let utils; + await act(async () => { + utils = renderPage({ rounds: [[match('q1'), match('q2')], [match('s1')], [match('f1')]], thirdPlaceMatch: match('b1') }); + }); + const slots = utils.container.querySelectorAll('.bracket-bronze-row > .bracket-bronze-row__slot'); + expect(slots).toHaveLength(3); + expect(slots[0].children).toHaveLength(0); + expect(slots[1].children).toHaveLength(0); + expect(slots[2].querySelector('[data-testid="card-b1"]')).toBeTruthy(); + expect(slots[2].textContent).toContain('3rd Place Match'); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx index eb4753777..3ec9993b9 100644 --- a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx @@ -186,6 +186,19 @@ describe('AdminCompetition section render-smoke (mp-hpe3 split characterization) }); }); +// The page keeps its 1400px width everywhere except the Bracket section, which +// takes the whole window so a six-round tree (33 to 64 competitors) has room +// on a large monitor (operator decision). +describe('AdminCompetition page width', () => { + it('lifts the 1400px width only on the Bracket section', async () => { + const page = (c) => c.querySelector('.page'); + const { container: bracket } = await mountSection('bracket'); + expect(page(bracket).style.maxWidth).toBe('none'); + const { container: overview } = await mountSection('overview'); + expect(page(overview).style.maxWidth).toBe('1400px'); + }); +}); + // mp-gy6g: "Complete competition" is the only trigger for a bracket-based // (knockout, or mixed-after-knockout) competition to ever reach status // "completed" — MaybeAutoCompletePools only auto-transitions League on its diff --git a/web-mobile/js/__tests__/render/admin_pools_chusen.render.test.jsx b/web-mobile/js/__tests__/render/admin_pools_chusen.render.test.jsx index bc98ed5b2..55fc78c79 100644 --- a/web-mobile/js/__tests__/render/admin_pools_chusen.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_pools_chusen.render.test.jsx @@ -52,12 +52,15 @@ // non-colliding group is unaffected by any of the above. // * "two tied groups in the same pool stay independent" -- pins groupKey // scoping, orthogonal to the per-member keying scheme. -// * "a mid-loop failure reorders the group" -- pins step 3/4 closing the #2 -// defect (the reorder reproduction). +// * "a failed write, then a re-fetch that reorders the group" -- pins step +// 3/4 closing the #2 defect (the reorder reproduction). // * "legacy (UUID-less) members" -- pins the #3/#4 blocker fix (bc-appx // item 1): three empty-id members must not collapse onto one row, // now enforced by checkinPid itself rather than a self-contained // workaround. +// +// The last two blocks pin a separate rule: a recorded chusen is shown with a +// Change control that reopens the same entry, and nothing else offers one. import React from 'react'; import { render, act, fireEvent, screen, waitFor } from '@testing-library/react'; @@ -148,10 +151,12 @@ const uniqueGroup = { minPosition: 1, }; -function makeApi(candidates) { +// API.chusenCandidates resolves both lists from one GET: the ties still +// waiting for a chusen and the ties a recorded chusen settled. +function makeApi(candidates, recorded = []) { return { - chusenCandidates: vi.fn().mockResolvedValue(candidates), - overridePoolRank: vi.fn().mockResolvedValue(true), + chusenCandidates: vi.fn().mockResolvedValue({ candidates, recorded }), + overridePoolRanks: vi.fn().mockResolvedValue(true), }; } @@ -187,12 +192,12 @@ describe('AdminPools chusen banner: same-name members are kept apart by index/id const recordBtn = screen.getByRole('button', { name: /Record chusen result/ }); await act(async () => { fireEvent.click(recordBtn); }); - await waitFor(() => expect(api.overridePoolRank).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(api.overridePoolRanks).toHaveBeenCalledTimes(1)); // No validation error banner rendered for the group. expect(screen.queryByText(/Enter each of positions/)).toBeNull(); }); - it('sends one overridePoolRank call per member, each with its OWN playerId', async () => { + it('sends the group in one overridePoolRanks call, each member with its OWN playerId', async () => { const api = makeApi([samenameGroup]); await mountAdminPools({ api }); @@ -204,14 +209,16 @@ describe('AdminPools chusen banner: same-name members are kept apart by index/id const recordBtn = screen.getByRole('button', { name: /Record chusen result/ }); await act(async () => { fireEvent.click(recordBtn); }); - await waitFor(() => expect(api.overridePoolRank).toHaveBeenCalledTimes(2)); - const calls = api.overridePoolRank.mock.calls; - // (compID, poolID, playerName, rank, password, playerId) - expect(calls[0]).toEqual(['c1', 'Pool A', 'Ryu Kan', 2, PASSWORD, 'team-1']); - expect(calls[1]).toEqual(['c1', 'Pool A', 'Ryu Kan', 1, PASSWORD, 'team-2']); - // The two calls must not carry the same identity: that was the defect + await waitFor(() => expect(api.overridePoolRanks).toHaveBeenCalledTimes(1)); + // (compID, poolID, ranks, password, forceDownstreamReopen) + expect(api.overridePoolRanks.mock.calls[0]).toEqual(['c1', 'Pool A', [ + { playerId: 'team-1', rank: 2 }, + { playerId: 'team-2', rank: 1 }, + ], PASSWORD, false]); + // The two entries must not carry the same identity: that was the defect // (both rows resolved through one identityByName['Ryu Kan'] entry). - expect(calls[0][5]).not.toBe(calls[1][5]); + const [first, second] = api.overridePoolRanks.mock.calls[0][2]; + expect(first.playerId).not.toBe(second.playerId); }); }); @@ -232,10 +239,11 @@ describe('AdminPools chusen banner: unique-name group regression guard', () => { const recordBtn = screen.getByRole('button', { name: /Record chusen result/ }); await act(async () => { fireEvent.click(recordBtn); }); - await waitFor(() => expect(api.overridePoolRank).toHaveBeenCalledTimes(2)); - const calls = api.overridePoolRank.mock.calls; - expect(calls[0]).toEqual(['c1', 'Pool A', 'Alpha', 2, PASSWORD, 'team-a']); - expect(calls[1]).toEqual(['c1', 'Pool A', 'Beta', 1, PASSWORD, 'team-b']); + await waitFor(() => expect(api.overridePoolRanks).toHaveBeenCalledTimes(1)); + expect(api.overridePoolRanks.mock.calls[0]).toEqual(['c1', 'Pool A', [ + { playerId: 'team-a', rank: 2 }, + { playerId: 'team-b', rank: 1 }, + ], PASSWORD, false]); }); it('still rejects an invalid permutation (regression guard on validation)', async () => { @@ -252,7 +260,7 @@ describe('AdminPools chusen banner: unique-name group regression guard', () => { await act(async () => { fireEvent.click(recordBtn); }); await screen.findByText(/Enter each of positions 1 to 2 exactly once/); - expect(api.overridePoolRank).not.toHaveBeenCalled(); + expect(api.overridePoolRanks).not.toHaveBeenCalled(); }); }); @@ -327,12 +335,12 @@ describe('AdminPools chusen banner: two tied groups in the same pool stay indepe expect(recordButtons.length).toBe(2); await act(async () => { fireEvent.click(recordButtons[0]); }); // group 1's button - await waitFor(() => expect(api.overridePoolRank).toHaveBeenCalledTimes(2)); - const calls = api.overridePoolRank.mock.calls; - expect(calls[0]).toEqual(['c1', 'Pool A', 'Alpha', 2, PASSWORD, 'team-a']); - expect(calls[1]).toEqual(['c1', 'Pool A', 'Beta', 1, PASSWORD, 'team-b']); + await waitFor(() => expect(api.overridePoolRanks).toHaveBeenCalledTimes(1)); // Group 2 must never appear in the write: it was not submitted. - expect(calls.some((c) => c[2] === 'Gamma' || c[2] === 'Delta')).toBe(false); + expect(api.overridePoolRanks.mock.calls[0]).toEqual(['c1', 'Pool A', [ + { playerId: 'team-a', rank: 2 }, + { playerId: 'team-b', rank: 1 }, + ], PASSWORD, false]); // Group 1's row is now gone (optimistically removed on success); only // group 2's row remains. Its inputs must still show the operator's @@ -347,7 +355,7 @@ describe('AdminPools chusen banner: two tied groups in the same pool stay indepe }); }); -describe('AdminPools chusen banner: a mid-loop failure reorders the group (bc-appx item 2/3)', () => { +describe('AdminPools chusen banner: a failed write, then a re-fetch that reorders the group (bc-appx item 2/3)', () => { // Three DISTINCT ids (not the legacy/empty-id scenario below): this block // pins identity-keying surviving a REORDER, not the id-collision blocker. const initialGroup = { @@ -364,9 +372,9 @@ describe('AdminPools chusen banner: a mid-loop failure reorders the group (bc-ap // server keeps a still-tied group's boundary stable across a reorder; // detectPoolTies groups by Points equality, and a rank override only // changes ORDER within that block), but Alpha and Beta have swapped - // places: a member carrying ANY override sorts ahead of one without, - // regardless of the override's value (engine/scoring.go), which is exactly - // what a partially-succeeded write produces. + // places, as any write to the pool between the two fetches can do (a + // member carrying ANY override sorts ahead of one without, regardless of + // the override's value, engine/scoring.go). const reorderedGroup = { ...initialGroup, teamNames: ['Beta', 'Alpha', 'Gamma'], @@ -382,14 +390,10 @@ describe('AdminPools chusen banner: a mid-loop failure reorders the group (bc-ap // First call (mount) returns the original order; the second call (the // catch block's re-fetch, triggered below) returns the reordered one. chusenCandidates: vi.fn() - .mockResolvedValueOnce([initialGroup]) - .mockResolvedValueOnce([reorderedGroup]), - // The FIRST overridePoolRank call in the submit loop -- Alpha's, since - // Alpha is members[0] in the order at the moment the operator clicks -- - // rejects, simulating the mid-loop write failure. The loop is - // sequential and stops at the first rejection, so Beta's and Gamma's - // writes are never attempted. - overridePoolRank: vi.fn().mockRejectedValueOnce(new Error('conflict')), + .mockResolvedValueOnce({ candidates: [initialGroup], recorded: [] }) + .mockResolvedValueOnce({ candidates: [reorderedGroup], recorded: [] }), + // The group write rejects, so the catch block re-fetches. + overridePoolRanks: vi.fn().mockRejectedValueOnce(new Error('conflict')), }; await mountAdminPools({ api }); await screen.findByText('Chusen (drawing lots) required'); @@ -404,8 +408,8 @@ describe('AdminPools chusen banner: a mid-loop failure reorders the group (bc-ap const recordBtn = screen.getByRole('button', { name: /Record chusen result/ }); await act(async () => { fireEvent.click(recordBtn); }); - // The submit failed on Alpha's write; only that one call was attempted. - await waitFor(() => expect(api.overridePoolRank).toHaveBeenCalledTimes(1)); + // The one group write failed. + await waitFor(() => expect(api.overridePoolRanks).toHaveBeenCalledTimes(1)); // The failure handler re-fetches candidates; wait for that SECOND call // (the reordered payload) to land and the component to re-render. await waitFor(() => expect(api.chusenCandidates).toHaveBeenCalledTimes(2)); @@ -479,7 +483,7 @@ describe('AdminPools chusen banner: legacy (UUID-less) members share an empty id expect(gammaInput.value).toBe('3'); }); - // overridePoolRank now requires playerId (operator ruling bc-pnum): the + // overridePoolRanks requires playerId (operator ruling bc-pnum): the // server resolves a pool member by id only and 400s outright without // one. Before the idsMissing gate, this test clicked the (enabled) // button and asserted the resulting playerId:"" payload -- a request @@ -495,7 +499,7 @@ describe('AdminPools chusen banner: legacy (UUID-less) members share an empty id expect(screen.getByText(/has no id in the pool draw/)).toBeTruthy(); recordBtn.click(); - expect(api.overridePoolRank).not.toHaveBeenCalled(); + expect(api.overridePoolRanks).not.toHaveBeenCalled(); }); }); @@ -541,3 +545,205 @@ describe('AdminPools chusen banner: non-ASCII member keys do not collapse the DO expect(labels[0].htmlFor).not.toBe(labels[1].htmlFor); }); }); + +// A recorded rank can move who holds a qualifying place after the knockout has +// started, and the server refuses it like a pool result correction. The panel +// must ask the operator (the same confirm the score editors use, naming who +// moves and which match reopens) and, confirmed, resend the whole order with +// the confirmation; declined, it records nothing and says so in the group. +describe('AdminPools chusen banner: a rank that moves a knockout qualifier', () => { + const refusal = () => { + const e = new Error('downstream_knockout_played'); + e.downstreamKnockoutPlayed = { + matchId: '', blockingMatchId: 'm9', blockingMatches: [{ id: 'm9', number: 9 }], displaced: 'Beta', + qualifierChange: [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Beta' }, to: { name: 'Alpha' } }], + ranking: true, + }; + return e; + }; + + async function recordDefaults(api) { + await mountAdminPools({ api }); + await screen.findByText('Chusen (drawing lots) required'); + const recordBtn = screen.getByRole('button', { name: /Record chusen result/ }); + await act(async () => { fireEvent.click(recordBtn); }); + } + + it('asks, and on confirm resends the order with the confirmation', async () => { + const api = makeApi([uniqueGroup]); + api.overridePoolRanks = vi.fn() + .mockRejectedValueOnce(refusal()) + .mockResolvedValue(true); + window.confirmDialog = vi.fn().mockResolvedValue(true); + await recordDefaults(api); + + await waitFor(() => expect(api.overridePoolRanks).toHaveBeenCalledTimes(2)); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + const { message, confirmLabel } = window.confirmDialog.mock.calls[0][0]; + expect(message).toContain("Recording this ranking moves Pool A's 1st place from Beta to Alpha"); + expect(message).toContain('Match 9'); + expect(confirmLabel).toBe('Apply and reopen'); + const order = [{ playerId: 'team-a', rank: 1 }, { playerId: 'team-b', rank: 2 }]; + expect(api.overridePoolRanks.mock.calls).toEqual([ + ['c1', 'Pool A', order, PASSWORD, false], + ['c1', 'Pool A', order, PASSWORD, true], + ]); + }); + + it('declined, records nothing and says so in the group', async () => { + const api = makeApi([uniqueGroup]); + api.overridePoolRanks = vi.fn().mockRejectedValueOnce(refusal()).mockResolvedValue(true); + window.confirmDialog = vi.fn().mockResolvedValue(false); + await recordDefaults(api); + + await screen.findByText('Ranking not recorded: the knockout match already fought was left unchanged.'); + expect(api.overridePoolRanks).toHaveBeenCalledTimes(1); + }); +}); + +// A pool correction made after a mixed competition's knockout has started can +// leave a qualifying tie only a chusen settles, and the server accepts the +// chusen then (AcceptsPoolRankOverride). The panel must fetch and show it in +// knockout status too, or the knockout match that place feeds can never be +// played. A knockout-only competition has no pool order to set, so it is not +// asked. +describe('AdminPools chusen banner: after the knockout has started', () => { + it('fetches and shows the candidates for a mixed competition in knockout status', async () => { + const api = makeApi([uniqueGroup]); + await mountAdminPools({ api, comp: teamComp({ status: 'knockout' }) }); + + await screen.findByText('Chusen (drawing lots) required'); + expect(api.chusenCandidates).toHaveBeenCalledWith('c1', PASSWORD); + expect(screen.getByText('Alpha')).toBeTruthy(); + expect(screen.getByText('Beta')).toBeTruthy(); + }); + + it('does not ask for a competition whose knockout has no pools behind it', async () => { + const api = makeApi([uniqueGroup]); + window.API = api; + await act(async () => { + render( + + ); + }); + expect(api.chusenCandidates).not.toHaveBeenCalled(); + expect(screen.queryByText('Chusen (drawing lots) required')).toBeNull(); + }); +}); + +// A chusen recorded in the wrong order must stay fixable (operator ruling: +// everything can be fixed after a wrong entry). Once every team in the tie has +// a rank, the tie leaves the "required" panel, so the Pools tab shows it as +// recorded, in the recorded order, with Change. Change reopens the same entry +// pre-filled with the recorded ranks and records the new order through the +// same overridePoolRanks path a first chusen uses, as ONE write: sent one rank +// at a time, the server answered for orders nobody chose, and a knockout match +// whose place the new order keeps was named and reopened. +describe('AdminPools chusen: a recorded chusen can be changed', () => { + // The server sends the teams in standings order and each team's recorded + // rank parallel to them; the order below is deliberately not by rank, so + // the displayed order can only come from `ranks`. + const recordedGroup = { + poolName: 'Pool A', + teamNames: ['Alpha', 'Beta', 'Gamma'], + teams: [ + { id: 'id-alpha', name: 'Alpha', dojo: 'Dojo A' }, + { id: 'id-beta', name: 'Beta', dojo: 'Dojo B' }, + { id: 'id-gamma', name: 'Gamma', dojo: 'Dojo G' }, + ], + minPosition: 1, + ranks: [2, 3, 1], + }; + + it('shows the recorded order, and Change reopens the entry pre-filled and records the new order in one request', async () => { + const api = makeApi([], [recordedGroup]); + await mountAdminPools({ api }); + + await screen.findByText('Drawing lots: 1st Gamma, 2nd Alpha, 3rd Beta'); + expect(screen.queryByText('Chusen (drawing lots) required')).toBeNull(); + expect(screen.queryAllByRole('spinbutton')).toHaveLength(0); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Change the drawing lots for Pool A' })); + }); + expect(screen.getByText(/Changing it can change who qualifies from Pool A/)).toBeTruthy(); + // Pre-filled with the recorded ranks, not the array positions. + expect(screen.getByLabelText('Alpha').value).toBe('2'); + expect(screen.getByLabelText('Beta').value).toBe('3'); + expect(screen.getByLabelText('Gamma').value).toBe('1'); + + fireEvent.change(screen.getByLabelText('Alpha'), { target: { value: '1' } }); + fireEvent.change(screen.getByLabelText('Beta'), { target: { value: '2' } }); + fireEvent.change(screen.getByLabelText('Gamma'), { target: { value: '3' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Record chusen result/ })); + }); + + await waitFor(() => expect(api.overridePoolRanks).toHaveBeenCalled()); + // (compID, poolID, ranks, password, forceDownstreamReopen): the same call + // a first chusen makes, once, with the whole order. + expect(api.overridePoolRanks.mock.calls).toEqual([ + ['c1', 'Pool A', [ + { playerId: 'id-alpha', rank: 1 }, + { playerId: 'id-beta', rank: 2 }, + { playerId: 'id-gamma', rank: 3 }, + ], PASSWORD, false], + ]); + await screen.findByText('Drawing lots: 1st Alpha, 2nd Beta, 3rd Gamma'); + expect(screen.queryAllByRole('spinbutton')).toHaveLength(0); + }); + + it('Cancel closes the entry without recording anything', async () => { + const api = makeApi([], [recordedGroup]); + await mountAdminPools({ api }); + + await act(async () => { + fireEvent.click(await screen.findByRole('button', { name: 'Change the drawing lots for Pool A' })); + }); + fireEvent.change(screen.getByLabelText('Alpha'), { target: { value: '1' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + }); + + expect(api.overridePoolRanks).not.toHaveBeenCalled(); + expect(screen.getByText('Drawing lots: 1st Gamma, 2nd Alpha, 3rd Beta')).toBeTruthy(); + // Reopening starts again from the recorded order, not the abandoned edit. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Change the drawing lots for Pool A' })); + }); + expect(screen.getByLabelText('Alpha').value).toBe('2'); + }); +}); + +// Only a tie settled by chusen is editable here: the general rank override was +// removed on purpose. A pool with no chusen shows nothing, and a tie still +// waiting for its chusen offers the entry but no Change. +describe('AdminPools chusen: a pool without a recorded chusen offers no Change', () => { + it('shows nothing for a pool with no chusen', async () => { + const api = makeApi([], []); + await mountAdminPools({ api }); + + expect(screen.queryByText('Chusen (drawing lots) recorded')).toBeNull(); + expect(screen.queryByText('Chusen (drawing lots) required')).toBeNull(); + expect(screen.queryByText(/Drawing lots:/)).toBeNull(); + expect(screen.queryByRole('button', { name: /Change/ })).toBeNull(); + expect(screen.queryAllByRole('spinbutton')).toHaveLength(0); + }); + + it('offers no Change for a tie still waiting for its chusen', async () => { + const api = makeApi([uniqueGroup], []); + await mountAdminPools({ api }); + + await screen.findByText('Chusen (drawing lots) required'); + expect(screen.queryByText('Chusen (drawing lots) recorded')).toBeNull(); + expect(screen.queryByRole('button', { name: /Change/ })).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_pools_tiebreak_buttons.render.test.jsx b/web-mobile/js/__tests__/render/admin_pools_tiebreak_buttons.render.test.jsx index 6769b8180..86805138d 100644 --- a/web-mobile/js/__tests__/render/admin_pools_tiebreak_buttons.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_pools_tiebreak_buttons.render.test.jsx @@ -45,7 +45,7 @@ function makeApi({ candidates, finalized = false }) { return { // No chusen ties for these fixtures; isTeamComp is also true for a team // league, so this effect runs too and must resolve to something benign. - chusenCandidates: vi.fn().mockResolvedValue([]), + chusenCandidates: vi.fn().mockResolvedValue({ candidates: [], recorded: [] }), leagueTiebreakCandidates: vi.fn().mockResolvedValue({ candidates, finalized }), leagueTiebreakGenerate: vi.fn().mockResolvedValue({ matches: [] }), leagueTiebreakRemove: vi.fn().mockResolvedValue({ deleted: 0 }), diff --git a/web-mobile/js/__tests__/render/admin_scores_row_name_mark_clip.render.test.jsx b/web-mobile/js/__tests__/render/admin_scores_row_name_mark_clip.render.test.jsx new file mode 100644 index 000000000..b6741b1be --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_scores_row_name_mark_clip.render.test.jsx @@ -0,0 +1,92 @@ +// bc-cse (fix #4): the admin Scores tab's own row (admin_schedule_score_editor +// .jsx, class .name) wraps a NumberedName clip element beside a Kiken/Fus. +// mark inside a block-ellipsis cell -- a long name used to clip the mark off +// the end (Shiro, trailing) or push it past the visible width (Aka, leading). +// See viewer_match.jsx's VSchedItem comment for the full mechanism; this pins +// the SAME structural fix (msb-name--labelled) landed on THIS host. +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const side = (id, name, number) => ({ id, name, number }); + +// A completed team match: Shiro (the long name) withdrew (decisionBy +// "shiro"), so window.teamMatchMarks (the REAL bracket.jsx implementation -- +// admin_schedule_score_editor.jsx transitively loads it via +// admin_schedule_lineup.jsx -> admin_scoring_shared.jsx, so a stub here would +// be clobbered by bracket.jsx's own `window.teamMatchMarks = teamMatchMarks` +// at import time) credits the Kiken mark to Shiro's (long) name. +const TEAM_MATCH = { + id: 'm-team-1', compId: 'c1', status: 'completed', court: 'A', decision: 'kiken-voluntary', decisionBy: 'shiro', + subResults: [{ position: 1 }], + sideA: side('team-aka', 'Aoki Dojo', 'T1'), + sideB: side('team-shiro', 'A Very Long Shiro Team Name That Should Clip', 'T2'), + winner: { id: 'team-aka', name: 'Aoki Dojo' }, +}; + +const STUBS = { + ScoreEditorModal: () =>
, + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + CourtPicker: () => null, + getScoreBtnClass: () => 'test-score-open', + matchScoreStr: () => '', + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + compMatches: () => [TEAM_MATCH], + startPatch: () => ({ status: 'running', winner: null }), + confirmDialog: vi.fn().mockResolvedValue(true), + pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + }, +}; + +let restore, AdminScoreEditor; + +beforeAll(async () => { + window.scrollTo = vi.fn(); + restore = installWindowStubs(STUBS); + const mod = await import('../../admin_schedule_score_editor.jsx'); + AdminScoreEditor = mod.AdminScoreEditor; +}); +afterAll(() => restore()); + +function mount() { + return render( + + ); +} + +describe('admin Scores row: the mark survives clipping (bc-cse #4)', () => { + it('the .name cell carries msb-name--labelled, and the mark is a real element, not part of the ellipsised text', () => { + const { container } = mount(); + const nameCells = [...container.querySelectorAll('.name')]; + expect(nameCells.length).toBeGreaterThanOrEqual(2); + for (const cell of nameCells) { + expect(cell.classList.contains('msb-name--labelled')).toBe(true); + } + + // Shiro carries the Kiken mark: a real sb-result-mark element. + const mark = container.querySelector('.sb-result-mark'); + expect(mark).toBeTruthy(); + expect(mark.textContent).toBe('Kiken'); + + // The mark is a SIBLING of the name, not appended to its text run: the + // long Shiro name text is present verbatim, with no "Kiken" folded in. + const shiroCell = nameCells.find((c) => c.textContent.includes('A Very Long Shiro')); + expect(shiroCell).toBeTruthy(); + expect(shiroCell.contains(mark)).toBe(true); + const numberedText = shiroCell.querySelector('.numbered-name__text'); + expect(numberedText).toBeTruthy(); + expect(numberedText.textContent).not.toContain('Kiken'); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx b/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx index 9add267e8..57a80c2f8 100644 --- a/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx @@ -348,3 +348,54 @@ describe('EngiScoreEditorModal number chip rides the first member only (bc-sccl) expect(shiroLines[1].querySelector('.num-prefix')).toBeNull(); }); }); + +// bc-kbhn: the engi hint shares the `.scoring-shortcut-hint` class (so the +// coarse-pointer rule hides it) and lists Esc only where the host can close. +describe('bc-kbhn: EngiShortcutHint', () => { + it('carries the shared class and no inline display', () => { + render( {}} onSubmit={() => {}} />); + const hint = screen.getByTestId('engi-shortcut-hint'); + expect(hint.classList.contains('scoring-shortcut-hint')).toBe(true); + expect(hint.style.display).toBe(''); + }); + + it('lists Esc when the host can close, and not on the court console', () => { + const { unmount } = render( {}} onSubmit={() => {}} />); + expect(screen.getByTestId('engi-shortcut-hint').textContent).toContain('close'); + unmount(); + render( {}} onSubmit={() => {}} variant="inline" canClose={false} />); + const t = screen.getByTestId('engi-shortcut-hint').textContent; + expect(t).not.toContain('close'); + expect(t).not.toContain('prev/next'); + }); +}); + +// bc-kbhn: the Scores tab wires onPrev/onNext unconditionally, so on the first +// or last match ← / → used to call a callback with no neighbour behind it +// (scoreKeyOf(null) throws there). The keys and the hint now both require the +// neighbour, as the individual and team editors do. +describe('bc-kbhn: engi ←/→ need a neighbour match', () => { + it('callbacks wired but no neighbour: no arrows in the hint and the keys do nothing', () => { + const onPrev = vi.fn(); + const onNext = vi.fn(); + render( {}} onSubmit={() => {}} onPrev={onPrev} onNext={onNext} />); + expect(screen.getByTestId('engi-shortcut-hint').textContent).not.toContain('prev/next'); + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }); + fireEvent.keyDown(document.body, { key: 'ArrowRight' }); + expect(onPrev).not.toHaveBeenCalled(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('a neighbour but no callback (a host with no nav): no arrows in the hint', () => { + render( {}} onSubmit={() => {}} nextMatch={makeMatch({ id: 'e2' })} />); + expect(screen.getByTestId('engi-shortcut-hint').textContent).not.toContain('prev/next'); + }); + + it('with a neighbour: the arrows are listed and the key moves', () => { + const onNext = vi.fn(); + render( {}} onSubmit={() => {}} nextMatch={makeMatch({ id: 'e2' })} onPrev={vi.fn()} onNext={onNext} />); + expect(screen.getByTestId('engi-shortcut-hint').textContent).toContain('prev/next'); + fireEvent.keyDown(document.body, { key: 'ArrowRight' }); + expect(onNext).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 6ba337ff0..598dc96b6 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -13,11 +13,20 @@ import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED } from '../../write_result.jsx'; // otherwise. The queued-vs-superseded split the offline-resolve test below // depends on is therefore the real one either way. +// bc-cse: captures the LATEST ScoreEditorModal props so a test can drive +// onSubmitAndNext/onAfterDecision directly (the chained-navigation tests +// below), while the rendered markup stays exactly what the pre-existing +// tests already query (data-testid="score-editor" data-match=...). +const probe = { props: null }; + const STUBBED_GLOBALS = { // MODULE-EVAL-TIME: captured at import; set before dynamic import below AdminTopbar: ({ children }) =>
{children}
, Breadcrumbs: () => null, - ScoreEditorModal: () =>
, + // Carries the id of the match the panel is scoring, so a test can tell + // WHICH match the console selected (captured at import, so it cannot be + // swapped per test). + ScoreEditorModal: (props) => { probe.props = props; return
; }, CourtPicker: () => null, BracketTree: () => null, Icon: ({ name }) => {name}, @@ -33,6 +42,8 @@ const STUBBED_GLOBALS = { sendAnnouncement: vi.fn(), updateMatchTime: vi.fn(), startMatch: vi.fn(), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + reinstateCompetitor: vi.fn().mockResolvedValue({}), }, startPatch: vi.fn(), confirmDialog: vi.fn().mockResolvedValue(true), @@ -66,13 +77,13 @@ function makeMinimalTournament(overrides = {}) { }; } -function renderPage(tournament, court = 'A') { +function renderPage(tournament, court = 'A', props = {}) { return render( { } }); + // UAT (bc-tmfn): the operator corrects a finished match, taps Clear + // withdrawal and reopen (the match is RUNNING again), scores the rest and + // taps Finish + Start Next. The console kept the old match pinned in + // CORRECTION mode and hid the match it had just started until "Back to + // court". A reopened correction is the court's live bout, so it becomes the + // live pick and the correction ends. + it('a reopened correction becomes the live match, so Finish + Start Next moves on to the match it started', async () => { + const side = (id, name) => ({ id, name }); + const m1 = { + id: 'm1', compId: 'c1', compName: 'Cup', status: 'completed', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:00', modifiedAt: 1000, + sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), winner: side('p1', 'Yamada'), + }; + const m2 = { + id: 'm2', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', sideA: side('p3', 'Sato'), sideB: side('p4', 'Kato'), + }; + let current = [m1, m2]; + window.tournamentMatches = () => current; + window.filterMatchesByCourt = (matches) => matches; + const prevFetch = window.API.fetchCourtMatches; + const prevSub = window.API.subscribeToEvents; + const prevRevert = window.API.revertMatchToQueue; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + window.API.revertMatchToQueue = vi.fn().mockResolvedValue(true); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + const editorMatch = () => utils.getByTestId('score-editor').getAttribute('data-match'); + const heading = () => utils.container.querySelector('.shiaijo-context__toggle').textContent; + const refresh = async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }; + + await act(async () => { utils.getByRole('button', { name: /^correct$/i }).click(); }); + expect(editorMatch()).toBe('m1'); + expect(heading()).toContain('Correcting'); + + // Clear withdrawal and reopen: m1 is running again. + current = [{ ...m1, status: 'running', winner: undefined }, m2]; + await refresh(); + expect(editorMatch()).toBe('m1'); + expect(heading()).not.toContain('Correcting'); + + // Finish + Start Next: m1 is completed again and m2 is running. + current = [{ ...m1, status: 'completed', modifiedAt: 3000 }, { ...m2, status: 'running' }]; + await refresh(); + expect(editorMatch(), 'the panel must follow the match Finish + Start Next started').toBe('m2'); + expect(utils.queryByRole('button', { name: /back to court/i })).toBeNull(); + expect(heading()).not.toContain('Correcting'); + } finally { + window.API.fetchCourtMatches = prevFetch; + window.API.subscribeToEvents = prevSub; + window.API.revertMatchToQueue = prevRevert; + } + }); + + // bc-cse #6: a reopen does not always land on "running". A match-level + // fusensho whose decisionBy side is STILL barred by an earlier, unrelated + // withdrawal reopens to "scheduled" instead (engine.reopenTargetStatus): + // starting it straight into "running" would strand the operator behind + // the eligibility gate. The correction must still end, or the panel stays + // pinned to the now-cleared correction and hides whatever the court's + // normal view would show -- here, the OTHER match already running on this + // same court -- until a reload. + it('a correction cleared to "scheduled" (still-barred fusensho) releases the panel back to the running bout', async () => { + const side = (id, name) => ({ id, name }); + const m1 = { + id: 'm1', compId: 'c1', compName: 'Cup', status: 'completed', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:00', modifiedAt: 1000, + sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), winner: side('p1', 'Yamada'), + decision: 'fusensho', decisionBy: 'aka', + }; + const m2 = { + id: 'm2', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', sideA: side('p3', 'Sato'), sideB: side('p4', 'Kato'), + }; + let current = [m1, m2]; + window.tournamentMatches = () => current; + window.filterMatchesByCourt = (matches) => matches; + const prevFetch = window.API.fetchCourtMatches; + const prevSub = window.API.subscribeToEvents; + const prevRevert = window.API.revertMatchToQueue; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + window.API.revertMatchToQueue = vi.fn().mockResolvedValue(true); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + const editorMatch = () => utils.getByTestId('score-editor').getAttribute('data-match'); + const heading = () => utils.container.querySelector('.shiaijo-context__toggle').textContent; + const refresh = async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }; + + await act(async () => { utils.getByRole('button', { name: /^correct$/i }).click(); }); + expect(editorMatch()).toBe('m1'); + expect(heading()).toContain('Correcting'); + + // Clear the withdrawal and reopen: the barred fusensho side is still + // barred elsewhere, so the server sends m1 to "scheduled", not + // "running" (m2 keeps running throughout, on the same court). + current = [{ ...m1, status: 'scheduled', decision: '', decisionBy: '', winner: undefined }, m2]; + await refresh(); + + // The correction must end: the panel falls back to the court's + // normal view -- the running bout (m2) -- instead of staying pinned + // to m1. + expect(editorMatch(), "the panel must release m1 and follow the court's running bout").toBe('m2'); + expect(heading()).not.toContain('Correcting'); + } finally { + window.API.fetchCourtMatches = prevFetch; + window.API.subscribeToEvents = prevSub; + window.API.revertMatchToQueue = prevRevert; + } + }); + + // UAT (bc-tmfn): a Start refused for an ineligible competitor + // ("kiken-voluntary at Pool A-2") stayed on screen after the withdrawal was + // cleared and eligibility restored, and after that match started it sat + // under the NEXT match. It must go once it may no longer apply (an + // eligibility change in its competition, or ANY change of Up next), and + // stay while it still does. + it('a refused Start is dropped when eligibility changes or Up next moves on, and kept while it still applies', async () => { + const side = (id, name) => ({ id, name }); + const m1 = { + id: 'm1', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:00', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + const m2 = { + id: 'm2', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', sideA: side('p3', 'Sato'), sideB: side('p4', 'Kato'), + }; + let current = [m1, m2]; + window.tournamentMatches = () => current; + window.filterMatchesByCourt = (matches) => matches; + let emit = () => {}; + const prevFetch = window.API.fetchCourtMatches; + const prevSub = window.API.subscribeToEvents; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = (cb) => { emit = cb; return () => {}; }; + const onEditScore = vi.fn().mockRejectedValue(new Error('kiken-voluntary at Pool A-2')); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + const refusal = () => utils.container.querySelector('.shiaijo-upnext__error'); + // The Up next card's own Start (an Upcoming row carries one too). + const start = async () => { + const card = utils.container.querySelector('.shiaijo-upnext__card'); + const btn = [...card.querySelectorAll('button')].find((b) => /start match/i.test(b.textContent)); + await act(async () => { btn.click(); }); + }; + const refresh = async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }; + const send = async (event) => { await act(async () => { emit(event); }); }; + + // A Start refused for an UPCOMING row's match is not shown under Up + // next, which is a different match (the toast names it at the time). + await act(async () => { utils.container.querySelector('.shiaijo-row__pick').click(); }); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(refusal()).toBeNull(); + + await start(); + expect(refusal()?.textContent).toBe('kiken-voluntary at Pool A-2'); + + // Still applies: a refetch with the same Up next, an unrelated event, and + // an eligibility change in ANOTHER competition all leave it. + await refresh(); + await send({ type: 'match_updated', data: { competitionId: 'c1', matchId: 'm9' } }); + await send({ type: 'competitor_status_updated', data: { competitionId: 'other', status: {} } }); + expect(refusal()?.textContent).toBe('kiken-voluntary at Pool A-2'); + + // The restore in this competition clears it. + await send({ type: 'competitor_status_updated', data: { competitionId: 'c1', status: { eligible: true } } }); + expect(refusal()).toBeNull(); + + // Refused again, then m1 starts elsewhere: Up next is now m2, and the + // refusal must not sit under it. + await start(); + expect(refusal()).not.toBeNull(); + current = [{ ...m1, status: 'running' }, m2]; + await refresh(); + expect(utils.container.querySelector('.shiaijo-upnext__card').textContent).toContain('Sato'); + expect(refusal()).toBeNull(); + + // Nor does it come back if m1 returns to the top of the queue. + current = [m1, m2]; + await refresh(); + expect(utils.container.querySelector('.shiaijo-upnext__card').textContent).toContain('Yamada'); + expect(refusal()).toBeNull(); + } finally { + window.API.fetchCourtMatches = prevFetch; + window.API.subscribeToEvents = prevSub; + } + }); + + // A Start refused for a match picked from further down the queue (here its + // competitor is fighting on another court) is kept keyed to that match. It + // used to come back when that match later reached Up next, although its + // cause was gone by then and nothing else clears it: a finished match sends + // no competitor_status_updated. Any change of Up next drops it. + it('a refused Start for a match further down the queue does not reappear when it becomes Up next', async () => { + const side = (id, name) => ({ id, name }); + const m1 = { + id: 'm1', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:00', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + const m2 = { + id: 'm2', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', sideA: side('p3', 'Sato'), sideB: side('p4', 'Kato'), + }; + let current = [m1, m2]; + window.tournamentMatches = () => current; + window.filterMatchesByCourt = (matches) => matches; + const prevFetch = window.API.fetchCourtMatches; + const prevSub = window.API.subscribeToEvents; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + const onEditScore = vi.fn().mockRejectedValue(new Error('Sato is fighting on shiaijo B')); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + const refusal = () => utils.container.querySelector('.shiaijo-upnext__error'); + const refresh = async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }; + + // Picked from the queue below Up next and refused: not shown under Up + // next, which is m1. + await act(async () => { utils.container.querySelector('.shiaijo-row__pick').click(); }); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(onEditScore.mock.calls[0][1]).toBe('m2'); + expect(refusal()).toBeNull(); + + // m1 starts, so m2 is now Up next. The old refusal must not come back + // with it. + current = [{ ...m1, status: 'running' }, m2]; + await refresh(); + expect(utils.container.querySelector('.shiaijo-upnext__card').textContent).toContain('Sato'); + expect(refusal()).toBeNull(); + } finally { + window.API.fetchCourtMatches = prevFetch; + window.API.subscribeToEvents = prevSub; + } + }); + // Guard: verify that a missing window scope reference causes a render failure. // This is the exact class of bug that PR #271 introduced undetected: // requestMoveCourt was used in ShiaijoQueueGroup but not passed as a prop, @@ -600,3 +851,173 @@ describe('AdminShiaijoPage render-smoke', () => { } }); }); + +// bc-cse: a scheduled match a competitor is barred from (withdrew earlier) +// cannot be fought. No auto-pick may offer it (Up Next, Finish + Start Next); +// its queue row shows why and a one-tap way to resolve it instead of a Start +// button the server would just refuse. +describe('a barred match is skipped by every auto-pick (bc-cse)', () => { + const side = (id, name) => ({ id, name }); + // Withdrawn on side B (Yama, kiken-voluntary): the default win goes to Umi. + const barredMatch = (over = {}) => ({ + id: 'm-barred', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', sideA: side('u', 'Umi'), sideB: side('y', 'Yama'), + ineligibleSides: { b: 'kiken-voluntary' }, + ...over, + }); + const openMatch = { + id: 'm-open', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:10', sideA: side('s', 'Sato'), sideB: side('k', 'Kato'), + }; + + it('Up Next skips it, its queue row offers the default win instead of Start, and no Reinstate for a non-reinstateable withdrawal', async () => { + window.tournamentMatches = () => [barredMatch(), openMatch]; + window.filterMatchesByCourt = (m) => m; + const recordDecision = vi.fn().mockResolvedValue({ applied: true }); + const prevRD = window.API.recordDecision; + window.API.recordDecision = recordDecision; + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + + // Up Next skips the barred match and offers the next fightable one. + const card = utils.container.querySelector('.shiaijo-upnext__card'); + expect(card.textContent).toContain('Sato'); + expect(card.textContent).not.toContain('Umi'); + expect(card.textContent).not.toContain('Yama'); + + // The barred match's own row: the note, one action, no Start, no Reinstate. + const rows = [...utils.container.querySelectorAll('.shiaijo-qrow')]; + const row = rows.find((r) => r.textContent.includes('Yama')); + expect(row).toBeTruthy(); + expect(row.textContent).toContain('Yama withdrew: record the default win.'); + const startBtn = [...row.querySelectorAll('button')].find((b) => /^start match$/i.test(b.textContent)); + expect(startBtn).toBeUndefined(); + expect(utils.queryByTestId('barred-match-reinstate')).toBeNull(); + const awardBtn = utils.getByTestId('barred-match-default-win'); + expect(awardBtn.textContent).toBe('Record default win for Umi'); + + await act(async () => { awardBtn.click(); }); + expect(recordDecision).toHaveBeenCalledWith('c1', 'm-barred', { + decision: 'fusensho', decisionBy: 'shiro', decisionReason: 'auto: Yama withdrawn', + }, ''); + } finally { + window.API.recordDecision = prevRD; + } + }); + + it('Finish + Start Next skips it when advancing to the next match on the same court', async () => { + const running = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + window.tournamentMatches = () => [running, barredMatch(), openMatch]; + window.filterMatchesByCourt = (m) => m; + const onEditScore = vi.fn().mockResolvedValue({ status: 'ok' }); + await act(async () => { renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m-run'); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed', winner: side('p1', 'Yamada') }); }); + // The finishing write, then the start-next write -- skipping m-barred. + expect(onEditScore).toHaveBeenCalledTimes(2); + expect(onEditScore.mock.calls[0][0]).toBe('c1'); + expect(onEditScore.mock.calls[0][1]).toBe('m-run'); + expect(onEditScore.mock.calls[1][0]).toBe('c1'); + expect(onEditScore.mock.calls[1][1]).toBe('m-open'); + }); + + it('offers Reinstate for a reinstateable (kiken-injury) withdrawal', async () => { + window.tournamentMatches = () => [barredMatch({ ineligibleSides: { b: 'kiken-injury' } }), openMatch]; + window.filterMatchesByCourt = (m) => m; + const reinstateCompetitor = vi.fn().mockResolvedValue({}); + const prevR = window.API.reinstateCompetitor; + window.API.reinstateCompetitor = reinstateCompetitor; + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + const reinstateBtn = utils.getByTestId('barred-match-reinstate'); + expect(reinstateBtn.textContent).toBe('Reinstate Yama'); + await act(async () => { reinstateBtn.click(); }); + expect(reinstateCompetitor).toHaveBeenCalledWith('c1', 'y', ''); + } finally { + window.API.reinstateCompetitor = prevR; + } + }); + + // bc-cse: both sides barred on a POOL match (barredMatch's own phase) can + // be recorded as drawn -- neither single-sided default-win action fits, + // but the pool/league draw action does. + it('both sides barred: the note, and the draw action (no single-sided action)', async () => { + window.tournamentMatches = () => [barredMatch({ id: 'Pool A-2', ineligibleSides: { a: 'fusenpai', b: 'kiken-voluntary' } })]; + window.filterMatchesByCourt = (m) => m; + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + const notice = utils.getByTestId('barred-match-notice'); + expect(notice.textContent).toContain('Both withdrew earlier: neither can fight this match.'); + expect(utils.queryByTestId('barred-match-default-win')).toBeNull(); + expect(utils.queryByTestId('barred-match-reinstate')).toBeNull(); + expect(utils.getByTestId('barred-match-record-drawn').textContent).toBe('Record as drawn (neither can fight)'); + }); + + // bc-cse: when EVERY scheduled match on the court is barred there is no + // Up Next card at all (upNext skips a barred match on purpose), so + // telling the operator to use it points at something not on screen. The + // placeholder must name the real remedy instead. + it('placeholder names the real remedy when every scheduled match is barred (no Up Next card)', async () => { + window.tournamentMatches = () => [barredMatch()]; + window.filterMatchesByCourt = (m) => m; + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + expect(utils.container.querySelector('.shiaijo-upnext__card')).toBeNull(); + expect(utils.container.textContent).toContain( + 'Every scheduled match on this court is barred. Resolve a withdrawal in the queue to bring one back.' + ); + expect(utils.container.textContent).not.toContain('Start the next match from the Up Next card'); + }); + + // bc-cse: the ordinary placeholder still applies when there is nothing + // scheduled for a reason OTHER than "everything is barred" -- here, a + // pending placeholder final with no real Up Next yet (same fixture shape + // as the "Later" row test above). + it('placeholder keeps the ordinary copy when nothing is scheduled for an unrelated reason', async () => { + const completedFeeder = { + id: 'r2-m0', compId: 'c1', compName: 'Cup', status: 'completed', + phase: 'bracket', matchNumber: 1, court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + winner: { id: 'p1', name: 'Yamada' }, + }; + const pendingFinal = { + id: 'r3-m0', compId: 'c1', compName: 'Cup', status: 'scheduled', + phase: 'bracket', matchNumber: 3, court: 'A', + sideA: { id: '', name: 'Winner of r2-m0' }, + sideB: { id: '', name: 'Winner of r2-m1' }, + }; + window.tournamentMatches = () => [completedFeeder, pendingFinal]; + window.filterMatchesByCourt = (m) => m; + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + expect(utils.container.textContent).toContain('Start the next match from the Up Next card'); + expect(utils.container.textContent).not.toContain('Every scheduled match on this court is barred'); + }); + + // bc-cse: a barred queue row offers only its own one-tap resolution -- + // Call to court and Lineup, both meaningless for a match the server would + // refuse to start, are hidden. + it('a barred queue row hides Call to court and Lineup', async () => { + const teamBarred = { + id: 'm-team-barred', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', compKind: 'team', teamSize: 3, + sideA: { id: 'team-a', name: 'Team A' }, sideB: { id: 'team-b', name: 'Team B' }, + ineligibleSides: { b: 'kiken-voluntary' }, + }; + window.tournamentMatches = () => [teamBarred, openMatch]; + window.filterMatchesByCourt = (m) => m; + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + const rows = [...utils.container.querySelectorAll('.shiaijo-qrow')]; + const row = rows.find((r) => r.textContent.includes('Team A')); + expect(row).toBeTruthy(); + const buttonText = [...row.querySelectorAll('button')].map((b) => b.textContent); + expect(buttonText).not.toContain('Lineup'); + expect(buttonText.some((t) => /call to court/i.test(t))).toBe(false); + }); +}); diff --git a/web-mobile/js/__tests__/render/barred_chip.render.test.jsx b/web-mobile/js/__tests__/render/barred_chip.render.test.jsx new file mode 100644 index 000000000..7356c5227 --- /dev/null +++ b/web-mobile/js/__tests__/render/barred_chip.render.test.jsx @@ -0,0 +1,112 @@ +// bc-tmfn: a scheduled match whose competitor is barred (withdrew earlier; +// ineligible_match.jsx) stays visible on every public schedule and pool +// list, with a small "Withdrawn" chip beside the barred competitor's name +// (barred_chip.jsx). This pins the chip on all four consuming surfaces: +// TWMatch (viewer_schedule.jsx), VSchedItem (viewer_match.jsx), +// PoolNumberedMatchRow (viewer_standings.jsx), and MatchCard (bracket.jsx). +// +// bracket.jsx is imported first in every case: it is the one module that +// sets the window.* globals (matchScoreStr, matchStateCell, boutMiddle, +// teamMatchMarks, …) the other three read at render time. +import React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; + +let TWMatch, VSchedItem, PoolNumberedMatchRow, MatchCard; + +beforeAll(async () => { + await import('../../bracket.jsx'); + MatchCard = window.MatchCard; + ({ TWMatch } = await import('../../viewer_schedule.jsx')); + ({ VSchedItem } = await import('../../viewer_match.jsx')); + ({ PoolNumberedMatchRow } = await import('../../viewer_standings.jsx')); +}); + +afterEach(() => cleanup()); + +const shiro = { id: 'p-shiro', name: 'Yama Competitor', number: 'K1' }; +const aka = { id: 'p-aka', name: 'Umi Competitor', number: 'K2' }; + +describe('TWMatch: the Withdrawn chip beside a barred competitor', () => { + it('shows the chip on the barred (shiro/sideB) side only', () => { + const m = { + id: 'p1', compId: 'c1', status: 'scheduled', scheduledAt: '10:00', phase: 'pool', + sideA: aka, sideB: shiro, + ineligibleSides: { b: 'kiken-voluntary' }, + }; + render(); + const chips = screen.getAllByTestId('barred-chip'); + expect(chips).toHaveLength(1); + expect(chips[0].textContent).toBe('Withdrawn'); + // Rides in the shiro name cell (sideB), never the aka one. + const [shiroCell, akaCell] = document.querySelectorAll('.tw-match__name'); + expect(shiroCell.contains(chips[0])).toBe(true); + expect(akaCell.contains(chips[0])).toBe(false); + }); + + it('shows no chip on either side when nobody is barred', () => { + const m = { + id: 'p2', compId: 'c1', status: 'scheduled', scheduledAt: '10:00', phase: 'pool', + sideA: aka, sideB: shiro, + }; + render(); + expect(screen.queryByTestId('barred-chip')).toBeNull(); + }); +}); + +describe('VSchedItem: the Withdrawn chip beside a barred competitor', () => { + it('shows the chip on the barred (aka/sideA) side only', () => { + const m = { + id: 'p3', compId: 'c1', status: 'scheduled', scheduledAt: '10:00', phase: 'pool', court: 'A', + sideA: aka, sideB: shiro, + ineligibleSides: { a: 'fusenpai' }, + }; + render(); + const chips = screen.getAllByTestId('barred-chip'); + expect(chips).toHaveLength(1); + // Lives inside the aka side, not the shiro side. + expect(document.querySelector('.vsched-item__side--aka').contains(chips[0])).toBe(true); + expect(document.querySelector('.vsched-item__side--shiro').contains(chips[0])).toBe(false); + // The centre middle mark stays "vs", never the chip's text. + expect(document.querySelector('.vsched-item__vs').textContent).toBe('vs'); + }); +}); + +describe('PoolNumberedMatchRow: the Withdrawn chip beside a barred competitor', () => { + it('shows the chip on the barred (shiro/sideB) side only, centre stays vs', () => { + const m = { + id: 'Pool A-1', status: 'scheduled', + sideA: aka, sideB: shiro, + ineligibleSides: { b: 'kiken-injury' }, + }; + render(); + const chips = screen.getAllByTestId('barred-chip'); + expect(chips).toHaveLength(1); + expect(document.querySelector('.pool-match-numbered-row__side--shiro').contains(chips[0])).toBe(true); + expect(document.querySelector('.pool-match-numbered-row__score').textContent).toBe('vs'); + }); +}); + +describe('MatchCard: the Withdrawn chip rides in the results column, never the meta strip', () => { + it('shows the chip on the barred side (sideA) and not on sideB', () => { + const m = { + id: 'b1', court: 'A', status: 'scheduled', + sideA: aka, sideB: shiro, + ineligibleSides: { a: 'kiken-voluntary' }, + }; + render(); + const chips = screen.getAllByTestId('barred-chip'); + expect(chips).toHaveLength(1); + // Inside .bc-side (the results column), never .bc-match-meta (the strip + // that carries the closed middle-mark set: NOW / BYE / X / (E) / (DH)). + expect(document.querySelector('.bc-match-meta').contains(chips[0])).toBe(false); + expect(document.querySelector('.bc-side--a').contains(chips[0])).toBe(true); + expect(document.querySelector('.bc-side--b').contains(chips[0])).toBe(false); + }); + + it('shows no chip for an ordinary scheduled match', () => { + const m = { id: 'b2', court: 'A', status: 'scheduled', sideA: aka, sideB: shiro }; + render(); + expect(screen.queryByTestId('barred-chip')).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/bracket_bye_slot.render.test.jsx b/web-mobile/js/__tests__/render/bracket_bye_slot.render.test.jsx deleted file mode 100644 index 7c842cf77..000000000 --- a/web-mobile/js/__tests__/render/bracket_bye_slot.render.test.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, beforeAll } from 'vitest'; - -// A non-power-of-two knockout contains structural byes: an entrant who advances -// a round with no opponent. BracketTreeMeta draws that as a standalone -// .bc-bye-slot placeholder in the upstream column. A NAMED slot used to render -// the competitor's name and nothing else, so a spectator saw an unexplained grey -// box sitting beside the match cards. These tests pin that the slot always -// carries the visible BYE marker, and that the marker is printed exactly once in -// either case (named or nameless). -// -// Render project (not the unit one): the marker is DOM the viewer reads off the -// bracket, so it has to be asserted on a real mount. The unit suite's fake-React -// stub never produces the slot markup. - -let BracketTree; - -beforeAll(async () => { - // BracketTree measures card geometry for its connectors; jsdom has no - // ResizeObserver. Only the slot TEXT matters here. - if (typeof globalThis.ResizeObserver === 'undefined') { - globalThis.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} - }; - } - await import('../../bracket.jsx'); - BracketTree = window.BracketTree; -}); - -// 5 entrants, the smallest roster that produces a structural bye. Same fixture -// shape as bracket_display_model.test.jsx / bracket_feeder_slot.render.test.jsx: -// Carol is seeded straight into the semi-final, so m-r2-1's sideA feeder is "" -// and buildDisplayModel inserts a named bye slot for her in the column before. -const side = (name) => ({ id: name ? `p-${name}` : '', name }); -const fivePlayerRounds = () => [ - [ - { id: 'm-r1-0', court: 'A', status: 'scheduled', sideA: side('Alice'), sideB: side('Bob'), displayRound: 2, feeders: ['', ''] }, - { id: 'm-r1-1', court: 'A', sideA: side(''), sideB: side(''), hidden: true }, - { id: 'm-r1-2', court: 'A', sideA: side('Carol'), sideB: side(''), hidden: true }, - { id: 'm-r1-3', court: 'A', status: 'scheduled', sideA: side('Dave'), sideB: side('Eve'), displayRound: 3, feeders: ['', ''] }, - ], - [ - { id: 'm-r2-0', court: 'A', sideA: side('Winner of r3-m0'), sideB: side(''), hidden: true }, - { id: 'm-r2-1', court: 'A', status: 'scheduled', sideA: side('Carol'), sideB: side('Winner of r3-m3'), displayRound: 2, feeders: ['', 'm-r1-3'] }, - ], - [ - { id: 'm-r3-0', court: 'A', status: 'scheduled', sideA: side('Winner of r2-m0'), sideB: side('Winner of r2-m1'), displayRound: 1, feeders: ['m-r1-0', 'm-r2-1'] }, - ], -]; - -const byeSlots = (container) => Array.from(container.querySelectorAll('.bc-bye-slot')); -const slotNamed = (container, name) => - byeSlots(container).find((s) => s.querySelector('.bc-bye-slot__name')?.textContent === name); - -describe('public bracket: a named bye slot is legible as an unopposed advance', () => { - // Alice, Bob and Carol are each seeded past a round in this bracket, so the - // fixture draws three named placeholders. - it('renders the competitor name AND a visible BYE marker in the same slot', () => { - const { container } = render(); - const slot = slotNamed(container, 'Carol'); - expect(slot).toBeTruthy(); - const tags = slot.querySelectorAll('.bc-bye-slot__tag'); - expect(tags).toHaveLength(1); // marker printed exactly once - expect(tags[0].textContent).toBe('BYE'); - // What a spectator actually reads off the box, in order. - expect(slot.textContent).toBe('CarolBYE'); - }); - - it('marks every named slot, as visible text and not only in the aria-label', () => { - const { container } = render(); - const slots = byeSlots(container); - expect(slots).toHaveLength(3); - expect(slots.map((s) => s.textContent).sort()).toEqual(['AliceBYE', 'BobBYE', 'CarolBYE']); - expect(screen.getAllByText('BYE')).toHaveLength(3); - }); - - it('prints the marker once, not twice, when the slot has no name', () => { - // A bye whose feeding side carries no player: the placeholder is nameless. - const rounds = fivePlayerRounds(); - rounds[1][1].sideA = side(''); - const { container } = render(); - const nameless = byeSlots(container).filter((s) => !s.querySelector('.bc-bye-slot__name')); - expect(nameless).toHaveLength(1); - expect(nameless[0].querySelectorAll('.bc-bye-slot__tag')).toHaveLength(1); - expect(nameless[0].textContent).toBe('BYE'); - }); -}); diff --git a/web-mobile/js/__tests__/render/bracket_round_label.render.test.jsx b/web-mobile/js/__tests__/render/bracket_round_label.render.test.jsx index 8b69a1dad..b4311a2fb 100644 --- a/web-mobile/js/__tests__/render/bracket_round_label.render.test.jsx +++ b/web-mobile/js/__tests__/render/bracket_round_label.render.test.jsx @@ -61,8 +61,8 @@ const fivePlayerRounds = () => [ ]; // Every rendered column: its header text and the ids of the match cards drawn -// under it, straight out of the DOM. Bye slots carry no data-match-id (they are -// a .bc-bye-slot div, not a MatchCard button), so only real matches appear. +// under it, straight out of the DOM. Only a MatchCard carries data-match-id, and +// the tree draws nothing else, so only real matches appear. const domColumns = (container) => Array.from(container.querySelectorAll('.bc-round')).map((col) => { const header = col.querySelector('.bc-round-label'); return { diff --git a/web-mobile/js/__tests__/render/bracket_structural_bye.render.test.jsx b/web-mobile/js/__tests__/render/bracket_structural_bye.render.test.jsx new file mode 100644 index 000000000..639090ba4 --- /dev/null +++ b/web-mobile/js/__tests__/render/bracket_structural_bye.render.test.jsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +// bc-tmfn (operator ruling). A non-power-of-two knockout contains structural +// byes: a competitor who skips a round because no match feeds their side. The +// bracket shows them the way the EKC reference sheets and the Excel tree page +// print them: ONLY in the card of the match they first fight. No placeholder +// card in the column before, no "BYE" tag, and no connector into that side. +// Where that leaves a card fed by ONE match, the connector from that match +// ends on the row it fills (option A), not at the seam between the two rows, +// and the card is placed with that row level with the match, so the line runs +// straight. +// +// These replace the pins of the old .bc-bye-slot placeholder, which drew each +// such competitor a second time, in a dashed box tagged BYE, one column early. +// +// Render project (not the unit one): what is and is not drawn, and where the +// SVG connector actually ends, only exist on a real mount. + +let BracketTree; + +beforeAll(async () => { + // BracketTree measures card geometry for its connectors; jsdom has no + // ResizeObserver. + if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + await import('../../bracket.jsx'); + BracketTree = window.BracketTree; +}); + +// 5 entrants, the smallest roster with a structural bye, as the engine +// persists it (internal/engine/testdata/bracket_match_numbers.json, the +// 5-entrant case, with names instead of Player01..05). One first-round bout, +// Dave v Eve (m-r1-3). Alice and Bob come straight into one semifinal +// (m-r1-0, fed by nothing); Carol comes straight into the other (m-r2-1) as +// sideA, against the winner of m-r1-3 as sideB. +const side = (name) => ({ id: name ? `p-${name}` : '', name }); +const fivePlayerRounds = () => [ + [ + { id: 'm-r1-0', court: 'A', status: 'scheduled', sideA: side('Alice'), sideB: side('Bob'), displayRound: 2, feeders: ['', ''] }, + { id: 'm-r1-1', court: 'A', sideA: side(''), sideB: side(''), hidden: true }, + { id: 'm-r1-2', court: 'A', sideA: side('Carol'), sideB: side(''), hidden: true }, + { id: 'm-r1-3', court: 'A', status: 'scheduled', sideA: side('Dave'), sideB: side('Eve'), displayRound: 3, feeders: ['', ''] }, + ], + [ + { id: 'm-r2-0', court: 'A', sideA: side('Winner of r3-m0'), sideB: side(''), hidden: true }, + { id: 'm-r2-1', court: 'A', status: 'scheduled', sideA: side('Carol'), sideB: side('Winner of r3-m3'), displayRound: 2, feeders: ['', 'm-r1-3'] }, + ], + [ + { id: 'm-r3-0', court: 'A', status: 'scheduled', sideA: side('Winner of r2-m0'), sideB: side('Winner of r2-m1'), displayRound: 1, feeders: ['m-r1-0', 'm-r2-1'] }, + ], +]; + +// The same draw with Carol's semifinal mirrored: the winner of m-r1-3 is +// sideA (the Aka row) and Carol, coming straight in, is sideB. +const mirroredCarolSF = () => { + const rounds = fivePlayerRounds(); + rounds[1][1] = { ...rounds[1][1], sideA: side('Winner of r3-m3'), sideB: side('Carol'), feeders: ['m-r1-3', ''] }; + return rounds; +}; + +const columnCardIds = (container) => Array.from(container.querySelectorAll('.bc-round')).map((col) => ({ + wraps: col.querySelectorAll('.bc-match-wrap').length, + ids: Array.from(col.querySelectorAll('[data-match-id]')).map((el) => el.getAttribute('data-match-id')), +})); + +const occurrences = (text, needle) => text.split(needle).length - 1; + +describe('bracket: a competitor who skips a round appears only in the card of their first match', () => { + it('draws no placeholder card and no BYE tag', () => { + const { container } = render(); + expect(container.querySelectorAll('.bc-bye-slot')).toHaveLength(0); + expect(screen.queryAllByText('BYE')).toHaveLength(0); + expect(container.textContent).not.toContain('BYE'); + }); + + it('holds only the one first-round bout in the first column', () => { + const { container } = render(); + const cols = columnCardIds(container); + expect(cols.map((c) => c.ids)).toEqual([['m-r1-3'], ['m-r1-0', 'm-r2-1'], ['m-r3-0']]); + // Every box in a column is a match card: nothing else is drawn. + expect(cols.map((c) => c.wraps)).toEqual([1, 2, 1]); + }); + + it('shows Alice, Bob and Carol once each, in their semifinal cards', () => { + const { container } = render(); + const home = { Alice: 'm-r1-0', Bob: 'm-r1-0', Carol: 'm-r2-1' }; + for (const [name, matchId] of Object.entries(home)) { + // Once in the whole tree: the placeholder used to print them twice. + expect(occurrences(container.textContent, name), name).toBe(1); + const els = screen.getAllByText(name); + expect(els, name).toHaveLength(1); + expect(els[0].closest('[data-match-id]').getAttribute('data-match-id'), name).toBe(matchId); + } + }); + + it('draws a connector only between matches: none into a skipped side', async () => { + const { container } = render(); + await act(async () => { + window.dispatchEvent(new Event('resize')); + }); + // m-r1-3 → m-r2-1, and the final's two. The old placeholders added three + // more (two into Alice/Bob's card, one into Carol's row). + expect(container.querySelectorAll('.bc-connectors path')).toHaveLength(3); + }); +}); + +// Card geometry for the connector pins (tree-relative px; the .bc-tree root +// keeps jsdom's zero rect, so tree-relative == page). Columns are 230px wide +// with a 56px gap, as on the real render (bracket_connector_elbow.render.test.jsx). +// Each card is 80px tall: a 20px meta header, then the Aka row (.bc-side--a, +// top+20..top+50, centre top+35) and the Shiro row (.bc-side--b, top+50..top+80, +// centre top+65). The anchor, the seam between the rows, is at top+50. +// +// A card's TOP is read from where the tree itself placed it (its wrapper's +// style.top, set from computeMetaTops once the cards are measured), so these +// pins follow the real placement rather than a hand-copied one: a line only +// runs straight here if the tree put the card where the line needs it. +const COLUMN_X = { + 'm-r1-3': { left: 0, right: 230 }, + 'm-r1-0': { left: 286, right: 516 }, + 'm-r2-1': { left: 286, right: 516 }, + 'm-r3-0': { left: 572, right: 802 }, +}; + +function installGeometryMock() { + const original = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function mockGetBoundingClientRect() { + const card = this.closest && this.closest('[data-match-id]'); + const x = card && COLUMN_X[card.getAttribute('data-match-id')]; + if (!x) return original.call(this); + const cardTop = parseFloat(card.closest('.bc-match-wrap')?.style.top) || 0; + let top, bottom; + if (this.classList.contains('bc-side--a')) { top = cardTop + 20; bottom = cardTop + 50; } + else if (this.classList.contains('bc-side--b')) { top = cardTop + 50; bottom = cardTop + 80; } + else if (this === card) { top = cardTop; bottom = cardTop + 80; } + else return original.call(this); + return { + left: x.left, right: x.right, top, bottom, + width: x.right - x.left, height: bottom - top, + x: x.left, y: top, + toJSON() { return this; }, + }; + }; + return () => { Element.prototype.getBoundingClientRect = original; }; +} + +// Two passes: the first measures the cards and places them, the second draws +// the connectors from the placed cards (in a browser the tree's own +// ResizeObserver triggers the second; jsdom has none). +const connectorPaths = async (rounds) => { + const { container } = render(); + for (let pass = 0; pass < 2; pass++) { + await act(async () => { + window.dispatchEvent(new Event('resize')); + }); + } + return Array.from(container.querySelectorAll('.bc-connectors path')).map((p) => p.getAttribute('d')); +}; + +describe('bracket connectors: a card fed by one match takes it on the fed row, in a straight line', () => { + let restoreGeometry; + beforeAll(() => { restoreGeometry = installGeometryMock(); }); + afterAll(() => { restoreGeometry(); }); + + it("runs Dave/Eve's connector straight into the centre of Carol's card's Shiro row when it fills sideB", async () => { + const paths = await connectorPaths(fivePlayerRounds()); + expect(paths).toHaveLength(3); + // Alice/Bob is stacked first (top 0). Carol's card rises 15px against its + // feeder (its Shiro row centre, 65, sits 15 below its seam, 50), so Dave/Eve + // is stacked 15 lower than the 96 it would take (111, anchor 161) and + // Carol's card lands at 96, keeping its 16px gap below Alice/Bob. Its Shiro + // row centre, 96 + 65 = 161, is level with Dave/Eve's anchor: no bend. + expect(paths).toContain('M 230 161 L 258 161 L 258 161 L 286 161'); + // The final's two connectors join at its anchor, centred on the two + // semifinal anchors ((50 + 146) / 2 = 98). + expect(paths).toContain('M 516 50 L 544 50 L 544 98 L 572 98'); + expect(paths).toContain('M 516 146 L 544 146 L 544 98 L 572 98'); + }); + + it("runs it straight into the Aka row's centre when the fed side is sideA", async () => { + const paths = await connectorPaths(mirroredCarolSF()); + expect(paths).toHaveLength(3); + // Dave/Eve is stacked at 96 (anchor 146). Carol's card drops 15px against + // it (its Aka row centre, 35, sits 15 above its seam), to 111, so its Aka + // row centre, 111 + 35 = 146, is level with Dave/Eve's anchor. + expect(paths).toContain('M 230 146 L 258 146 L 258 146 L 286 146'); + // The final centres on the semifinal anchors ((50 + 161) / 2 = 105.5). + expect(paths).toContain('M 516 50 L 544 50 L 544 105.5 L 572 105.5'); + expect(paths).toContain('M 516 161 L 544 161 L 544 105.5 L 572 105.5'); + }); + + // The connector SVG spans the cards themselves: the rightmost card's right + // edge (the final, 802) and the lowest card's bottom (Dave/Eve, 111 + 80). + // Sized from the tree's own scroll size instead, it would include itself and + // hold a tree whose columns narrow to fit at its first, wider width. + it('sizes the connector SVG to the cards it joins', async () => { + const { container } = render(); + for (let pass = 0; pass < 2; pass++) { + await act(async () => { + window.dispatchEvent(new Event('resize')); + }); + } + const svg = container.querySelector('.bc-connectors'); + expect(svg.getAttribute('width')).toBe('802'); + expect(svg.getAttribute('height')).toBe('191'); + }); +}); diff --git a/web-mobile/js/__tests__/render/display_tv_dh_gating.render.test.jsx b/web-mobile/js/__tests__/render/display_tv_dh_gating.render.test.jsx new file mode 100644 index 000000000..96d2878f7 --- /dev/null +++ b/web-mobile/js/__tests__/render/display_tv_dh_gating.render.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { TvDisplay } from '../../display.jsx'; + +// bc-cse: the TV board's Daihyosen-row gate (TvDisplay's showDH, a tied +// knockout team match with every regular bout DONE) used to hand-roll its own +// "has a result" test inline, missing the encho-only branch subBoutHasResult +// (team_default_credit.jsx) already covers: a bout fought into overtime with +// no ippon letters, winner, decision or hansoku recorded yet (an operator +// working the score sheet who has marked the period without a strike). The +// consolidated gate treats that bout as done, same as every other surface +// asking subBoutHasResult the same question. +describe('TvDisplay: Daihyosen banner gate counts an overtime bout as done', () => { + function knockoutTeamComp(subResults) { + return { + id: 'cT', name: 'Team Cup', kind: 'team', teamSize: 1, withZekkenName: false, + poolMatches: [], + bracket: { rounds: [ + [{ id: 'm-r1-0', court: 'A', status: 'running', + sideA: { name: 'Red Dojo' }, sideB: { name: 'White Dojo' }, + subResults }], + ] }, + }; + } + + it('shows the Daihyosen banner once the lone regular bout is in overtime, no other marker', () => { + const comp = knockoutTeamComp([ + { position: 1, sideA: '', sideB: '', ipponsA: [], ipponsB: [], encho: { periodCount: 1 } }, + ]); + const { container } = render( + + ); + expect(container.querySelector('[data-testid="tvd-dh-pending"]')).toBeTruthy(); + }); + + it('does NOT show it while the lone regular bout carries no marker at all', () => { + const comp = knockoutTeamComp([ + { position: 1, sideA: '', sideB: '', ipponsA: [], ipponsB: [] }, + ]); + const { container } = render( + + ); + expect(container.querySelector('[data-testid="tvd-dh-pending"]')).toBeNull(); + }); +}); + +// The TV board asks only whether every regular bout is done; whether the +// encounter is TIED is TeamScoreboard's own test, which reads the server's +// teamResult first (teamIVPWFrom), the same source the viewer card and the +// board's own headline IV/PW read. It used to repeat the tie test from the +// bout rows alone, so wherever those disagreed with the server's figures the +// TV hid a Daihyosen row the card showed. +describe('TvDisplay: the Daihyosen row follows the same tie as every other surface', () => { + function tiedByServer(subResults, teamResult) { + return { + id: 'cT', name: 'Team Cup', kind: 'team', teamSize: 1, withZekkenName: false, + poolMatches: [], + bracket: { rounds: [ + [{ id: 'm-r1-0', court: 'A', status: 'running', + sideA: { name: 'Red Dojo' }, sideB: { name: 'White Dojo' }, + subResults, teamResult }], + ] }, + }; + } + + it('shows it when the server reads the encounter tied, whatever the bout rows alone would count', () => { + // The rows and teamResult disagree on purpose: the row credits Aka a + // point, the server's figures are level. The server's figure is the one + // every surface shows, so the TV follows it. + const comp = tiedByServer( + [{ position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [], winner: 'Red Dojo' }], + { shiroIV: 0, akaIV: 0, shiroPW: 0, akaPW: 0 }, + ); + const { container } = render( + + ); + expect(container.querySelector('[data-testid="tvd-dh-pending"]')).toBeTruthy(); + }); + + it('does NOT show it when the encounter is not tied, with no server figures to read', () => { + const comp = tiedByServer( + [{ position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [], winner: 'Red Dojo' }], + undefined, + ); + const { container } = render( + + ); + expect(container.querySelector('[data-testid="tvd-dh-pending"]')).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/display_tv_headline_name_mark_clip.render.test.jsx b/web-mobile/js/__tests__/render/display_tv_headline_name_mark_clip.render.test.jsx new file mode 100644 index 000000000..1c6556010 --- /dev/null +++ b/web-mobile/js/__tests__/render/display_tv_headline_name_mark_clip.render.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { TvWhiteBoard } from '../../display.jsx'; + +// bc-cse (fix #4): the TV headline (TvWhiteBoard, display_scoreboard.jsx) +// wraps a NumberedName clip element beside a Kiken/Fus. mark inside a +// block-ellipsis div -- a long Shiro name used to clip the trailing mark off +// the end. See viewer_match.jsx's VSchedItem comment for the mechanism; this +// pins the same structural fix (msb-name--labelled) landed on this host. +describe('TV headline: the name mark survives clipping (bc-cse #4)', () => { + const base = { + tournament: { name: 'Cup' }, court: 'A', connected: true, + lineupA: null, lineupB: null, showDH: false, queueMatches: [], zekken: false, + }; + + // Shiro (the long name) withdrew (decisionBy "shiro"), crediting Aka the + // Kiken mark's COMPLEMENT -- Shiro carries the "Kiken" loser mark. + function promotedWithdrawn() { + return { + match: { + id: 'm1', status: 'completed', decision: 'kiken-voluntary', decisionBy: 'shiro', + sideA: { name: 'Aoki Dojo', number: 'T1' }, + sideB: { name: 'A Very Long Shiro Team Name That Should Clip', number: 'T2' }, + winner: { name: 'Aoki Dojo' }, + subResults: [{ position: 1 }], + }, + competition: { id: 'c1', name: 'Team Cup', kind: 'team', teamSize: 3, format: 'mixed' }, + isBracket: false, + }; + } + + it('the headline div carries msb-name--labelled, and the mark is a real sb-result-mark sibling', () => { + const p = promotedWithdrawn(); + const props = { ...base, promoted: p, isTeamMatch: true, subResults: p.match.subResults, teamSize: 3 }; + const { container } = render(); + + const mark = container.querySelector('.sb-result-mark'); + expect(mark).toBeTruthy(); + expect(mark.textContent).toBe('Kiken'); + + const labelled = mark.closest('.msb-name--labelled'); + expect(labelled).toBeTruthy(); + + // The mark is a sibling, not appended into the name's own text node. + const nameText = labelled.querySelector('.numbered-name__text'); + expect(nameText).toBeTruthy(); + expect(nameText.textContent).toContain('A Very Long Shiro Team Name'); + expect(nameText.textContent).not.toContain('Kiken'); + }); +}); diff --git a/web-mobile/js/__tests__/render/display_tv_individual_barred_chip.render.test.jsx b/web-mobile/js/__tests__/render/display_tv_individual_barred_chip.render.test.jsx new file mode 100644 index 000000000..51526b893 --- /dev/null +++ b/web-mobile/js/__tests__/render/display_tv_individual_barred_chip.render.test.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { TvDisplay } from '../../display.jsx'; + +// bc-cse: every phone schedule row (VSchedItem, TWMatch, viewer_standings) +// shows the "Withdrawn" chip beside a barred competitor's name on a +// still-scheduled match. The TV individual pool board (TvIndividualBoard) +// listed the same row as a plain scheduled match with no chip at all -- +// IndividualScore's showBarredChip prop (match_scoreboard.jsx) closes that +// gap, reusing barred_chip.jsx exactly as the phone rows do. +describe('TvIndividualBoard: a scheduled barred bout shows the Withdrawn chip (bc-cse)', () => { + function comp() { + return { + id: 'c1', name: 'Kendo Cup', kind: 'individual', teamSize: 0, withZekkenName: false, + poolMatches: [ + { id: 'Pool A-0', court: 'A', status: 'running', + sideA: { name: 'Aoki Taro' }, sideB: { name: 'Endo Goro' }, ipponsA: ['M'], ipponsB: [] }, + { id: 'Pool A-1', court: 'A', status: 'scheduled', + sideA: { name: 'Sato Ken' }, sideB: { name: 'Ito Rei' }, + ineligibleSides: { b: 'kiken-voluntary' } }, + ], + bracket: { rounds: [] }, + }; + } + + it('shows a barred-chip on the scheduled barred row', () => { + const { container } = render( + + ); + expect(container.querySelectorAll('[data-testid="barred-chip"]').length).toBe(1); + expect(container.textContent).toContain('Withdrawn'); + }); + + it('shows no chip when nobody is barred', () => { + const c = comp(); + c.poolMatches[1].ineligibleSides = undefined; + const { container } = render( + + ); + expect(container.querySelectorAll('[data-testid="barred-chip"]').length).toBe(0); + }); +}); diff --git a/web-mobile/js/__tests__/render/editor_barred_match.render.test.jsx b/web-mobile/js/__tests__/render/editor_barred_match.render.test.jsx new file mode 100644 index 000000000..52ca4eb61 --- /dev/null +++ b/web-mobile/js/__tests__/render/editor_barred_match.render.test.jsx @@ -0,0 +1,174 @@ +// bc-cse: a SCHEDULED match a competitor is barred from (withdrew earlier, +// or is kiken-injury and not yet reinstated) cannot be fought. Opening it in +// either editor -- how the operator reaches it, per ineligible_match.jsx's +// header -- must show BarredMatchNotice (admin_scoring_shared.jsx) where +// "Start match" would be, never a Start the server would just refuse. + +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: {}, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + compMatchesForCompetition: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + window.API = { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + reinstateCompetitor: vi.fn().mockResolvedValue({}), + }; +}); + +// Yamada (aka) vs Tanaka (shiro), a scheduled knockout Round 1 bout. Tanaka +// withdrew earlier (kiken-voluntary): the default win is awaited for Yamada. +const individualBarred = (over = {}) => ({ + id: 'm-r1-0', compId: 'comp1', status: 'scheduled', phase: 'bracket', round: 'Round 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0, + ineligibleSides: { b: 'kiken-voluntary' }, + ...over, +}); + +// Kyoto (aka) vs Osaka (shiro), a scheduled team pool match. Kyoto withdrew +// injured (kiken-injury, reinstateable): both the default win and Reinstate +// must be offered. +const teamBarred = (over = {}) => ({ + id: 'm-pool-1', compId: 'comp1', status: 'scheduled', phase: 'pool', poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 3, + sideA: { id: 'team-kyoto', name: 'Kyoto' }, sideB: { id: 'team-osaka', name: 'Osaka' }, + ineligibleSides: { a: 'kiken-injury' }, + ...over, +}); + +async function mount(match, props = {}) { + let view; + await act(async () => { + view = render(); + }); + return view; +} + +describe('a scheduled barred match shows BarredMatchNotice instead of Start (bc-cse)', () => { + it('individual editor: no Start match button; the note and default-win action are offered instead', async () => { + await mount(individualBarred()); + expect(screen.queryByText('Start match')).toBeNull(); + const notice = screen.getByTestId('barred-match-notice'); + expect(notice.textContent).toContain('Tanaka withdrew: record the default win.'); + // kiken-voluntary is not reinstateable. + expect(screen.queryByTestId('barred-match-reinstate')).toBeNull(); + const btn = screen.getByTestId('barred-match-default-win'); + expect(btn.textContent).toBe('Record default win for Yamada'); + + await act(async () => { fireEvent.click(btn); }); + expect(window.API.recordDecision).toHaveBeenCalledWith('comp1', 'm-r1-0', { + decision: 'fusensho', decisionBy: 'shiro', decisionReason: 'auto: Tanaka withdrawn', + }, 'secret'); + }); + + // bc-cse: BarredMatchNotice is a full-width block, lifted above + // .score-nav__actions (a centred WRAPPING flex row of small buttons) -- + // not squeezed into one flex item alongside them. + it('individual editor: the notice is not nested inside .score-nav__actions', async () => { + await mount(individualBarred()); + const notice = screen.getByTestId('barred-match-notice'); + const actions = document.querySelector('.score-nav__actions'); + expect(actions.contains(notice)).toBe(false); + }); + + it('individual editor: a non-barred scheduled match still shows Start match', async () => { + await mount(individualBarred({ ineligibleSides: undefined })); + expect(screen.getByText('Start match')).toBeTruthy(); + expect(screen.queryByTestId('barred-match-notice')).toBeNull(); + }); + + it('team editor: no Start match button; both the default-win action and Reinstate are offered for a kiken-injury withdrawal', async () => { + await mount(teamBarred()); + expect(screen.queryByText('Start match')).toBeNull(); + const notice = screen.getByTestId('barred-match-notice'); + expect(notice.textContent).toContain('Kyoto withdrew injured: reinstate them or record the default win.'); + + const awardBtn = screen.getByTestId('barred-match-default-win'); + expect(awardBtn.textContent).toBe('Record default win for Osaka'); + const reinstateBtn = screen.getByTestId('barred-match-reinstate'); + expect(reinstateBtn.textContent).toBe('Reinstate Kyoto'); + + await act(async () => { fireEvent.click(reinstateBtn); }); + expect(window.API.reinstateCompetitor).toHaveBeenCalledWith('comp1', 'team-kyoto', 'secret'); + }); + + // bc-cse: same structural rule for the team editor. + it('team editor: the notice is not nested inside .score-nav__actions', async () => { + await mount(teamBarred()); + const notice = screen.getByTestId('barred-match-notice'); + const actions = document.querySelector('.score-nav__actions'); + expect(actions.contains(notice)).toBe(false); + }); + + it('team editor: a non-barred scheduled match still shows Start match', async () => { + await mount(teamBarred({ ineligibleSides: undefined })); + expect(screen.getByText('Start match')).toBeTruthy(); + expect(screen.queryByTestId('barred-match-notice')).toBeNull(); + }); +}); + +// bc-cse: "If both competitors withdraw, neither receives a win or points." +// A pool/league match can be recorded as drawn; a knockout match cannot (the +// bracket needs a winner), so it offers no action at all. +describe('both sides barred (bc-cse)', () => { + it('team editor (pool): offers "Record as drawn", which posts an exact hikiwake body', async () => { + // A real pool id: the server accepts this draw for pool/league ids only. + await mount(teamBarred({ id: 'Pool 1-0', ineligibleSides: { a: 'kiken-injury', b: 'fusenpai' } })); + const notice = screen.getByTestId('barred-match-notice'); + expect(notice.textContent).toContain('Both withdrew earlier: neither can fight this match.'); + // Neither single-sided action is offered: awaitedDefaultWin is null when + // both sides are barred. + expect(screen.queryByTestId('barred-match-default-win')).toBeNull(); + expect(screen.queryByTestId('barred-match-reinstate')).toBeNull(); + + const btn = screen.getByTestId('barred-match-record-drawn'); + expect(btn.textContent).toBe('Record as drawn (neither can fight)'); + await act(async () => { fireEvent.click(btn); }); + expect(window.API.recordDecision).toHaveBeenCalledWith('comp1', 'Pool 1-0', { + decision: 'hikiwake', decisionReason: 'auto: Kyoto and Osaka withdrawn', + }, 'secret'); + }); + + it('individual editor (knockout): no action at all, and the note says to correct the draw', async () => { + await mount(individualBarred({ ineligibleSides: { a: 'kiken-voluntary', b: 'fusenpai' } })); + const notice = screen.getByTestId('barred-match-notice'); + expect(notice.textContent).toBe('Neither can fight: correct the earlier withdrawal or the draw.'); + expect(screen.queryByTestId('barred-match-default-win')).toBeNull(); + expect(screen.queryByTestId('barred-match-reinstate')).toBeNull(); + expect(screen.queryByTestId('barred-match-record-drawn')).toBeNull(); + expect(notice.querySelectorAll('button').length).toBe(0); + }); +}); diff --git a/web-mobile/js/__tests__/render/individual_editor_ko_tie_blocked.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_ko_tie_blocked.render.test.jsx new file mode 100644 index 000000000..96765cef7 --- /dev/null +++ b/web-mobile/js/__tests__/render/individual_editor_ko_tie_blocked.render.test.jsx @@ -0,0 +1,162 @@ +// bc-rawm: a tied INDIVIDUAL knockout bout (equal ippon counts, no hantei +// verdict, no recorded withdrawal) has no winner. buildPatch's own ippon- +// decided branch falls through to a hikiwake result with winner:null when +// the two sides are level -- a shape a knockout match can never legally +// carry -- so Finish (and Enter, and Finish + Start Next, all keyed on the +// same canFinish) must refuse it until the operator either scores past the +// tie or records hantei. The server refuses the identical shape on a +// completed correction (validateBracketCompletion), so the block holds +// there too, not just on the first Finish. +// +// Reuses isKoTieBlocked from admin_scoring_team.jsx (the team editor's +// identical "knockout match, no winner" rule), never a re-derivation of it. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +// Yamada (aka) vs Tanaka (shiro), a running knockout Round 1 bout. +const match = { + id: 'm-r1-0', compId: 'comp1', status: 'running', phase: 'bracket', round: 'Round 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, + sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0, +}; + +async function mount(props = {}) { + let view; + let onSubmit = vi.fn().mockResolvedValue(undefined); + await act(async () => { + view = render(); + }); + return { ...view, onSubmit: props.onSubmit || onSubmit }; +} + +const addButton = (side, letter) => [...document.querySelectorAll(`.sb-side--${side} .ipt-btn`)].find((b) => b.textContent === letter); +const finishButton = () => [...document.querySelectorAll('.score-nav button')].find((b) => /^(Finish|Save correction|Needs a winner)/.test(b.textContent)); + +describe('a tied individual knockout bout blocks Finish (bc-rawm)', () => { + it('1-1 disables Finish, changes its label, and states the reason', async () => { + await mount(); + await act(async () => { fireEvent.click(addButton('aka', 'M')); }); + await act(async () => { fireEvent.click(addButton('shiro', 'M')); }); + + const btn = finishButton(); + expect(btn.textContent).toBe('Needs a winner'); + expect(btn.disabled).toBe(true); + expect(btn.title).toBe('Needs a winner: fight encho, then record hantei if still tied.'); + }); + + it('a non-tied score (1-0) leaves Finish enabled, with its usual label', async () => { + await mount(); + await act(async () => { fireEvent.click(addButton('aka', 'M')); }); + + const btn = finishButton(); + expect(btn.textContent).toBe('Finish'); + expect(btn.disabled).toBe(false); + }); + + it('Enter does not submit while tied', async () => { + const { onSubmit } = await mount(); + await act(async () => { fireEvent.click(addButton('aka', 'M')); }); + await act(async () => { fireEvent.click(addButton('shiro', 'M')); }); + await act(async () => { fireEvent.keyDown(window, { key: 'Enter' }); }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('Enter submits once the tie is broken', async () => { + const { onSubmit } = await mount(); + await act(async () => { fireEvent.click(addButton('aka', 'M')); }); + await act(async () => { fireEvent.keyDown(window, { key: 'Enter' }); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + // The server refuses the identical tied shape on a completed correction + // (validateBracketCompletion), so the block must hold there too: a + // completed match that now reads 1-1 (e.g. after the operator removed a + // point) cannot Save correction either. + it('blocks a completed correction that now reads 1-1 too', async () => { + await mount({ match: { ...match, status: 'completed', ipponsA: ['M'], ipponsB: ['K'], winner: null } }); + const btn = finishButton(); + expect(btn.textContent).toBe('Needs a winner'); + expect(btn.disabled).toBe(true); + }); + + // A recorded withdrawal already has a winner regardless of the scoreline, + // so it is exempt from the tie block: Save correction stays available. + it('does not block a withdrawal-decided completed match even when its scoreline reads tied', async () => { + await mount({ + match: { + ...match, status: 'completed', decision: 'kiken-voluntary', decisionBy: 'shiro', + ipponsA: ['○', '○'], ipponsB: [], winner: { id: 'p1', name: 'Yamada' }, + }, + }); + const btn = finishButton(); + expect(btn.textContent).toBe('Save correction'); + expect(btn.disabled).toBe(false); + }); + + // bc-cse: aTotal===bTotal is true by construction at a pristine 0-0 (no + // points struck yet), so without the hasPointsOrDraw gate this read "Needs + // a winner" on EVERY knockout bout before anything happened -- disabled + // either way (canFinish already requires points), but the wording claimed + // a tie that never occurred. + it('a fresh 0-0 knockout bout (nothing scored yet) reads "Finish", not "Needs a winner"', async () => { + await mount(); + const btn = finishButton(); + expect(btn.textContent).toBe('Finish'); + expect(btn.disabled).toBe(true); // still nothing to finish -- just not mislabeled + }); + + // Same reasoning for a match that has not even started: it reads 0-0 the + // same way a fresh running match does. + it('a SCHEDULED knockout match reads "Finish", not "Needs a winner", even at 0-0', async () => { + await mount({ match: { ...match, status: 'scheduled' } }); + const btn = finishButton(); + expect(btn.textContent).toBe('Finish'); + }); + + // A pool-phase individual match is not knockout, so the tie block never + // engages there: a 1-1 pool bout is drawable, not "needs a winner". + it('does not block a tied pool-phase match', async () => { + await mount({ match: { ...match, phase: 'pool', poolName: 'Pool 1' } }); + await act(async () => { fireEvent.click(addButton('aka', 'M')); }); + await act(async () => { fireEvent.click(addButton('shiro', 'M')); }); + const btn = finishButton(); + expect(btn.textContent).not.toBe('Needs a winner'); + }); +}); diff --git a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx index c649c774d..7058326ba 100644 --- a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx @@ -432,7 +432,9 @@ describe('the team editor shows the bout scoreline the server holds', () => { const onSubmit = vi.fn(); const { rerender, container } = open(teamMatch([]), onSubmit); await scoreOneForKyoto(container); - await reopenWith(rerender, teamMatch([bout(1, 'b')]), onSubmit); + // Bout 3 arrives too: Finish refuses while any bout has no result + // (bc-tmfn), and this test is about bout 1 surviving the write. + await reopenWith(rerender, teamMatch([bout(1, 'b'), bout(3, 'a')]), onSubmit); const finish = () => [...container.querySelectorAll('button')].find(b => /finish/i.test(b.textContent)); for (let i = 0; i < 2 && finish(); i++) { await act(async () => { fireEvent.click(finish()); }); } @@ -494,10 +496,12 @@ describe('the team editor shows the bout scoreline the server holds', () => { // `position: 4`, a fourth numbered bout in a three-person team. const onSubmit = vi.fn(); const dh = { position: -1, sideA: KYOTO, sideB: OSAKA, ipponsA: ['M'], ipponsB: ['K'], decision: 'daihyosen' }; - const { rerender, container } = open(teamMatch([bout(1, 'a'), dh]), onSubmit); + // Bout 3 is recorded so Finish is allowed to write (bc-tmfn: it refuses + // while any bout has no result). + const { rerender, container } = open(teamMatch([bout(1, 'a'), bout(3, 'b'), dh]), onSubmit); await scoreOneForKyoto(container); // dirty, so the adopt cannot paper over it - await reopenWith(rerender, teamMatch([bout(1, 'a')]), onSubmit); + await reopenWith(rerender, teamMatch([bout(1, 'a'), bout(3, 'b')]), onSubmit); const finishBtn = () => [...container.querySelectorAll('button')].find(b => /finish/i.test(b.textContent)); for (let i = 0; i < 2 && finishBtn(); i++) { await act(async () => { fireEvent.click(finishBtn()); }); } @@ -527,9 +531,10 @@ describe('the team editor shows the bout scoreline the server holds', () => { // emitted patch, since the rep bout is excluded from IV/PW by design and so // leaves no trace in the summary strip. const onSubmit = vi.fn(); - const { rerender, container } = open(teamMatch([bout(1, 'a'), bout(2, 'b')]), onSubmit); + // All three bouts recorded so Finish is allowed to write (bc-tmfn). + const { rerender, container } = open(teamMatch([bout(1, 'a'), bout(2, 'b'), bout(3, 'a')]), onSubmit); await reopenWith(rerender, teamMatch([ - bout(1, 'a'), bout(2, 'b'), + bout(1, 'a'), bout(2, 'b'), bout(3, 'a'), { position: -1, sideA: KYOTO, sideB: OSAKA, ipponsA: [], ipponsB: [], decision: 'daihyosen' }, ]), onSubmit); diff --git a/web-mobile/js/__tests__/render/kachinuki_done_bout_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_done_bout_double_tap.render.test.jsx new file mode 100644 index 000000000..0057071e4 --- /dev/null +++ b/web-mobile/js/__tests__/render/kachinuki_done_bout_double_tap.render.test.jsx @@ -0,0 +1,110 @@ +// bc-kbrw: a double tap on a fought kachinuki bout opened the bout above it. +// +// Tapping a fought bout row expands it for correction under the finger, so +// the second tap of a double tap landed on whatever moved there and opened +// ANOTHER bout. For DONE_BOUT_OPEN_TAP_GUARD_MS after a row opens, a pointer +// click on the bout list is swallowed in the capture phase. Pointer clicks +// carry detail >= 1 (fireEvent.click defaults to 0, which is what a click +// synthesized from the keyboard carries, so the tests pass detail explicitly). +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { DONE_BOUT_OPEN_TAP_GUARD_MS } from '../../admin_scoring_team.jsx'; + +const STUBBED_GLOBALS = { + isHikiwake: (_type) => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: (_kind) => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue({ + id: 'comp1', + config: { format: 'knockout', teamMatchType: 'kachinuki', naginata: false, players: [] }, + }), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); }); + +// Two fought bouts and the current one (same shape as the config matrix's +// "tapping a different fought bout" fixture). +async function renderEncounter() { + await act(async () => { + render( + , + ); + }); +} + +const tap = (el) => act(async () => { fireEvent.click(el, { detail: 1 }); }); +const isOpen = (idx) => !!screen.queryByTestId(`kachinuki-done-collapse-${idx}`); + +describe('bc-kbrw: a double tap on a fought bout opens that bout only', () => { + it('the second tap, inside the window, does not open another bout', async () => { + await renderEncounter(); + await tap(screen.getByTestId('kachinuki-done-bout-1')); + expect(isOpen(1)).toBe(true); + + // The second tap of the double tap lands on a different row. + await tap(screen.getByTestId('kachinuki-done-bout-0')); + expect(isOpen(1)).toBe(true); + expect(isOpen(0)).toBe(false); + }); + + it('a deliberate tap after the window opens the other bout', async () => { + await renderEncounter(); + await tap(screen.getByTestId('kachinuki-done-bout-1')); + await act(async () => { vi.advanceTimersByTime(DONE_BOUT_OPEN_TAP_GUARD_MS + 50); }); + await tap(screen.getByTestId('kachinuki-done-bout-0')); + expect(isOpen(0)).toBe(true); + expect(isOpen(1)).toBe(false); + }); + + it('a keyboard-synthesized click (detail 0) is never swallowed', async () => { + await renderEncounter(); + await tap(screen.getByTestId('kachinuki-done-bout-1')); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-done-bout-0'), { detail: 0 }); }); + expect(isOpen(0)).toBe(true); + }); +}); diff --git a/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx index e57d0fadc..9ab058c28 100644 --- a/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx @@ -5,13 +5,10 @@ // 1. REOPEN IS ONE TAP. The operator ended the match BY MISTAKE and is // standing at the shiaijo with the competitors still there. Nothing may // stand between the tap and the reopen — no prompt, no reason, no arm. -// 2. THE REASON IS COLLECTED ON THE WAY OUT. The write that completes a -// reopened encounter carries a correctionReason (the server refuses it -// otherwise), prompted for on [End match]. The requirement is driven by -// the SERVER field m.reopenPending, never by local state: this editor -// mounts per match, so an operator who reopens, walks off to another -// court and comes back has no local memory of the reopen — exactly the -// case the design exists to survive, and the case a local flag loses. +// 2. ENDING IT AGAIN ASKS FOR NO REASON EITHER (operator ruling +// 2026-09-25: a match can be reopened without any reason, and nothing is +// gated on that). [End match] and the withdrawal panel behave on a +// reopened encounter exactly as on any running one. // 3. A BUSY COURT IS NOT A DEAD END. The court-busy 409 names the match // holding the court; the panel offers to send it back to the queue and // retry. That is DESTRUCTIVE (it clears that match's score), so the @@ -19,8 +16,8 @@ // retry may strand the UI when it fails. import React from 'react'; -import { render, act, fireEvent, screen, waitFor, within } from '@testing-library/react'; -import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { render, act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; const STUBBED_GLOBALS = { @@ -99,8 +96,8 @@ function completedKachinukiMatch(overrides = {}) { } // The same encounter after a reopen: running again, bout log intact, and -// carrying the server's reopenPending stamp — the flag that makes the audit -// reason due on the next completing write. +// carrying the server's reopenPending stamp, which asks the operator for +// nothing. function reopenedKachinukiMatch(overrides = {}) { return completedKachinukiMatch({ status: 'running', @@ -127,17 +124,20 @@ async function renderEditor(props = {}) { } describe('kachinuki [Reopen match] is one tap', () => { - it('posts immediately, with no reason and no prompt, and closes the editor', async () => { + it('posts immediately, with no reason and no prompt, and keeps the editor open', async () => { const onClose = vi.fn(); await renderEditor({ onClose }); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); // No gate of any kind: the tap IS the reopen. expect(screen.queryByText('Reason for reopening')).toBeNull(); expect(window.API.reopenMatch).toHaveBeenCalledTimes(1); - // Three arguments: comp, match, password. A reason argument here would - // mean the operator was asked for one. - expect(window.API.reopenMatch).toHaveBeenCalledWith('comp1', 'm1', 'secret'); - await waitFor(() => expect(onClose).toHaveBeenCalled()); + // No reason and no downstream confirmation: a reason here would mean the + // operator was asked for one. + expect(window.API.reopenMatch).toHaveBeenCalledWith('comp1', 'm1', 'secret', { reason: '', force: false }); + // The editor follows the match to running in place, as Clear withdrawal + // and reopen does, so what the reopen reports can still be read here. + await waitFor(() => expect(screen.getByTestId('kachinuki-reopen-button').textContent).toBe('Reopened')); + expect(onClose).not.toHaveBeenCalled(); }); it('shows a plain-sentence 409 inline, verbatim, and stays open', async () => { @@ -158,10 +158,10 @@ describe('kachinuki [Reopen match] is one tap', () => { }); it('stays disabled after a successful tap so a double-tap cannot fire a second reopen', async () => { - // mp-gmcg: onClose is a no-op in the inline (shiaijo) variant, so the - // completed snapshot lingers through the SSE refetch window. The button - // must stay disabled until the match flips to running, or a second tap - // posts a reopen the server rejects as "not completed" (409). + // mp-gmcg: the editor stays open, so the completed snapshot lingers + // through the SSE refetch window. The button must stay disabled until the + // match flips to running, or a second tap posts a reopen the server + // rejects as "not completed" (409). await renderEditor(); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); await waitFor(() => expect(window.API.reopenMatch).toHaveBeenCalledTimes(1)); @@ -170,52 +170,106 @@ describe('kachinuki [Reopen match] is one tap', () => { await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); expect(window.API.reopenMatch).toHaveBeenCalledTimes(1); }); + + // The match_updated broadcast is what flips the prop to running. When it + // never arrives (a dropped event, no refetch), the success response is the + // only word the editor gets, so it must not go on saying "Reopening…" as if + // the request were still in flight: it says the reopen landed, and still + // refuses a second post. + it('says Reopened on the success response even when the running match never arrives', async () => { + await renderEditor(); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); + await waitFor(() => expect(window.API.reopenMatch).toHaveBeenCalledTimes(1)); + const button = screen.getByTestId('kachinuki-reopen-button'); + await waitFor(() => expect(button.textContent).toBe('Reopened')); + expect(button.textContent).not.toBe('Reopening…'); + expect(button.disabled).toBe(true); + }); }); -describe('kachinuki reopen: the reason is collected on the way OUT', () => { - it('[End match] on a reopened encounter prompts, then sends the reason as correctionReason', async () => { +describe('kachinuki reopen: a later knockout match already fought', () => { + // The dialog stub is shared across the file; put back the default answer. + afterEach(() => { window.confirmDialog = vi.fn().mockResolvedValue(true); }); + + // What api_client's reopenFailureError throws for the 409 + // downstream_knockout_played refusal of a reopen. + function reopenDownstreamRefusal() { + const e = new Error('reopening match "m1" would change the winner ...'); + e.downstreamKnockoutPlayed = { + matchId: 'm1', + blockingMatchId: 'm-r2-0', + blockingMatches: [{ id: 'm-r2-0', number: 3 }], + displaced: 'Team A', + qualifierChange: [], + reopen: true, + }; + return e; + } + + it('asks in reopen words, and on confirm keeps the editor open to name what else it reopened', async () => { + const onClose = vi.fn(); + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.API.reopenMatch = vi.fn() + .mockRejectedValueOnce(reopenDownstreamRefusal()) + .mockResolvedValueOnce({ reopenedMatches: [{ id: 'm-r2-0', number: 3 }] }); + const utils = await renderEditor({ onClose }); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); + + await waitFor(() => expect(window.confirmDialog).toHaveBeenCalledTimes(1)); + const { message, confirmLabel } = window.confirmDialog.mock.calls[0][0]; + expect(message).toContain('Reopening this match also reopens Match 3'); + expect(message.toLowerCase()).not.toContain('correction'); + expect(confirmLabel).toBe('Reopen both'); + + await waitFor(() => expect(window.API.reopenMatch).toHaveBeenCalledTimes(2)); + expect(window.API.reopenMatch.mock.calls[1][3]).toEqual({ reason: '', force: true }); + await waitFor(() => expect(screen.getByTestId('kachinuki-reopen-notice').textContent) + .toBe('Match 3 was reopened: it must be fought and scored again.')); + expect(onClose).not.toHaveBeenCalled(); + + // The notice is read once the match is running again, which is when the + // operator looks for it: the flip removes the Reopen control, not it. + await act(async () => { + utils.rerender( + + ); + }); + expect(screen.queryByTestId('kachinuki-reopen-button')).toBeNull(); + expect(screen.getByTestId('kachinuki-reopen-notice').textContent) + .toBe('Match 3 was reopened: it must be fought and scored again.'); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('says the reopen was cancelled, not a correction, when the operator declines', async () => { + window.confirmDialog = vi.fn().mockResolvedValue(false); + window.API.reopenMatch = vi.fn().mockRejectedValue(reopenDownstreamRefusal()); + await renderEditor(); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); + await waitFor(() => expect(screen.getByTestId('kachinuki-reopen-error').textContent) + .toBe('Reopen cancelled: this match and the later result it depends on were left unchanged.')); + expect(window.API.reopenMatch).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('kachinuki-reopen-button').textContent).toBe('Reopen match'); + }); +}); + +describe('kachinuki reopen: ending it again asks for no reason', () => { + it('[End match] on a reopened encounter arms and ends as on any running one', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); await renderEditor({ match: reopenedKachinukiMatch(), onSubmit }); - // First tap opens the prompt INSTEAD of arming: its Confirm is the commit. fireEvent.click(screen.getByTestId('kachinuki-end-match-button')); - expect(screen.getByText('Reason for reopening')).toBeTruthy(); + expect(screen.queryByText('Reason for reopening')).toBeNull(); expect(onSubmit).not.toHaveBeenCalled(); - // The real reason is the default selection: an operator who ended by - // mistake must not have to relabel it "Scoring error" or type it out. - expect(screen.getByLabelText('Reason category').value).toBe('Ended by mistake'); - // The verdict being committed is shown: the prompt hides the End button - // whose armed label would otherwise carry it. - expect(screen.getByTestId('kachinuki-end-verdict').textContent).toContain('AKA WIN'); - - // A free-text note so the asserted reason cannot be the default arriving - // by accident. - fireEvent.change(screen.getByLabelText('Reason note'), { target: { value: 'ended on the wrong bout' } }); - await act(async () => { fireEvent.click(screen.getByText('Confirm')); }); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-end-match-button')); }); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); const patch = onSubmit.mock.calls[0][0]; expect(patch.status).toBe('completed'); - expect(patch.correctionReason).toBe('Ended by mistake: ended on the wrong bout'); - }); - - it('Cancel abandons the End write without submitting', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); - await renderEditor({ match: reopenedKachinukiMatch(), onSubmit }); - fireEvent.click(screen.getByTestId('kachinuki-end-match-button')); - // Scoped to the prompt's own Cancel: while a reason prompt owns the footer - // it must be the ONLY Cancel on screen, so this also pins that the - // editor's nav row is hidden underneath it. - const prompt = screen.getByText('Reason for reopening').closest('form'); - fireEvent.click(within(prompt).getByText('Cancel')); - expect(onSubmit).not.toHaveBeenCalled(); - // Back to rest: End is available again for a second attempt. - expect(screen.getByTestId('kachinuki-end-match-button')).toBeTruthy(); + expect(patch.correctionReason).toBeUndefined(); }); it('does not prompt on a running encounter that was never reopened', async () => { - // The requirement rides the SERVER field. Without reopenPending the End - // action keeps its ordinary arm/confirm and sends no correctionReason. const onSubmit = vi.fn().mockResolvedValue(undefined); await renderEditor({ match: reopenedKachinukiMatch({ reopenPending: false }), onSubmit }); const end = screen.getByTestId('kachinuki-end-match-button'); @@ -260,8 +314,12 @@ describe('kachinuki reopen: a busy court gets a remedy, not a dead end', () => { // The warning is on screen, in words, BEFORE the operator commits. expect(screen.getByTestId('kachinuki-reopen-conflict-warning').textContent) .toContain('clears any score already entered for it'); - // The server's own sentence is not swallowed by our friendlier heading. - expect(panel.textContent).toContain('Court A already has a running match (m-r1-1).'); + // bc-rawm: the server's own raw sentence is GONE, not shown alongside the + // heading -- it named the same internal id and told the operator to + // "finish that match before reopening", the opposite of this panel's own + // remedy button. + expect(panel.textContent).not.toContain('Finish that match before reopening'); + expect(panel.textContent).not.toContain('m-r1-1'); expect(screen.getByTestId('kachinuki-reopen-requeue-button')).toBeTruthy(); }); @@ -279,9 +337,10 @@ describe('kachinuki reopen: a busy court gets a remedy, not a dead end', () => { // ONE server call: target=(comp1, m1), blocker=(comp1, m-r1-1). No second // reopenMatch call — the requeue and reopen commit together server-side. - expect(window.API.requeueBlockerAndReopen).toHaveBeenCalledWith('comp1', 'm1', 'comp1', 'm-r1-1', 'secret'); + expect(window.API.requeueBlockerAndReopen).toHaveBeenCalledWith('comp1', 'm1', 'comp1', 'm-r1-1', 'secret', { reason: '', force: false }); expect(window.API.reopenMatch).toHaveBeenCalledTimes(1); - await waitFor(() => expect(onClose).toHaveBeenCalled()); + await waitFor(() => expect(screen.queryByTestId('kachinuki-reopen-conflict')).toBeNull()); + expect(onClose).not.toHaveBeenCalled(); }); it('clears the panel and shows the message when the atomic remedy is refused', async () => { @@ -305,53 +364,49 @@ describe('kachinuki reopen: a busy court gets a remedy, not a dead end', () => { it('re-offers the remedy when a DIFFERENT match has since taken the court', async () => { // The atomic remedy fails with a court_busy naming a NEW blocker: the panel - // updates to that match rather than dead-ending. + // re-offers the remedy rather than dead-ending. bc-rawm: the panel no + // longer prints the blocking matchId as text (no label was sent for it + // here, and window.compMatchesForCompetition's fixture only knows + // 'm-r1-1', so the fetched blockerLabel cannot resolve the new one + // either), so this proves the new conflict took hold BEHAVIOURALLY: + // tapping the remedy again targets the NEW blocker's id, not the stale + // one. window.API.reopenMatch = vi.fn().mockRejectedValue(courtBusyError()); const newBlocker = courtBusyError(); newBlocker.matchId = 'm-r1-2'; newBlocker.message = 'Court A already has a running match (m-r1-2). Finish that match before reopening this one.'; - window.API.requeueBlockerAndReopen = vi.fn().mockRejectedValue(newBlocker); + window.API.requeueBlockerAndReopen = vi.fn() + .mockRejectedValueOnce(newBlocker) + .mockResolvedValue(true); await renderEditor(); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); await screen.findByTestId('kachinuki-reopen-conflict'); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-requeue-button')); }); - // Still a conflict panel, now naming the new blocker — the remedy is re-offered. - const panel = await screen.findByTestId('kachinuki-reopen-conflict'); - await waitFor(() => expect(panel.textContent).toContain('m-r1-2')); + // Still a conflict panel — the remedy is re-offered, not a dead end. + await screen.findByTestId('kachinuki-reopen-conflict'); expect(screen.getByTestId('kachinuki-reopen-requeue-button')).toBeTruthy(); + + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-requeue-button')); }); + expect(window.API.requeueBlockerAndReopen).toHaveBeenLastCalledWith( + 'comp1', 'm1', 'comp1', 'm-r1-2', 'secret', { reason: '', force: false }, + ); }); - // POST /decision is the OTHER way to finalize a match, so the server demands - // the outstanding reason there too. The client has to ASK for it: the reason - // box is normally rendered only for kiken, so on a reopened encounter a - // fusenpai had no field at all and the operator met a 400 they could not - // satisfy from the UI. These pin the prompt, not just the payload. - it('asks for the reason on a fusenpai after a reason-less reopen', async () => { + // POST /decision is the other way to end a match, and after a reason-less + // reopen it asks for no reason either: the panel is the same as on any + // running match, with Record available at once. + it('asks for no reason on a fusenpai after a reason-less reopen', async () => { await renderEditor({ match: reopenedKachinukiMatch() }); await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-fusenpai-button')); }); - // The box exists at all (it does not for a non-reopened fusenpai) and says - // it is required, naming the reopen as the cause. - const input = screen.getByTestId('decision-reason'); - expect(input).toBeTruthy(); - expect(screen.getByText(/Reason \(required/)).toBeTruthy(); - expect(screen.getByText(/reopened, so ending it again needs a reason/)).toBeTruthy(); - - // Record is held back while it is empty, so the operator cannot walk into - // the server's rejection. - const record = screen.getByRole('button', { name: 'Record' }); - expect(record.disabled).toBe(true); - await act(async () => { fireEvent.input(input, { target: { value: ' ' } }); }); - expect(record.disabled).toBe(true); - await act(async () => { fireEvent.input(input, { target: { value: 'Ended by mistake' } }); }); - expect(record.disabled).toBe(false); + expect(screen.queryByTestId('decision-reason')).toBeNull(); + expect(screen.queryByText(/ending it again needs a reason/)).toBeNull(); + expect(screen.getByRole('button', { name: 'Record' }).disabled).toBe(false); }); it('leaves the reason optional when the match was not reopened', async () => { - // Blast radius: the ordinary decision flow must stay reasonless. Gating - // every decision would be a worse regression than the hole it closed. await renderEditor({ match: completedKachinukiMatch({ status: 'running', winner: null }) }); await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-fusenpai-button')); }); diff --git a/web-mobile/js/__tests__/render/match_lineup_side_editor_order.render.test.jsx b/web-mobile/js/__tests__/render/match_lineup_side_editor_order.render.test.jsx new file mode 100644 index 000000000..9469cf1b3 --- /dev/null +++ b/web-mobile/js/__tests__/render/match_lineup_side_editor_order.render.test.jsx @@ -0,0 +1,84 @@ +// bc-cpdc: "Copy from previous match" moved from the footer (beside "Save +// lineup") to the header of each side's editor, matching the docs' +// walkthrough (docs/user-guide/organisers/team-tournaments.md: "use Copy +// from previous match at the top of the lineup panel"). The docs define +// behaviour, so the control moved rather than the sentence. +// +// This pins the ORDER an operator actually sees in the DOM: Copy above the +// position rows, Save below them. Asserting mere presence of the button +// would pass whether it sits in the header or the footer; only ordering +// (via Node.compareDocumentPosition, real DOM -- this suite mounts with +// real React via @testing-library/react, unlike the unit suite's vdom +// stub) catches a regression back to the old placement. + +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + resolveRoundIndex: () => 0, + compMatches: () => [], + AdminLineupHelpers: { + positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), + rosterFor: () => [], + mergeRosterWithAssigned: (base) => (Array.isArray(base) ? base : []), + teamIdOf: (t) => t?.id || t?.name || '', + resolveMemberIdsForPositions: vi.fn().mockResolvedValue({ memberIds: {}, squad: [], failures: [] }), + memberIdentityWarning: () => '', + }, + API: { + fetchMatchLineup: vi.fn().mockResolvedValue(null), + fetchTeamLineup: vi.fn().mockResolvedValue(null), + fetchSquads: vi.fn().mockResolvedValue({}), + putMatchLineup: vi.fn().mockResolvedValue({ positions: {} }), + }, +}; + +let restoreGlobals; +let MatchLineupSideEditor; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + ({ MatchLineupSideEditor } = await import('../../admin_schedule_lineup.jsx')); +}); + +afterAll(() => restoreGlobals()); + +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; +const TEAM = { id: 'uuid-grouped', name: 'Grouped Team', number: 'T5' }; +const MATCH = { + id: 'match-1', + compId: 'comp-1', + sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, + sideB: { id: 'other', name: 'Other' }, + status: 'scheduled', +}; + +describe('MatchLineupSideEditor places Copy above the positions and Save below them (bc-cpdc)', () => { + it('renders Copy from previous match before the first position row, and Save lineup after it', async () => { + let utils; + await act(async () => { + utils = render( + + ); + }); + const { container, findByRole } = utils; + + const saveBtn = await findByRole('button', { name: /Save lineup/ }); + const copyBtn = await findByRole('button', { name: /Copy from previous match/ }); + const firstPos = container.querySelector('[data-testid^="match-lineup-pos-"]'); + expect(firstPos).toBeTruthy(); + + // Node.compareDocumentPosition: DOCUMENT_POSITION_FOLLOWING === 4. + expect(copyBtn.compareDocumentPosition(firstPos) & 4).toBeTruthy(); + expect(firstPos.compareDocumentPosition(saveBtn) & 4).toBeTruthy(); + }); +}); diff --git a/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx b/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx new file mode 100644 index 000000000..eedb88e4d --- /dev/null +++ b/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx @@ -0,0 +1,655 @@ +// bc-tmfn: Clear withdrawal and reopen (operator rulings 2026-09-24: +// "Everything should be able to be fixed, in case of a wrong entry. The +// operator just needs to be aware of the consequences, if it affects +// downstream matches."). Correcting a match a withdrawal ended shows what is +// recorded and ONE way to remove it, from one shared component +// (RecordedWithdrawal, admin_scoring_shared.jsx) in BOTH editors: a reopen in +// one tap, no reason asked (operator ruling 2026-09-25: a match can be +// reopened without any reason), with its consequence stated beside the button +// before it is tapped, never a score write. These pin the line, the +// request, the downstream confirm, the court-busy remedy, and where the +// control must not appear (kachinuki, which keeps its own Reopen; a match no +// withdrawal decided). + +import React from 'react'; +import { render, act, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { readStylesheet, cssBlock } from '../helpers/source.js'; +import { DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED } from '../../write_result.jsx'; +import { withdrawalLabel } from '../../admin_scoring_shared.jsx'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: {}, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + compMatchesForCompetition: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.API = { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + reopenMatch: vi.fn().mockResolvedValue({ reopenedMatches: [] }), + requeueBlockerAndReopen: vi.fn().mockResolvedValue({ reopenedMatches: [] }), + }; +}); + +// A fixed-order team pool match Kyoto (aka) withdrew from after bout 1. +function teamWithdrawal(overrides = {}) { + return { + id: 'm-pool-1', compId: 'comp1', status: 'completed', phase: 'pool', poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 3, + sideA: { id: 'team-kyoto', name: 'Kyoto' }, + sideB: { id: 'team-osaka', name: 'Osaka' }, + subResults: [{ position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [], winner: 'Kyoto', decision: '' }], + decision: 'kiken-voluntary', decisionBy: 'aka', decisionReason: 'knee', + winner: { id: 'team-osaka', name: 'Osaka' }, ipponsB: ['○', '○'], + ...overrides, + }; +} + +// An individual knockout match Tanaka (shiro) did not appear for. +function individualWithdrawal(overrides = {}) { + return { + id: 'm-r1-0', compId: 'comp1', status: 'completed', phase: 'knockout', round: 'Round 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, + sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: ['○', '○'], ipponsB: [], hansokuA: 0, hansokuB: 0, + decision: 'fusenpai', decisionBy: 'shiro', + winner: { id: 'p1', name: 'Yamada' }, + ...overrides, + }; +} + +async function mount(match, props = {}) { + let view; + await act(async () => { + view = render(); + }); + return view; +} + +// LiveHost behaves like the real hosts (the Scores tab, the pools page, the +// court console): the open match is resolved from live data, so when a reopen +// lands the SAME editor re-renders with the match running (flip, standing in +// for the SSE refetch), and onClose really unmounts it. A vi.fn onClose over +// a frozen match cannot show what the operator sees after the reopen. +let flip; +function LiveHost({ initial, onClose }) { + const [match, setMatch] = React.useState(initial); + const [open, setOpen] = React.useState(true); + flip = (patch) => setMatch((m) => ({ ...m, ...patch })); + if (!open) return
; + return ( + { onClose?.(); setOpen(false); }} + onSubmit={vi.fn().mockResolvedValue(undefined)} + password="secret" + /> + ); +} + +async function mountLive(match, onClose) { + await act(async () => { render(); }); +} + +// What the server leaves after Clear withdrawal and reopen on these fixtures: +// running, verdict cleared, the winner's default-win maru gone (neither +// withdrawn side had struck anything at match level). +const REOPENED = { status: 'running', decision: '', decisionBy: '', decisionReason: '', winner: null, ipponsA: [], ipponsB: [] }; + +async function clearWithdrawal() { + await act(async () => { fireEvent.click(screen.getByTestId('clear-withdrawal-reopen')); }); +} + +describe.each([ + ['team', teamWithdrawal, 'Recorded: Kiken – Voluntary, Kyoto withdrew.', 'Kyoto'], + ['individual', individualWithdrawal, 'Recorded: Fusenpai, Tanaka did not appear.', 'Tanaka'], +])('%s editor: Clear withdrawal and reopen', (_kind, fixture, recorded, withdrawn) => { + it('says what is recorded and what clearing does, then reopens in one tap with no reason, never a score write', async () => { + const onClose = vi.fn(); + await mountLive(fixture(), onClose); + const line = screen.getByTestId('recorded-withdrawal'); + expect(line.textContent).toContain(recorded); + // The consequence is stated before the tap: there is no confirm step to + // state it in. + expect(screen.getByTestId('clear-withdrawal-consequence').textContent) + .toContain(`${withdrawn} can compete again`); + expect(window.API.reopenMatch).not.toHaveBeenCalled(); + + await clearWithdrawal(); + expect(document.querySelector('.reason-prompt')).toBeNull(); + expect(window.API.reopenMatch).toHaveBeenCalledTimes(1); + expect(window.API.reopenMatch).toHaveBeenCalledWith( + 'comp1', fixture().id, 'secret', { reason: '', force: false }, + ); + expect(window.API.recordScore).not.toHaveBeenCalled(); + + // bc-tmfn R3: the operator is told to score the rest and finish it, so + // the editor stays open and follows the match to running in place. + await act(async () => { flip(REOPENED); }); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByTestId('host-closed')).toBeNull(); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + expect(screen.queryByText('CORRECTION')).toBeNull(); + }); + + it('asks before reopening a later match with its own result, then retries with force', async () => { + const refusal = Object.assign(new Error('refused'), { + // As api_client's reopenFailureError marks it: met by a reopen. + downstreamKnockoutPlayed: { matchId: fixture().id, blockingMatchId: 'm-r2-0', blockingMatches: [{ id: 'm-r2-0', number: 2 }], displaced: 'Yamada', reopen: true }, + }); + window.API.reopenMatch = vi.fn() + .mockRejectedValueOnce(refusal) + .mockResolvedValueOnce({ reopenedMatches: [{ id: 'm-r2-0', number: 2 }] }); + const onClose = vi.fn(); + await mountLive(fixture(), onClose); + await clearWithdrawal(); + await waitFor(() => expect(window.API.reopenMatch).toHaveBeenCalledTimes(2)); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(window.confirmDialog.mock.calls[0][0].message).toContain('Match 2'); + // Reopen words, not a correction's: the operator is reopening this match. + expect(window.confirmDialog.mock.calls[0][0].message).toContain('Reopening this match also reopens Match 2'); + expect(window.confirmDialog.mock.calls[0][0].message.toLowerCase()).not.toContain('correction'); + expect(window.confirmDialog.mock.calls[0][0].confirmLabel).toBe('Reopen both'); + expect(window.API.reopenMatch.mock.calls[1][3]).toEqual({ reason: '', force: true }); + + // bc-tmfn R3: the notice is read AFTER the match is running again, which + // is when the operator looks for it. It used to live inside + // RecordedWithdrawal, which unmounts on that flip, in an editor whose + // host had already closed it. + await act(async () => { flip(REOPENED); }); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId('withdrawal-reopen-notice').textContent) + .toBe('Match 2 was reopened: it must be fought and scored again.'); + + // A new result on the same match (here another kiken) ends that reopen: + // the notice described it, not the match now in front of the operator. + await act(async () => { flip({ status: 'completed', decision: 'kiken-voluntary', decisionBy: 'aka' }); }); + expect(screen.queryByTestId('withdrawal-reopen-notice')).toBeNull(); + }); + + it('declining the downstream confirm leaves everything as it was and says so', async () => { + window.confirmDialog = vi.fn().mockResolvedValue(false); + window.API.reopenMatch = vi.fn().mockRejectedValue(Object.assign(new Error('refused'), { + downstreamKnockoutPlayed: { blockingMatchId: 'm-r2-0', blockingMatches: [{ id: 'm-r2-0', number: 2 }], reopen: true }, + })); + const onClose = vi.fn(); + await mount(fixture(), { onClose }); + await clearWithdrawal(); + await waitFor(() => expect(screen.getByTestId('withdrawal-reopen-error').textContent).toBe(DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED)); + expect(window.API.reopenMatch).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('a busy court offers the remedy, which carries no reason either', async () => { + window.API.reopenMatch = vi.fn().mockRejectedValue(Object.assign( + new Error('Court A already has a running match (m-blk).'), + { code: 'court_busy', court: 'A', matchId: 'm-blk', compId: 'comp1' }, + )); + await mount(fixture()); + await clearWithdrawal(); + await waitFor(() => expect(screen.getByTestId('withdrawal-reopen-conflict')).toBeTruthy()); + expect(screen.getByTestId('withdrawal-reopen-conflict-warning').textContent).toMatch(/clears any score already entered/); + await act(async () => { fireEvent.click(screen.getByTestId('withdrawal-reopen-requeue-button')); }); + expect(window.API.requeueBlockerAndReopen).toHaveBeenCalledWith( + 'comp1', fixture().id, 'comp1', 'm-blk', 'secret', { reason: '', force: false }, + ); + }); + + // bc-rawm: the heading must never fall back to the raw internal matchId, + // and the server's own raw refusal sentence (which named that same id and + // told the operator to "finish that match before reopening" -- the + // opposite of this panel's own button) is gone entirely. + it('the conflict heading never falls back to the raw match id, and drops the server sentence', async () => { + window.API.reopenMatch = vi.fn().mockRejectedValue(Object.assign( + new Error('Court A already has a running match (m-blk). Finish that match before reopening this one.'), + { code: 'court_busy', court: 'A', matchId: 'm-blk', compId: 'comp1' }, + )); + await mount(fixture()); + await clearWithdrawal(); + const conflict = await waitFor(() => screen.getByTestId('withdrawal-reopen-conflict')); + expect(conflict.textContent).not.toContain('m-blk'); + expect(conflict.textContent).toContain('Shiaijo A is running another match.'); + expect(conflict.textContent).not.toContain('Finish that match before reopening'); + }); + + // bc-cse: a server-sent `label` for the blocking match (the court_busy + // conflict reuses the score path's structured payload) names no + // COMPETITOR ("Pool A · Match 2"), so it is only the FALLBACK when the + // panel's own best-effort fetched blockerLabel has not resolved (still + // loading here: fetchCompetitionDetails resolves null in this file's + // default beforeEach, so the fetch effect never finds the blocker). + it('falls back to the server label while the fetched one has not resolved', async () => { + window.API.reopenMatch = vi.fn().mockRejectedValue(Object.assign( + new Error('server sentence'), + { code: 'court_busy', court: 'A', matchId: 'm-blk', compId: 'comp1', label: 'Pool A · Match 2' }, + )); + await mount(fixture()); + await clearWithdrawal(); + const conflict = await waitFor(() => screen.getByTestId('withdrawal-reopen-conflict')); + expect(conflict.textContent).toContain('Shiaijo A is running Pool A · Match 2.'); + }); + + // bc-cse: once the fetch DOES resolve, the panel prefers it -- naming the + // blocking match's COMPETITORS, the safety property the operator is about + // to wipe the score of, which the server's own bare "Pool A · Match 2" + // label never carries. + it('prefers the fetched label (with competitor names) over the server label once it resolves', async () => { + window.API.reopenMatch = vi.fn().mockRejectedValue(Object.assign( + new Error('server sentence'), + { code: 'court_busy', court: 'A', matchId: 'm-blk', compId: 'comp1', label: 'Pool A · Match 2' }, + )); + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ id: 'comp1', config: {} }); + window.compMatchesForCompetition = () => [ + { id: 'm-blk', phase: 'pool', sideA: { name: 'Aoki Taro' }, sideB: { name: 'Endo Goro' } }, + ]; + await mount(fixture()); + await clearWithdrawal(); + const conflict = await waitFor(() => screen.getByTestId('withdrawal-reopen-conflict')); + await waitFor(() => expect(conflict.textContent).toContain('Endo Goro vs Aoki Taro')); + expect(conflict.textContent).not.toContain('Pool A · Match 2'); + }); +}); + +// bc-tmfn R7: a kachinuki encounter a withdrawal decided reopens through the +// same RecordedWithdrawal as every other editor (the line, the consequence, +// the one-tap clear), because that reopen also makes the withdrawn team eligible +// again; its plain one-tap Reopen stays for every other kachinuki result. One +// control, one consequence text, and the kachinuki feedback ids still apply. +describe('kachinuki: a withdrawal reopens through Clear withdrawal and reopen', () => { + const kachinukiWithdrawal = (o = {}) => teamWithdrawal({ teamMatchType: 'kachinuki', compFormat: 'mixed', ...o }); + + it('shows the recorded line in place of the one-tap Reopen, and states the consequence first', async () => { + const onClose = vi.fn(); + await mountLive(kachinukiWithdrawal(), onClose); + expect(screen.getByTestId('recorded-withdrawal').textContent).toContain('Recorded: Kiken – Voluntary, Kyoto withdrew.'); + expect(screen.queryByTestId('kachinuki-reopen-button')).toBeNull(); + + expect(screen.getByTestId('clear-withdrawal-consequence').textContent).toContain('Kyoto can compete again'); + expect(window.API.reopenMatch).not.toHaveBeenCalled(); + await clearWithdrawal(); + expect(window.API.reopenMatch).toHaveBeenCalledWith( + 'comp1', 'm-pool-1', 'secret', { reason: '', force: false }, + ); + await act(async () => { flip(REOPENED); }); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + }); + + it('keeps the kachinuki feedback ids for what the reopen came back with', async () => { + window.API.reopenMatch = vi.fn().mockRejectedValue(Object.assign( + new Error('Court A already has a running match (m-blk).'), + { code: 'court_busy', court: 'A', matchId: 'm-blk', compId: 'comp1' }, + )); + await mount(kachinukiWithdrawal()); + await clearWithdrawal(); + await waitFor(() => expect(screen.getByTestId('kachinuki-reopen-conflict')).toBeTruthy()); + }); + + it('an encounter no withdrawal decided keeps the one-tap Reopen and no recorded line', async () => { + await mount(kachinukiWithdrawal({ decision: 'kachinuki-exhaustion', decisionBy: '', decisionReason: '', winner: { id: 'team-kyoto', name: 'Kyoto' } })); + expect(screen.getByTestId('kachinuki-reopen-button')).toBeTruthy(); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + }); +}); + +describe('where Clear withdrawal and reopen is not offered', () => { + + it.each([ + ['team', () => teamWithdrawal({ decision: '', decisionBy: '', winner: { id: 'team-kyoto', name: 'Kyoto' } })], + ['individual', () => individualWithdrawal({ decision: '', decisionBy: '', ipponsA: ['M', 'K'] })], + ])('a %s match no withdrawal decided', async (_kind, fixture) => { + await mount(fixture()); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + }); + + it('a match still being fought', async () => { + await mount(individualWithdrawal({ status: 'running', decision: '', decisionBy: '', winner: null, ipponsA: ['M'] })); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + }); +}); + +// bc-tmfn R1: Save correction on an individual match a withdrawal decided +// keeps the withdrawal and the winner's default-win maru and saves the +// withdrawer's letters as entered (operator ruling 2026-09-24: "Save +// correction should just save what the operator enters"). The maru used to +// count as two points, so the bout read as decided, every add button was +// off, and the only way to change the withdrawer's letter was to delete the +// winner's maru, which the server then silently put back. +describe('individual correction while a withdrawal is recorded', () => { + // Yamada (aka) won by default when Tanaka (shiro) withdrew holding a men. + const kiken = (o = {}) => individualWithdrawal({ decision: 'kiken-voluntary', decisionBy: 'shiro', ipponsB: ['M'], ...o }); + const slots = (side) => [...document.querySelectorAll(`.sb-slots--${side} .sb-slot`)]; + const addButton = (side, letter) => [...document.querySelectorAll(`.sb-side--${side} .ipt-btn`)].find((b) => b.textContent === letter); + + it("shows the winner's maru read-only and saves the withdrawer's corrected letter", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await mount(kiken(), { onSubmit }); + + expect(slots('aka').map((b) => b.textContent)).toEqual(['○', '○']); + slots('aka').forEach((b) => expect(b.disabled).toBe(true)); + expect(addButton('aka', 'M').disabled).toBe(true); + + // The men was entered by mistake: tap it off and enter the kote. + const men = slots('shiro').find((b) => b.textContent === 'M'); + expect(men.disabled).toBe(false); + await act(async () => { fireEvent.click(men); }); + expect(addButton('shiro', 'K').disabled).toBe(false); + await act(async () => { fireEvent.click(addButton('shiro', 'K')); }); + // One point is the withdrawer's most: two would have won the bout. + expect(addButton('shiro', 'D').disabled).toBe(true); + expect(slots('aka').map((b) => b.textContent)).toEqual(['○', '○']); + + const save = [...document.querySelectorAll('.score-nav button')].find((b) => b.textContent === 'Save correction'); + await act(async () => { fireEvent.click(save); }); + const confirm = [...document.querySelectorAll('.reason-prompt button')].find((b) => b.textContent === 'Confirm'); + await act(async () => { fireEvent.click(confirm); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + const patch = onSubmit.mock.calls[0][0]; + expect(patch.ipponsB).toEqual(['K']); + expect(patch.ipponsA).toEqual(['○', '○']); + expect(patch.winner?.name).toBe('Yamada'); + }); + + // UAT (bc-tmfn): the winner's letters and maru were disabled but looked + // exactly like the withdrawer's live ones: full opacity, same colours, a + // pointer cursor. jsdom applies no stylesheet, so this pins the two halves + // that make the look: the read-only controls ARE disabled .ipt-btn / + // .sb-slot buttons, and styles.css gives exactly those the disabled look + // the board's other disabled controls have (.btn:disabled). Opacity, not a + // colour: the side's tint and the maru's fill keep their colours. + it("the winner's read-only side is styled as disabled, and the withdrawer's is not", async () => { + await mount(kiken()); + const readOnly = [...slots('aka'), ...['M', 'K', 'D', 'T', 'H'].map((l) => addButton('aka', l))]; + readOnly.forEach((b) => { + expect(b.disabled).toBe(true); + expect(b.matches('.ipt-btn:disabled, .sb-slot:disabled')).toBe(true); + }); + // The withdrawer's recorded men is the operator's to correct: live. + expect(slots('shiro').find((b) => b.textContent === 'M').matches('.sb-slot:disabled')).toBe(false); + // The Aka half still carries its tint and its side in text. + expect(document.querySelector('.sb-side--aka')).not.toBeNull(); + + const css = readStylesheet(); + const btnDisabled = cssBlock(css, '.btn:disabled'); + for (const sel of ['.ipt-btn:disabled', '.sb-slot:disabled']) { + const rule = cssBlock(css, sel); + expect(rule, `${sel} has no rule in styles.css`).not.toBeNull(); + expect(rule).toMatch(/cursor:\s*not-allowed/); + expect(rule).toMatch(/opacity:\s*0\.6/); + expect(rule).not.toMatch(/(^|[^-])(color|background)\s*:/); + } + expect(btnDisabled).toMatch(/opacity:\s*0\.6/); + // A disabled letter does not react to the pointer. + expect(css).toMatch(/^\.ipt-btn:hover:not\(:disabled\)\s*\{/m); + expect(css).toMatch(/^\.ipt-btn--h:hover:not\(:disabled\)\s*\{/m); + }); + + it('a reopen made with unsaved taps on the board leaves no maru behind', async () => { + // The editor stays open through the reopen, so its board must drop the + // winner's maru with the ruling even when the operator had tapped + // something first: kept, the maru would be tappable and autosaved as + // two points on a match that no longer has a withdrawal. + await mountLive(kiken()); + const men = slots('shiro').find((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(men); }); + await clearWithdrawal(); + await act(async () => { flip({ ...REOPENED, ipponsB: ['M'] }); }); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + expect(slots('aka').map((b) => b.textContent)).toEqual(['·', '·']); + expect(slots('shiro').map((b) => b.textContent).filter((t) => t !== '·')).toEqual(['M']); + expect(addButton('aka', 'M').disabled).toBe(false); + }); + + it('the keyboard cannot add to the winner either (one maru, in encho)', async () => { + await mount(kiken({ ipponsA: ['○'], ipponsB: [], encho: { periodCount: 1 } })); + expect(slots('aka').filter((b) => b.textContent === '○')).toHaveLength(1); + await act(async () => { fireEvent.keyDown(window, { key: 'M', shiftKey: true }); }); + expect(slots('aka').map((b) => b.textContent).filter((t) => t !== '·')).toEqual(['○']); + await act(async () => { fireEvent.keyDown(window, { key: 'm' }); }); + expect(slots('shiro').map((b) => b.textContent).filter((t) => t !== '·')).toEqual(['M']); + }); +}); + +// bc-tmfn R2: on a single bout the reopen cannot keep the winner's points +// (the withdrawal replaced them with the default win when it was recorded), +// so the consequence says so and asks for them to be entered again. A team +// encounter keeps its bouts and keeps its wording. +describe("Clear withdrawal and reopen names the winner's lost points on a single bout", () => { + it('individual kiken', async () => { + await mount(individualWithdrawal({ decision: 'kiken-injury', ipponsB: ['M'] })); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).toContain("Yamada's points were replaced by the default win when the withdrawal was recorded, so enter them again"); + expect(text).toContain("Tanaka's points are kept"); + expect(text).not.toContain('with what was fought kept'); + }); + + it('individual fusenpai: a no-show fought nothing, so only the default win goes', async () => { + await mount(individualWithdrawal()); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).toContain('the default win given to Yamada is removed, and Tanaka can compete again'); + expect(text).not.toContain('points'); + }); + + it('team', async () => { + await mount(teamWithdrawal()); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).toContain('with what was fought kept'); + expect(text).not.toContain('replaced by the default win'); + }); +}); + +// A pool match of a pools-then-knockout competition feeds the knockout through +// its standings, so the consequence adds that finishing it may change who +// qualifies, and that the save will show any knockout match that affects +// before anything is saved. A league pool (no knockout) and a knockout match +// do not say it. +describe('Clear withdrawal and reopen: pool match feeding a knockout', () => { + it('a mixed competition pool match says finishing may change who qualifies', async () => { + await mount(teamWithdrawal({ compFormat: 'mixed' })); + expect(screen.getByTestId('clear-withdrawal-qualifier-note').textContent.replace(/\s+/g, ' ')) + .toBe('Finishing it may change who qualifies from Pool 1. If that moves someone who has already fought in the knockout, you will be shown which knockout matches it affects before anything is saved.'); + }); + + it.each([ + ['a league pool match', () => teamWithdrawal({ compFormat: 'league' })], + ['a knockout match', () => individualWithdrawal({ compFormat: 'mixed' })], + ])('%s does not', async (_label, fixture) => { + await mount(fixture()); + expect(screen.getByTestId('clear-withdrawal-consequence')).toBeTruthy(); + expect(screen.queryByTestId('clear-withdrawal-qualifier-note')).toBeNull(); + }); +}); + +// bc-rawm: withdrawalInForce widened to the match-level DEFAULT-WIN class +// (RemainingMatchesPanel.award writes a whole-match `decision: "fusensho"` +// for a scheduled match against a competitor already withdrawn elsewhere, +// since fusenpai there is refused with 409 already_ineligible). Same +// RecordedWithdrawal door as kiken/fusenpai, different copy: the Recorded +// line names the winner, and the reopen button reads "Clear default win and +// reopen". +describe('a match-level fusensho default win (bc-rawm)', () => { + // Yamada (aka) got the default win when Tanaka (shiro) withdrew elsewhere + // and RemainingMatchesPanel awarded this still-scheduled match to Yamada. + const defaultWin = (o = {}) => individualWithdrawal({ + decision: 'fusensho', decisionBy: 'shiro', decisionReason: 'auto: Tanaka withdrawn', + ipponsA: ['○', '○'], ipponsB: [], + ...o, + }); + + it('names the winner in the Recorded line and offers "Clear default win"', async () => { + await mount(defaultWin()); + const line = screen.getByTestId('recorded-withdrawal'); + expect(line.textContent).toContain('Recorded: Default win (fusensho) for Yamada. Tanaka had withdrawn.'); + const button = screen.getByTestId('clear-withdrawal-reopen'); + // bc-cse: no "and reopen" -- a fusensho reopen does not always land + // running any more (see the consequence copy test below). + expect(button.textContent).toBe('Clear default win'); + }); + + // bc-cse: the server returns the match to SCHEDULED, not running, when + // the barred competitor (Tanaka) is still barred -- almost always true, + // since whatever barred them elsewhere is still on record -- so the + // consequence says that plainly rather than promising "it goes back to + // running". No fetchCompetitorStatuses stub here (the untouched default + // API mock), so the reinstate clause is correctly absent: unknown reads + // as "not reinstateable", never as a promise that may not hold. + it('the reopen consequence says the match queues again and the default win must be re-recorded', async () => { + await mount(defaultWin()); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).toBe('The match goes back to the queue. Tanaka is still withdrawn, so record the default win again.'); + expect(text).not.toContain('reinstate'); + expect(text).not.toContain('goes back to running'); + }); + + // bc-cse: when a live check finds Tanaka reinstateable (kiken-injury, + // still ineligible), the consequence ALSO offers that route. + it('offers the reinstate route too when the barred competitor is still ineligible and reinstateable', async () => { + window.API.fetchCompetitorStatuses = vi.fn().mockResolvedValue([ + { playerId: 'p2', eligible: false, reinstateable: true }, + ]); + await mount(defaultWin()); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-consequence').textContent).toContain('reinstate')); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).toBe('The match goes back to the queue. Tanaka is still withdrawn, so record the default win again, or reinstate Tanaka first to fight it.'); + }); + + // Not reinstateable (e.g. kiken-voluntary) or no longer barred: no + // reinstate clause either way -- only kiken-injury/still-ineligible earns it. + it('does not offer reinstate when the status says it is not reinstateable', async () => { + window.API.fetchCompetitorStatuses = vi.fn().mockResolvedValue([ + { playerId: 'p2', eligible: false, reinstateable: false }, + ]); + await mount(defaultWin()); + await waitFor(() => expect(window.API.fetchCompetitorStatuses).toHaveBeenCalled()); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).not.toContain('reinstate'); + }); + + // bc-cse: reinstated, or their earlier withdrawal cleared elsewhere -- + // either way the barred competitor's own status now reads eligible, so + // the server reopens THIS match straight to running rather than the + // queue, and the copy says that instead of promising the queue and a + // re-recorded default win. + it('says the match reopens in progress when the barred competitor is eligible again', async () => { + window.API.fetchCompetitorStatuses = vi.fn().mockResolvedValue([ + { playerId: 'p2', eligible: true }, + ]); + await mount(defaultWin()); + await waitFor(() => expect(window.API.fetchCompetitorStatuses).toHaveBeenCalled()); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).toBe('Tanaka can fight again, so the match reopens in progress. Score it and finish it as usual.'); + expect(text).not.toContain('goes back to the queue'); + expect(text).not.toContain('reinstate'); + }); + + it('clears in one tap through the same door as kiken/fusenpai, with no reason', async () => { + const onClose = vi.fn(); + await mountLive(defaultWin(), onClose); + await clearWithdrawal(); + expect(window.API.reopenMatch).toHaveBeenCalledWith( + 'comp1', defaultWin().id, 'secret', { reason: '', force: false }, + ); + await act(async () => { flip(REOPENED); }); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); + }); + + // withdrawalLabel's short "fact" form, for the team-summary-decision slot + // (admin_scoring_team.jsx) and anywhere else that needs a label rather + // than the full winner-naming sentence RecordedWithdrawal composes itself. + it('withdrawalLabel gives the short "fact" form', () => { + expect(withdrawalLabel('fusensho')).toBe('Default win (fusensho)'); + }); +}); + +// bc-cse: clearing THIS withdrawal never touches a LATER match the same +// withdrawn competitor's default win (RemainingMatchesPanel.award) already +// closed -- that match keeps its own recorded result. The consequence lists it +// as a consequence (a warning, not a lock, per the 2026-09-24 ruling): the +// operator can still reopen it separately to fight it. Fetched the same way +// RemainingMatchesPanel finds Tanaka/Kyoto's other matches. +describe('Clear withdrawal and reopen: a later default win from the same withdrawal (bc-cse)', () => { + it('lists a later default win as a consequence, named by its scores-list label plus the pairing', async () => { + const laterMatch = { + id: 'Pool 2-0', compId: 'comp1', phase: 'pool', poolName: 'Pool 2', status: 'completed', decision: 'fusensho', decisionBy: 'shiro', + sideA: { id: 'team-osaka', name: 'Osaka' }, sideB: { id: 'team-kyoto', name: 'Kyoto' }, + }; + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ id: 'comp1', config: {} }); + window.compMatchesForCompetition = vi.fn().mockReturnValue([laterMatch]); + await mount(teamWithdrawal()); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-default-win-consequences')).toBeTruthy()); + const line = screen.getByTestId(`clear-withdrawal-default-win-${laterMatch.id}`); + // bc-cse: named by scoreRowMatchLabel FIRST -- a pairing alone cannot be + // found in the scores list, which is where the operator has to go to + // reopen it -- joined to the pairing the same way ReopenFeedback's own + // fetched blockerLabel does (" · "). + expect(line.textContent).toBe('Pool 2 · Match 1 · Kyoto vs Osaka keeps its default win; reopen it to fight it.'); + }); + + // bc-cse: a match carrying no number at all (drawn before numbering + // existed, or a supplementary DH/TB bout) falls back to the bare pairing + // scoreRowMatchLabel's own doc describes -- never a raw internal id. + it('falls back to the bare pairing when the later match carries no number', async () => { + const laterMatch = { + id: 'Pool 2-DH-0', compId: 'comp1', phase: 'pool', poolName: 'Pool 2', status: 'completed', decision: 'fusensho', decisionBy: 'shiro', + sideA: { id: 'team-osaka', name: 'Osaka' }, sideB: { id: 'team-kyoto', name: 'Kyoto' }, + }; + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ id: 'comp1', config: {} }); + window.compMatchesForCompetition = vi.fn().mockReturnValue([laterMatch]); + await mount(teamWithdrawal()); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-default-win-consequences')).toBeTruthy()); + const line = screen.getByTestId(`clear-withdrawal-default-win-${laterMatch.id}`); + expect(line.textContent).toBe('Kyoto vs Osaka keeps its default win; reopen it to fight it.'); + expect(line.textContent).not.toContain('Pool 2-DH-0'); + }); + + it('a match already carrying a fusensho for a DIFFERENT competitor is not listed', async () => { + const unrelated = { + id: 'm-r2-1', compId: 'comp1', status: 'completed', decision: 'fusensho', decisionBy: 'aka', + sideA: { id: 'team-nagoya', name: 'Nagoya' }, sideB: { id: 'team-sendai', name: 'Sendai' }, + }; + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ id: 'comp1', config: {} }); + window.compMatchesForCompetition = vi.fn().mockReturnValue([unrelated]); + await mount(teamWithdrawal()); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-consequence')).toBeTruthy()); + expect(screen.queryByTestId('clear-withdrawal-default-win-consequences')).toBeNull(); + }); + + it('omits the block when there is no such later match', async () => { + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ id: 'comp1', config: {} }); + window.compMatchesForCompetition = vi.fn().mockReturnValue([]); + await mount(teamWithdrawal()); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-consequence')).toBeTruthy()); + expect(screen.queryByTestId('clear-withdrawal-default-win-consequences')).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/remaining_matches_panel_award.render.test.jsx b/web-mobile/js/__tests__/render/remaining_matches_panel_award.render.test.jsx new file mode 100644 index 000000000..545665bbb --- /dev/null +++ b/web-mobile/js/__tests__/render/remaining_matches_panel_award.render.test.jsx @@ -0,0 +1,152 @@ +// bc-rawm: RemainingMatchesPanel.award referenced an undefined `wname` +// (ReferenceError on every click, admin_scoring_shared.jsx). The unit suite +// never caught it because it mounts no real React and never invokes the +// click handler; only a REAL render, actually clicked, executes the function +// body. This drives the individual editor through a genuine kiken flow (the +// only door that opens the panel) and then clicks "Award default win to +// opponent", pinning both that it no longer throws and the exact +// recordDecision arguments: fusensho (not fusenpai, which the server refuses +// with 409 already_ineligible for a competitor already withdrawn elsewhere), +// decisionBy naming the withdrawn competitor's side, and a decisionReason +// naming them. + +import React from 'react'; +import { render, act, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + // Unlike the config-matrix / withdrawal-reopen render suites (which stub + // this false because their kiken fixtures are already-completed matches, + // never SUBMITTED through it), this test drives a LIVE kiken submit and + // depends on the branch this predicate gates (makeSubmitDecision, + // admin_scoring_shared.jsx): true routes to RemainingMatchesPanel, the one + // door this file exists to test. + isKikenDecision: (k) => typeof k === 'string' && k.startsWith('kiken'), + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: {}, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + poolLabel: (m) => m.poolName || 'Pool', + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +// Yamada (aka) vs Tanaka (shiro), fresh 0-0, a knockout Round 1 match. +const match = { + id: 'm-r1-0', compId: 'comp1', status: 'running', phase: 'knockout', round: 'Round 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, + sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0, +}; + +// A second, still-SCHEDULED match of Tanaka's, in a different pool, for the +// panel to list and the operator to award. +const remaining = { + id: 'Pool A-3', compId: 'comp1', phase: 'pool', poolName: 'Pool A', court: 'B', status: 'scheduled', + sideA: { id: 'p2', name: 'Tanaka' }, + sideB: { id: 'p3', name: 'Suzuki' }, +}; + +beforeEach(() => { + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.API = { + fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', config: { format: 'knockout', naginata: false, players: [] } }), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn().mockResolvedValue({ id: 'm-r1-0', status: 'completed' }), + }; + window.compMatchesForCompetition = vi.fn().mockReturnValue([remaining]); +}); + +async function mount(props = {}) { + let view; + await act(async () => { + view = render(); + }); + return view; +} + +async function recordTanakaKiken() { + await mount(); + await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-kiken-voluntary-button')); }); + // defaultSide="shiro" on DecisionPrompt: Tanaka (sideB) is the withdrawn + // side without touching the radio. + const record = [...document.querySelectorAll('.decision-prompt button')].find((b) => b.textContent === 'Record'); + await act(async () => { fireEvent.click(record); }); + await waitFor(() => expect(window.API.recordDecision).toHaveBeenCalledTimes(1)); +} + +describe('RemainingMatchesPanel.award (bc-rawm)', () => { + it('lists the remaining scheduled match and awards fusensho with the withdrawn side and a reason naming them', async () => { + await recordTanakaKiken(); + + // The panel opened, fetched, and listed Tanaka's remaining match. + await waitFor(() => expect(screen.getByText('Suzuki')).toBeTruthy()); + const award = screen.getByText('Award default win to opponent'); + expect(award.disabled).toBe(false); + + // The click must not throw (the ReferenceError this test exists to + // catch would surface here) and must record the right decision. + await act(async () => { fireEvent.click(award); }); + + expect(window.API.recordDecision).toHaveBeenCalledTimes(2); + const [compId, matchId, body, password] = window.API.recordDecision.mock.calls[1]; + expect(compId).toBe('comp1'); + expect(matchId).toBe('Pool A-3'); + // fusensho, not fusenpai: fusenpai would be refused (409 + // already_ineligible) for a competitor already withdrawn elsewhere. + expect(body.decision).toBe('fusensho'); + // Tanaka occupies sideA of the remaining match, so decisionBy names AKA + // -- the withdrawn competitor's side, exactly as fusenpai's did. + expect(body.decisionBy).toBe('aka'); + expect(body.decisionReason).toBe('auto: Tanaka withdrawn'); + expect(password).toBe('secret'); + + // The awarded match drops off the list; nothing left to walk. + await waitFor(() => expect(screen.queryByText('Suzuki')).toBeNull()); + expect(screen.getByText('No remaining scheduled matches.')).toBeTruthy(); + }); + + it('shows no remaining matches when Tanaka has none scheduled', async () => { + window.compMatchesForCompetition = vi.fn().mockReturnValue([]); + await recordTanakaKiken(); + await waitFor(() => expect(screen.getByText('No remaining scheduled matches.')).toBeTruthy()); + }); + + // bc-cse: notLandedBanner, not writeWasSuperseded alone -- that predicate + // is TRUE for both the superseded AND clock_skew refusal, so a clock_skew + // award used to read "a newer result for this match is already recorded", + // which sends the operator to check a result that was never written. + it('a clock_skew refusal on the award shows the clock copy, not the superseded one', async () => { + await recordTanakaKiken(); + await waitFor(() => expect(screen.getByText('Suzuki')).toBeTruthy()); + + window.API.recordDecision = vi.fn().mockResolvedValue({ applied: false, reason: 'clock_skew' }); + const award = screen.getByText('Award default win to opponent'); + await act(async () => { fireEvent.click(award); }); + + await waitFor(() => expect(screen.getByText(/this device's clock was out of step with the server/)).toBeTruthy()); + expect(screen.queryByText(/a newer result for this match is already recorded/)).toBeNull(); + // Not dropped from the list: the operator can retry once resynced. + expect(screen.getByText('Suzuki')).toBeTruthy(); + }); +}); diff --git a/web-mobile/js/__tests__/render/score_editor_barred_match.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_barred_match.render.test.jsx new file mode 100644 index 000000000..6a1f6f555 --- /dev/null +++ b/web-mobile/js/__tests__/render/score_editor_barred_match.render.test.jsx @@ -0,0 +1,154 @@ +// bc-cse: a scheduled match a competitor is barred from (withdrew earlier) +// cannot be fought. The Scores tab's own auto-advance (Finish + Start Next's +// nextActiveMatch) must skip it exactly as the court console's does, and its +// row shows the note plus the one-tap default-win action instead of relying +// on the operator to open the editor to discover why Start would fail. +// +// Prev/Next (chained navigation) still REACHES a barred match on purpose: +// opening one directly is how the operator resolves it (ineligible_match.jsx +// header). Only the AUTO-PICK is scoped here. +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const side = (id, name) => ({ id, name }); + +// bc-tmfn fixture convention: Yama withdrew (kiken-voluntary) on side B, so +// the default win is awaited for Umi (side A). +const RUNNING = { id: 'm-run', compId: 'c1', status: 'running', court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka') }; +const BARRED = { + id: 'm-barred', compId: 'c1', status: 'scheduled', court: 'A', scheduledAt: '09:05', + sideA: side('u', 'Umi'), sideB: side('y', 'Yama'), ineligibleSides: { b: 'kiken-voluntary' }, +}; +const OPEN = { id: 'm-open', compId: 'c1', status: 'scheduled', court: 'A', scheduledAt: '09:10', sideA: side('s', 'Sato'), sideB: side('k', 'Kato') }; + +// Captures the LATEST ScoreEditorModal props so a test can drive +// onSubmitAndNext directly, mirroring admin_shiaijo.render.test.jsx's probe. +const probe = { props: null }; + +const STUBS = { + ScoreEditorModal: (props) => { probe.props = props; return
; }, + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + CourtPicker: () => null, + getScoreBtnClass: () => 'test-score-open', + matchScoreStr: () => '', + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + compMatches: () => [RUNNING, BARRED, OPEN], + startPatch: () => ({ status: 'running', winner: null }), + confirmDialog: vi.fn().mockResolvedValue(true), + pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + }, +}; + +let restore, AdminScoreEditor; + +beforeAll(async () => { + window.scrollTo = vi.fn(); + restore = installWindowStubs(STUBS); + const mod = await import('../../admin_schedule_score_editor.jsx'); + AdminScoreEditor = mod.AdminScoreEditor; +}); +afterAll(() => restore()); + +function mount(onEditScore = vi.fn()) { + return render( + + ); +} + +describe('the Scores tab and a barred match (bc-cse)', () => { + it("the barred match's row shows the note and calls recordDecision with the fusensho body", async () => { + let utils; + await act(async () => { utils = mount(); }); + const notice = utils.getByTestId('barred-match-notice'); + expect(notice.textContent).toContain('Yama withdrew: record the default win.'); + const btn = utils.getByTestId('barred-match-default-win'); + expect(btn.textContent).toBe('Record default win for Umi'); + + await act(async () => { btn.click(); }); + expect(window.API.recordDecision).toHaveBeenCalledWith('c1', 'm-barred', { + decision: 'fusensho', decisionBy: 'shiro', decisionReason: 'auto: Yama withdrawn', + }, 'pw'); + }); + + it('nextActiveMatch (Finish + Start Next) skips the barred match and advances to the next fightable one', async () => { + const onEditScore = vi.fn().mockResolvedValue({ status: 'ok' }); + let utils; + await act(async () => { utils = mount(onEditScore); }); + + // Open the running match: rows sort running first. + const scoreBtns = [...utils.container.querySelectorAll('button.test-score-open')]; + await act(async () => { fireEvent.click(scoreBtns[0]); }); + expect(probe.props.match.id).toBe('m-run'); + + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + expect(onEditScore).toHaveBeenCalledTimes(2); + expect(onEditScore.mock.calls[0][1]).toBe('m-run'); + // m-barred is skipped; m-open is started instead. + expect(onEditScore.mock.calls[1][1]).toBe('m-open'); + }); + + // bc-cse: notLandedBanner, not writeWasSuperseded alone -- a clock_skew + // refusal must read its own copy, never the superseded one (which sends + // the operator to check a result that does not exist and tells them not + // to do the one thing, re-entering, that would actually save it). + it('a clock_skew refusal shows the clock copy, not the superseded one, and stays tappable', async () => { + window.API.recordDecision = vi.fn().mockResolvedValue({ applied: false, reason: 'clock_skew' }); + let utils; + await act(async () => { utils = mount(); }); + const btn = utils.getByTestId('barred-match-default-win'); + await act(async () => { btn.click(); }); + + const notice = utils.getByTestId('barred-match-notice'); + expect(notice.textContent).toContain("this device's clock was out of step with the server"); + expect(notice.textContent).not.toContain('a newer result for this match is already recorded'); + // Not landed: the operator can retry once the clock has resynced. + expect(btn.disabled).toBe(false); + expect(btn.textContent).toBe('Record default win for Umi'); + }); + + // bc-cse: after a landed (or queued -- see below) write the button must + // lock, or a second tap before the SSE refetch repaints this row sends a + // second /decision. + it('locks the button after a successful record so a second tap sends only one /decision', async () => { + window.API.recordDecision = vi.fn().mockResolvedValue({ applied: true }); + let utils; + await act(async () => { utils = mount(); }); + const btn = utils.getByTestId('barred-match-default-win'); + await act(async () => { btn.click(); }); + + expect(btn.disabled).toBe(true); + expect(btn.textContent).toBe('Recorded'); + await act(async () => { btn.click(); }); + expect(window.API.recordDecision).toHaveBeenCalledTimes(1); + }); + + // bc-cse: a QUEUED write (offline court) is not yet stored but WILL land, + // so the button locks the same way a landed write does -- a second tap + // would double-enqueue -- while the notice says it is not saved yet. + it('a queued write locks the button too and says it is not saved yet', async () => { + window.API.recordDecision = vi.fn().mockResolvedValue({ queued: true }); + let utils; + await act(async () => { utils = mount(); }); + const btn = utils.getByTestId('barred-match-default-win'); + await act(async () => { btn.click(); }); + + expect(btn.disabled).toBe(true); + const notice = utils.getByTestId('barred-match-notice'); + expect(notice.textContent).toContain('queued'); + await act(async () => { btn.click(); }); + expect(window.API.recordDecision).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx new file mode 100644 index 000000000..3eb99d960 --- /dev/null +++ b/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx @@ -0,0 +1,324 @@ +// bc-dscn: "Discard unsaved scoring changes?" may only appear where closing +// would really discard something. +// +// Three ways it used to lie: +// - On a RUNNING match every scoring edit is autosaved (300ms debounce), so +// the autosave landed while the prompt waited and "Discard" discarded +// nothing. Closing now saves any edit still inside the debounce window at +// once and closes without asking. +// - On the inline court console the host cannot close (canClose=false, +// onClose a no-op), yet Esc raised the prompt anyway. Esc is now ignored +// there, and handleDismiss refuses to run. +// - On a team match the dispatcher's own Esc listener also fired and closed +// the editor past the team editor's prompt. It now stands aside. +// +// A scheduled match (no autosave) and a correction keep the prompt, and so does +// a running individual match whose hikiwake toggle moved, because +// buildPatch("running") does not carry the draw, and a running kachinuki match +// with a changed unplayed row, which that patch drops. A hantei ARM alone is a +// mode, not a result: closing drops it with no prompt and no write. +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: (_type) => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: (_kind) => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + window.confirmDialog = vi.fn().mockResolvedValue(true); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function individualMatch(status) { + return { + id: `m-${status}`, + status, + phase: 'pool', + poolName: 'Pool 1', + court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, + sideB: { id: 'p2', name: 'Tanaka' }, + }; +} + +function teamMatch(status) { + return { + id: `t-${status}`, + status, + phase: 'pool', + poolName: 'Pool 1', + court: 'A', + compKind: 'team', + teamSize: 3, + sideA: { id: 'teamA', name: 'Team A' }, + sideB: { id: 'teamB', name: 'Team B' }, + }; +} + +function mount(match, props = {}) { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const utils = render( + , + ); + return { ...utils, onSubmit, onClose }; +} + +// Resolves to fireEvent's return value: false when a handler called +// preventDefault, i.e. when the editor claimed the key. +const pressEscape = async () => { + let notPrevented; + await act(async () => { notPrevented = fireEvent.keyDown(document.body, { key: 'Escape' }); }); + return notPrevented; +}; +const clickClose = () => act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); + +describe('bc-dscn: individual editor', () => { + it('a RUNNING match closes without the prompt and saves the pending edit now', async () => { + const { onSubmit, onClose } = mount(individualMatch('running')); + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + expect(onSubmit).not.toHaveBeenCalled(); // still inside the debounce window + + await clickClose(); + + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].status).toBe('running'); + expect(onClose).toHaveBeenCalledTimes(1); + + // The debounce timer was cancelled by the flush, not left to fire again. + await act(async () => { vi.advanceTimersByTime(350); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('an untouched RUNNING match closes without writing', async () => { + const { onSubmit, onClose } = mount(individualMatch('running')); + await clickClose(); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('a SCHEDULED match (no autosave) still prompts before discarding', async () => { + const { onClose } = mount(individualMatch('scheduled')); + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + await clickClose(); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); // the stub confirms + }); + + it('a RUNNING match with the hikiwake toggle moved still prompts (the running patch carries no draw)', async () => { + const { onClose, onSubmit } = mount(individualMatch('running')); + await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-mark-draw')); }); + window.confirmDialog = vi.fn().mockResolvedValue(false); + await clickClose(); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + // The arm is a mode, not a result: flushing it would send a freshly stamped + // write with an unchanged scoreline, which can win last-write-wins over + // another device's older queued result. + it('a RUNNING match with only the hantei ARMED closes with no prompt and no write', async () => { + const { onSubmit, onClose } = mount(individualMatch('running')); + await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-arm')); }); + await clickClose(); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('Esc on the inline court console (canClose=false) never prompts', async () => { + // Scheduled, so a dirty editor WOULD prompt if Esc reached handleDismiss: + // only the canClose gate can keep the dialog away here. + const { onClose } = mount(individualMatch('scheduled'), { variant: 'inline', canClose: false }); + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + // Esc is left to whatever has focus (e.g. an open fighter list), not claimed. + expect(await pressEscape()).toBe(true); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe('bc-dscn: team editor', () => { + it('a RUNNING team match closes without the prompt and saves the pending edit now', async () => { + const { onSubmit, onClose } = mount(teamMatch('running')); + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + expect(onSubmit).not.toHaveBeenCalled(); + + await clickClose(); + + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledTimes(1); + const patch = onSubmit.mock.calls[0][0]; + expect(patch.status).toBe('running'); + expect(patch.subResults.some((s) => (s.ipponsA || []).length + (s.ipponsB || []).length > 0)).toBe(true); + expect(onClose).toHaveBeenCalledTimes(1); + + await act(async () => { vi.advanceTimersByTime(350); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('a RUNNING team match with only the daihyosen hantei ARMED closes with no prompt and no write', async () => { + const match = { + ...teamMatch('running'), + subResults: [ + ...[1, 2, 3].map((position) => ({ position, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: 'hikiwake' })), + { position: -1, sideA: 'Team A', sideB: 'Team B', decision: 'daihyosen' }, + ], + }; + const { onSubmit, onClose } = mount(match); + await act(async () => { fireEvent.click(screen.getByTestId('team-daihyosen-hantei-arm')); }); + await clickClose(); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // Under kachinuki the running patch drops every unplayed row, so a fighter + // picked on the current bout (it rides the bout, not a lineup PUT) is not + // carried by a flush: closing would lose it, so the prompt stays. + it('a RUNNING kachinuki match with a fighter picked on the unscored current bout still prompts', async () => { + const savedAPI = window.API; + window.API = { + ...savedAPI, + fetchCompetitionDetails: vi.fn().mockResolvedValue({ + id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, + }), + fetchSquads: vi.fn().mockResolvedValue({ + 'team-A': [{ id: 'm-kept', index: 1, name: 'Kept Winner' }, { id: 'm-fresh', index: 9, name: 'Fresh Fighter' }], + 'team-B': [{ id: 'm-shiro', index: 1, name: 'Shiro One' }], + }), + }; + try { + const match = { + id: 'k1', compId: 'comp1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 5, compFormat: 'mixed', teamMatchType: 'kachinuki', + sideA: { id: 'team-A', name: 'Team A' }, + sideB: { id: 'team-B', name: 'Team B' }, + subResults: [ + ...[1, 2, 3, 4, 5].map((position) => ({ position, sideA: `Aka ${position}`, sideB: `Shiro ${position}`, ipponsA: ['M'], ipponsB: [] })), + { position: 6, sideA: 'Kept Winner', sideB: '', sideAMemberId: 'm-kept', ipponsA: [], ipponsB: [] }, + ], + }; + let utils; + await act(async () => { utils = mount(match); }); + const akaInput = document.querySelector('.team-sub-match__side--aka input'); + await act(async () => { fireEvent.focus(akaInput); }); + const fresh = Array.from(document.querySelectorAll('.team-sub-match__side--aka .pmf__option')) + .find((b) => b.textContent.includes('Fresh Fighter')); + expect(fresh, 'expected "Fresh Fighter" to be offered').toBeTruthy(); + await act(async () => { fireEvent.mouseDown(fresh); }); + + window.confirmDialog = vi.fn().mockResolvedValue(false); + await clickClose(); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(utils.onClose).not.toHaveBeenCalled(); + expect(utils.onSubmit).not.toHaveBeenCalled(); + } finally { + window.API = savedAPI; + } + }); + + it('a SCHEDULED team match still prompts before discarding', async () => { + mount(teamMatch('scheduled')); + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + await clickClose(); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + }); + + // The dispatcher (ScoreEditorModal) registers its own keydown listener before + // it hands a team match to TeamScoreEditorModal. That listener used to act as + // well, so Esc closed the team editor straight past its own prompt, and a + // point key wrote a phantom individual-shaped result on a team match. + it('Esc on a dirty SCHEDULED team match waits for the prompt (declined: stays open)', async () => { + const { onClose } = mount(teamMatch('scheduled')); + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + window.confirmDialog = vi.fn().mockResolvedValue(false); + await pressEscape(); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('a point key on a team match writes no match-level ippons', async () => { + const { onSubmit } = mount(teamMatch('running')); + await act(async () => { fireEvent.keyDown(document.body, { key: 'm' }); }); + await act(async () => { vi.advanceTimersByTime(350); }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('Esc on the inline court console (canClose=false) never prompts', async () => { + const { onClose } = mount(teamMatch('scheduled'), { variant: 'inline', canClose: false }); + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + // Esc is left to whatever has focus (e.g. an open fighter list), not claimed. + expect(await pressEscape()).toBe(true); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +// bc-kbhn: each editor passes the hint what its keydown handler really does. +describe('bc-kbhn: the editors list only the keys that act on their host', () => { + const hintKbds = () => [...screen.getByTestId('scoring-modal-shortcut-hint').querySelectorAll('kbd')].map((k) => k.textContent); + + it('individual, court console (inline, canClose=false, no nav): no arrows, no Esc', () => { + mount(individualMatch('running'), { variant: 'inline', canClose: false }); + expect(hintKbds()).not.toContain('←'); + expect(hintKbds()).not.toContain('Esc'); + }); + + it('individual, Scores tab with a neighbour: arrows and Esc', () => { + mount(individualMatch('running'), { nextMatch: individualMatch('scheduled'), onPrev: vi.fn(), onNext: vi.fn() }); + expect(hintKbds()).toEqual(expect.arrayContaining(['←', '→', 'Esc'])); + }); + + it('individual, callbacks wired but no neighbour: no arrows (the keys do nothing)', async () => { + const onPrev = vi.fn(); + const onNext = vi.fn(); + mount(individualMatch('running'), { onPrev, onNext }); + expect(hintKbds()).not.toContain('←'); + await act(async () => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }); }); + expect(onPrev).not.toHaveBeenCalled(); + }); + + it('team, court console (inline, canClose=false, no nav): no arrows, no Esc', () => { + mount(teamMatch('running'), { variant: 'inline', canClose: false }); + expect(screen.queryByTestId('scoring-modal-shortcut-hint')).toBeNull(); // team match (no keyboard scoring): nothing left to list + }); +}); diff --git a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx index 8d6aa428d..8b6e0e69f 100644 --- a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx @@ -99,6 +99,8 @@ let AdminShiaijoPage, AdminPools, AdminBracket, AdminScoreEditor, MatchViewerMod beforeAll(async () => { // jsdom doesn't implement scrollTo; the schedule surface calls it on open. window.scrollTo = vi.fn(); + // Nor scrollIntoView; the bracket page scrolls its panel into view on a pick. + Element.prototype.scrollIntoView = vi.fn(); restoreGlobals = installWindowStubs(STUBBED_GLOBALS); await import('../../admin_shiaijo.jsx'); await import('../../admin_pools.jsx'); diff --git a/web-mobile/js/__tests__/render/score_editor_team_default_win_mark.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_team_default_win_mark.render.test.jsx new file mode 100644 index 000000000..b5f1bb954 --- /dev/null +++ b/web-mobile/js/__tests__/render/score_editor_team_default_win_mark.render.test.jsx @@ -0,0 +1,108 @@ +// bc-tmfn: the admin Scores list places the match-level Kiken/Fus. mark a +// default-win decision gives a TEAM match beside the WITHDRAWN team's name +// (window.teamMatchMarks, bracket.jsx), never inside the shared score cell +// (teamIVPWScore is deliberately free of marks) and never doubled onto an +// individual match, whose own mark already rides inline in its score +// string. +// +// This mounts the REAL bracket.jsx (transitively, via +// admin_schedule_lineup.jsx -> admin_scoring_shared.jsx -> bracket.jsx, +// which sets window.teamMatchMarks/sideMarks/placeMarks during the dynamic +// import below) rather than stubbing teamMatchMarks: a stub set before that +// import is silently overwritten once bracket.jsx's own module-eval-time +// window assignments run, so this exercises the real sideMarks + placeMarks +// + sameCompetitor composition, not a fake. +import React from 'react'; +import { render, act, screen, cleanup, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const side = (name, dojo, number) => ({ name, dojo, number, id: `${number}-id` }); + +// Shiro (sideB) withdrew: decisionBy="shiro" -> Aka (sideA, Tora A) is +// credited/wins, matching engine.RecordDecisionTx's decisionBy->Winner +// mapping. winner is the SAME object reference as sideA so sameCompetitor +// resolves it by id without ambiguity. +const toraA = side('Tora A', 'Nara', 'T1'); +const toraB = side('Tora B', 'Tokyo', 'T2'); +const TEAM_MATCH = { + id: 'team-m', compId: 'c1', status: 'completed', court: 'A', scheduledAt: '09:00', + sideA: toraA, sideB: toraB, winner: toraA, + decision: 'kiken-voluntary', decisionBy: 'shiro', + subResults: [{ position: 1, winner: 'Tora A' }, { position: 2 }], +}; +const INDIVIDUAL_MATCH = { + id: 'indiv-m', compId: 'c1', status: 'completed', court: 'A', scheduledAt: '09:15', + sideA: side('Ken Saito', 'Nara', 'K1'), sideB: side('Aiko Sato', 'Tokyo', 'K2'), + winner: 'Ken Saito', decision: 'kiken-voluntary', decisionBy: 'shiro', +}; +const MATCHES = [TEAM_MATCH, INDIVIDUAL_MATCH]; + +const STUBS = { + ScoreEditorModal: () => null, + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + CourtPicker: () => null, + getScoreBtnClass: () => 'test-score-open', + matchScoreStr: (m) => (m.id === 'team-m' ? 'IV 0–2\nPW 0–4' : 'M vs Kiken'), + boutMiddle: () => 'vs', + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + compMatches: () => MATCHES, + startPatch: () => ({ status: 'running', winner: null }), + confirmDialog: vi.fn().mockResolvedValue(true), + pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, + API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null) }, +}; + +let restore, AdminScoreEditor; + +beforeAll(async () => { + window.scrollTo = vi.fn(); + restore = installWindowStubs(STUBS); + const mod = await import('../../admin_schedule_score_editor.jsx'); + AdminScoreEditor = mod.AdminScoreEditor; +}); +afterAll(() => restore()); +beforeEach(() => cleanup()); + +async function mount() { + await act(async () => { + render( + + ); + }); +} + +describe('the Scores list places the default-win mark beside the withdrawn TEAM name', () => { + it('shows "Kiken" on the team row, beside Tora B (Shiro, the side decisionBy names)', async () => { + await mount(); + const teamRow = screen.getByText('Tora B').closest('.score-edit-row'); + expect(teamRow).toBeTruthy(); + const mark = within(teamRow).getByTestId('team-summary-mark-shiro'); + expect(mark.textContent).toBe('Kiken'); + // Tora A (Aka) is the credited winner: no mark on that side. + expect(within(teamRow).queryByTestId('team-summary-mark-aka')).toBeNull(); + }); + + it('does NOT double the mark on an individual row (it already rides inline in the score string)', async () => { + await mount(); + const indivRow = screen.getByText('Ken Saito').closest('.score-edit-row'); + expect(indivRow).toBeTruthy(); + expect(within(indivRow).queryByTestId('team-summary-mark-shiro')).toBeNull(); + expect(within(indivRow).queryByTestId('team-summary-mark-aka')).toBeNull(); + }); + + it('the score cell itself stays free of the mark (teamIVPWScore has none to carry)', async () => { + await mount(); + const teamRow = screen.getByText('Tora B').closest('.score-edit-row'); + const scoreCell = teamRow.querySelector('.score-edit-row__scoreval'); + expect(scoreCell.textContent).not.toContain('Kiken'); + }); +}); diff --git a/web-mobile/js/__tests__/render/scoring_shortcut_hint.render.test.jsx b/web-mobile/js/__tests__/render/scoring_shortcut_hint.render.test.jsx index 89374edea..038886a30 100644 --- a/web-mobile/js/__tests__/render/scoring_shortcut_hint.render.test.jsx +++ b/web-mobile/js/__tests__/render/scoring_shortcut_hint.render.test.jsx @@ -11,7 +11,7 @@ const kbds = (container) => [...container.querySelectorAll('kbd')].map((k) => k. describe('ScoringShortcutHint keyboard discoverability', () => { it('lists the ippon keys with Shiro/Aka when scoring is active', () => { - const { container, getByTestId } = render(); + const { container, getByTestId } = render(); expect(kbds(container)).toEqual(expect.arrayContaining(['M', 'K', 'D', 'T', 'H'])); const t = getByTestId('scoring-modal-shortcut-hint').textContent; expect(t).toContain('Shiro'); @@ -25,10 +25,45 @@ describe('ScoringShortcutHint keyboard discoverability', () => { }); it('shows only nav shortcuts when scoring is tap-only (fixed-order team bout)', () => { - const { container, getByTestId } = render(); + const { container, getByTestId } = render(); expect(kbds(container)).not.toContain('M'); const t = getByTestId('scoring-modal-shortcut-hint').textContent; expect(t).not.toContain('Shiro'); expect(t).toContain('prev/next'); }); }); + +// bc-kbhn: the hint showed on the iPad and advertised keys the court console +// ignores. +describe('bc-kbhn: the hint lists only keys that act, and can hide', () => { + it('lists no arrows and no Esc when the host wires neither (the court console)', () => { + const { container, getByTestId } = render(); + expect(kbds(container)).not.toContain('←'); + expect(kbds(container)).not.toContain('→'); + expect(kbds(container)).not.toContain('Esc'); + const t = getByTestId('scoring-modal-shortcut-hint').textContent; + expect(t).not.toContain('prev/next'); + expect(t).not.toContain('close'); + // No dangling separator after the last group. + expect(t.trim().endsWith('·')).toBe(false); + }); + + it('lists Esc without the arrows when the host can close but has no neighbour', () => { + const { container } = render(); + expect(kbds(container)).toEqual(['Esc']); + }); + + it('renders nothing when it would list nothing', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('carries no inline display, so the coarse-pointer rule in styles.css can hide it', () => { + // jsdom cannot evaluate (pointer: coarse); what it can pin is the defect: + // an inline display:flex outranks any stylesheet rule, media query or not. + const { getByTestId } = render(); + const el = getByTestId('scoring-modal-shortcut-hint'); + expect(el.classList.contains('scoring-shortcut-hint')).toBe(true); + expect(el.style.display).toBe(''); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx b/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx index 86e700b66..90d3e27c2 100644 --- a/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx @@ -57,6 +57,13 @@ afterAll(() => restoreGlobals()); beforeEach(() => { window.API.recordScore.mockClear(); }); +// bc-tmfn: every bout of a team match is fought, so Finish refuses while a +// numbered bout has no result. Bouts 2 and 3 are recorded Ties; the test +// strikes bout 1 itself, so there is still a SCORED row to check. +const TIED_BOUTS = [2, 3].map((position) => ({ + position, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: 'hikiwake', +})); + function makeMatch(overrides = {}) { return { id: 'm-pool-1', @@ -70,6 +77,7 @@ function makeMatch(overrides = {}) { sideA: { id: 'team-kyoto', name: 'Kyoto' }, sideB: { id: 'team-osaka', name: 'Osaka' }, ...overrides, + subResults: [...TIED_BOUTS, ...(overrides.subResults || [])], }; } diff --git a/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx index 65e2b7e5c..c657e0238 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx @@ -72,6 +72,14 @@ beforeEach(() => { window.API.recordScore.mockClear(); }); +// bc-tmfn: every bout of a team match is fought, so Finish refuses while a +// numbered bout has no result. These fixtures finish, so each numbered bout +// is recorded as a Tie, which is what an untouched row used to be sent as: +// the daihyosen row these tests are about sees exactly what it saw before. +const FOUGHT_BOUTS = [1, 2, 3].map((position) => ({ + position, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: 'hikiwake', +})); + function makeMatch(overrides = {}) { return { id: 'm-pool-1', @@ -85,6 +93,7 @@ function makeMatch(overrides = {}) { sideA: { id: 'team-kyoto', name: 'Kyoto' }, sideB: { id: 'team-osaka', name: 'Osaka' }, ...overrides, + subResults: [...FOUGHT_BOUTS, ...(overrides.subResults || [])], }; } diff --git a/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx index 0c610073b..82f33d1be 100644 --- a/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx @@ -195,6 +195,10 @@ describe('team score editor: a failed write disarms the finish confirmation', () sideB: { id: 'team-B', name: 'Team B' }, subResults: [ { position: 1, sideA: 'Team A', sideB: 'Team B', ipponsA: ['M', 'M'], ipponsB: [], winner: 'Team A' }, + // bc-tmfn: every bout is fought, so Finish only arms once each + // numbered bout has a result. + { position: 2, sideA: '', sideB: '', ipponsA: ['K'], ipponsB: [], winner: 'Team A' }, + { position: 3, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: 'hikiwake' }, ], }; } diff --git a/web-mobile/js/__tests__/render/team_finish_refuses_unfought_bouts.render.test.jsx b/web-mobile/js/__tests__/render/team_finish_refuses_unfought_bouts.render.test.jsx new file mode 100644 index 000000000..31b1949ea --- /dev/null +++ b/web-mobile/js/__tests__/render/team_finish_refuses_unfought_bouts.render.test.jsx @@ -0,0 +1,162 @@ +// bc-tmfn: Finish on a team match refuses while a numbered bout has no result +// (operator ruling 2026-09-24: every bout of a team match is fought). The +// refusal names the bouts in visible text, in the server's words, and sends +// nothing. Corrections are not exempt, except a correction to a match a +// withdrawal ended, which keeps that ruling (removing one is a reopen, pinned +// in recorded_withdrawal_reopen.render.test.jsx). Pure-helper cases live in +// unfinished_team_bouts.test.jsx; the server half in team_finish_gate_test.go. + +import React from 'react'; +import { render, act, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { window.API.recordScore.mockClear(); }); + +const FOUGHT_BOUT_1 = { position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [], winner: 'Kyoto', decision: '' }; + +function makeMatch(overrides = {}) { + return { + id: 'm-pool-1', + compId: 'comp1', + status: 'running', + phase: 'pool', + poolName: 'Pool 1', + court: 'A', + compKind: 'team', + teamSize: 3, + sideA: { id: 'team-kyoto', name: 'Kyoto' }, + sideB: { id: 'team-osaka', name: 'Osaka' }, + subResults: [FOUGHT_BOUT_1], + ...overrides, + }; +} + +async function mount(match) { + await act(async () => { + render( window.API.recordScore('comp1', match.id, p, '', match)} password="" />); + }); +} + +const primaryButton = (re) => [...document.querySelectorAll('button')].find((b) => re.test(b.textContent || '')); + +describe('team editor: Finish refuses while a bout has no result', () => { + it('names the unfought bouts, does not arm, and sends nothing', async () => { + await mount(makeMatch()); + expect(screen.queryByTestId('team-finish-unfinished-bouts')).toBeNull(); + + await act(async () => { fireEvent.click(screen.getByText('Finish')); }); + + const refusal = screen.getByTestId('team-finish-unfinished-bouts'); + expect(refusal.textContent).toBe('Bout 2 and Bout 3 have no result. Record a score, a Tie, or a Fusensho before finishing.'); + expect(refusal.textContent).not.toMatch(/lineup/i); + expect(screen.queryByText('Tap again to finish')).toBeNull(); + expect(window.API.recordScore.mock.calls.filter(([, , p]) => p?.status === 'completed')).toHaveLength(0); + }); + + it('finishes once every bout has a result, a Tie included', async () => { + await mount(makeMatch()); + await act(async () => { fireEvent.click(screen.getByText('Finish')); }); + expect(screen.getByTestId('team-finish-unfinished-bouts')).toBeTruthy(); + + // Tie bouts 2 and 3 (one Tie button per numbered row, in bout order). + // The refusal follows the board live. + const ties = () => [...document.querySelectorAll('[data-testid="scoring-modal-tie-button"]')]; + expect(ties()).toHaveLength(3); + await act(async () => { fireEvent.click(ties()[1]); }); + expect(screen.getByTestId('team-finish-unfinished-bouts').textContent).toMatch(/^Bout 3 has no result\./); + await act(async () => { fireEvent.click(ties()[2]); }); + expect(screen.queryByTestId('team-finish-unfinished-bouts')).toBeNull(); + + await act(async () => { fireEvent.click(screen.getByText('Finish')); }); + await act(async () => { fireEvent.click(screen.getByText('Tap again to finish')); }); + const completed = window.API.recordScore.mock.calls.filter(([, , p]) => p?.status === 'completed'); + expect(completed).toHaveLength(1); + const numbered = completed[0][2].subResults.filter((s) => s.position > 0); + expect(numbered.map((s) => s.decision)).toEqual(['', 'hikiwake', 'hikiwake']); + }); + + it('refuses a correction that would leave a bout without a result', async () => { + await mount(makeMatch({ status: 'completed', winner: { id: 'team-kyoto', name: 'Kyoto' } })); + const save = primaryButton(/Save correction/); + expect(save).toBeTruthy(); + await act(async () => { fireEvent.click(save); }); + expect(screen.getByTestId('team-finish-unfinished-bouts').textContent) + .toBe('Bout 2 and Bout 3 have no result. Record a score, a Tie, or a Fusensho before finishing.'); + expect(window.API.recordScore.mock.calls.filter(([, , p]) => p?.status === 'completed')).toHaveLength(0); + }); + + // Operator ruling 2026-09-24: "Save correction should just save what the + // operator enters." A correction to a match a withdrawal ended keeps that + // ruling on the server (engine.KeepsWithdrawalRuling), so the bouts nobody + // fought after it do not refuse the save, and the write restates nothing + // about the withdrawal. + it.each(['kiken-voluntary', 'kiken-injury', 'kiken', 'fusenpai'])('saves a correction over a recorded %s without refusing or restating it', async (decision) => { + await mount(makeMatch({ + status: 'completed', decision, decisionBy: 'aka', decisionReason: 'knee', + winner: { id: 'team-osaka', name: 'Osaka' }, ipponsB: ['○', '○'], + })); + // Recovery path: a withdrawal recorded against the wrong team is re-decided + // from the same editor, so the panel stays offered on the correction. + expect(screen.getByTestId('scoring-modal-kiken-voluntary-button')).toBeTruthy(); + await act(async () => { fireEvent.click(primaryButton(/Save correction/)); }); + const confirm = primaryButton(/Tap again|Confirm/); + if (confirm) await act(async () => { fireEvent.click(confirm); }); + expect(screen.queryByTestId('team-finish-unfinished-bouts')).toBeNull(); + const completed = window.API.recordScore.mock.calls.filter(([, , p]) => p?.status === 'completed'); + expect(completed).toHaveLength(1); + const patch = completed[0][2]; + expect(patch.decision).toBeUndefined(); + expect(patch.decisionBy).toBeUndefined(); + expect(patch.decisionReason).toBeUndefined(); + expect(patch.subResults.filter((s) => s.position > 0)[0].ipponsA).toEqual(['M']); + // The bouts favour Kyoto, the side that withdrew; the write names the + // ruling's winner so no local bracket advance can move Kyoto on, and it + // is not a draw (score.type maps to decision "hikiwake"). + expect(patch.winner?.name ?? patch.winner).toBe('Osaka'); + expect(patch.score.type).toBe('ippon'); + }); + + it('sends an untouched bout as no result, not as a Tie', async () => { + await mount(makeMatch({ status: 'scheduled', subResults: [] })); + await act(async () => { fireEvent.click(primaryButton(/Start/)); }); + const running = window.API.recordScore.mock.calls.map(([, , p]) => p).find((p) => p?.status === 'running'); + expect(running).toBeTruthy(); + const numbered = running.subResults.filter((s) => s.position > 0); + expect(numbered).toHaveLength(3); + for (const s of numbered) expect(s.decision).toBe(''); + }); +}); diff --git a/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx b/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx index 7a0224650..f29ab4e03 100644 --- a/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx +++ b/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx @@ -48,3 +48,17 @@ describe('useTeamLineups follows a changed squads map (bc-dnst)', () => { expect(latest.squadA.map(m => m.name)).toEqual(['Sato']); }); }); + +// A pool match stores Round -1 ("no round"). A board handed the stored +// match (no roundIndex) must read it as round 0, as resolveRoundIndex does: +// the server refuses a negative round with a 400. +describe('useTeamLineups reads a pool match stored Round -1 as round 0', () => { + it('never asks the server for round -1', async () => { + const POOL_MATCH = { ...MATCH, round: -1 }; + function PoolProbe() { useTeamLineups(POOL_MATCH, COMP('Sato')); return null; } + await act(async () => { render(); }); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(window.API.fetchTeamLineup).toHaveBeenCalled(); + for (const call of window.API.fetchTeamLineup.mock.calls) expect(call[2]).toBe(0); + }); +}); diff --git a/web-mobile/js/__tests__/render/withdrawal_correction_shows_result.render.test.jsx b/web-mobile/js/__tests__/render/withdrawal_correction_shows_result.render.test.jsx new file mode 100644 index 000000000..5ee0e2fe9 --- /dev/null +++ b/web-mobile/js/__tests__/render/withdrawal_correction_shows_result.render.test.jsx @@ -0,0 +1,235 @@ +// bc-kcsh: "Opening Correct on a match decided by kiken or fusenpai (team and +// individual) shows the recorded decision and the winner before any edit." +// A match has ONE result and every surface asking for it shows the same one, +// and the score editors are such surfaces. Two gaps this pins: +// - the TEAM band read its verdict off the bouts, which the withdrawal +// ended, so a won match read "DRAW"; it now states the recorded winner +// and decision while the withdrawal is in force. IV/PW (bc-tmfn) now +// include a default-win CREDIT for every numbered bout the withdrawal +// left with no result of its own -- IV+1/PW+2 to the OTHER side from +// decisionBy, same as every other surface (team_default_credit.jsx) -- +// on top of whatever WAS actually fought before the withdrawal landed; +// - neither editor put the Kiken/Fus. result mark beside the withdrawn +// side. It rides beside that side's name (WithdrawalMarkedName, from +// sideMarks + withdrawnKeyOf), on the inner side, never in the centre. + +import React from 'react'; +import { render, act, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: {}, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + compMatchesForCompetition: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + window.API = { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDecision: vi.fn(), + reopenMatch: vi.fn(), + requeueBlockerAndReopen: vi.fn(), + putMatchLineup: vi.fn(), + }; +}); + +// A fixed-order team pool match Kyoto (aka) won bout 1 of, then withdrew +// from. The server stores the ruling's winner (Osaka) and its default-win +// maru; the unfought bouts stay blank. +function teamWithdrawal(overrides = {}) { + return { + id: 'm-pool-1', compId: 'comp1', status: 'completed', phase: 'pool', poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 3, + sideA: { id: 'team-kyoto', name: 'Kyoto', number: 'T1' }, + sideB: { id: 'team-osaka', name: 'Osaka', number: 'T2' }, + subResults: [{ position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [], winner: 'Kyoto', decision: '' }], + decision: 'kiken-voluntary', decisionBy: 'aka', + winner: { id: 'team-osaka', name: 'Osaka' }, ipponsB: ['○', '○'], + ...overrides, + }; +} + +// An individual pool match Endo (shiro) struck men in, then withdrew from. +function individualWithdrawal(overrides = {}) { + return { + id: 'm-pool-2', compId: 'comp2', status: 'completed', phase: 'pool', poolName: 'Pool A', court: 'D', + sideA: { id: 'p-aoki', name: 'Aoki Taro', number: 'K1' }, + sideB: { id: 'p-endo', name: 'Endo Goro', number: 'K3' }, + ipponsA: ['○', '○'], ipponsB: ['M'], hansokuA: 0, hansokuB: 0, + decision: 'kiken-voluntary', decisionBy: 'shiro', + winner: { id: 'p-aoki', name: 'Aoki Taro' }, + ...overrides, + }; +} + +async function mount(match) { + await act(async () => { + render(); + }); +} + +const header = () => document.querySelector('.editor-modal__body .sb-match'); +const sideName = (color) => header().querySelector(`.sb-side--${color} .sb-name`); + +// The mark names ONE competitor: it sits in that side's name cell, on the +// inner side (across the name from the number chip), and nowhere else. +function expectMarkBeside(color, mark) { + const other = color === 'shiro' ? 'aka' : 'shiro'; + const el = screen.getByTestId(`withdrawal-mark-${color}`); + expect(el.textContent).toBe(mark); + expect(sideName(color).contains(el)).toBe(true); + expect(screen.queryByTestId(`withdrawal-mark-${other}`)).toBeNull(); + expect(header().querySelector('.sb-center').textContent).not.toContain(mark); + const nameText = sideName(color).querySelector('.numbered-name__text'); + // DOCUMENT_POSITION_FOLLOWING (4): the second node follows the first. + const markFollowsName = !!(nameText.compareDocumentPosition(el) & 4); + expect(markFollowsName).toBe(color === 'shiro'); +} + +describe('team editor: correcting a match a withdrawal ended', () => { + it('states the recorded winner and decision, not the bouts\' DRAW', async () => { + await mount(teamWithdrawal()); + expect(screen.getByTestId('team-summary-result').textContent).toBe('SHIRO WIN'); + expect(screen.getByTestId('team-summary-decision').textContent).toBe('Kiken – Voluntary'); + // bc-tmfn: bout 1 was actually fought (aka/Kyoto won it: IV 1, PW 1). + // Bouts 2 and 3 (teamSize=3) carry no result of their own, so they are + // credited to shiro/Osaka -- the OTHER side from decisionBy="aka" -- + // IV+1/PW+2 each: IV 2, PW 4. + const stats = [...document.querySelectorAll('.team-summary__stats')].map((n) => n.textContent); + expect(stats).toEqual(['IV: 2 · PW: 4', 'IV: 1 · PW: 1']); + }); + + it('marks the withdrawn team beside its name, never in the centre', async () => { + await mount(teamWithdrawal()); + expectMarkBeside('aka', 'Kiken'); + }); + + it('a fusenpai names the no-show team and the other side as winner', async () => { + await mount(teamWithdrawal({ + decision: 'fusenpai', decisionBy: 'shiro', + winner: { id: 'team-kyoto', name: 'Kyoto' }, ipponsA: ['○', '○'], ipponsB: [], + })); + expect(screen.getByTestId('team-summary-result').textContent).toBe('AKA WIN'); + expect(screen.getByTestId('team-summary-decision').textContent).toBe('Fusenpai'); + expectMarkBeside('shiro', 'Fus.'); + }); + + it('shows neither once no withdrawal is in force', async () => { + await mount(teamWithdrawal({ status: 'running', decision: '', decisionBy: '', winner: null, ipponsB: [] })); + expect(screen.queryByTestId('team-summary-decision')).toBeNull(); + expect(screen.queryByTestId('withdrawal-mark-aka')).toBeNull(); + expect(screen.queryByTestId('withdrawal-mark-shiro')).toBeNull(); + }); +}); + +// bc-cse: a match-level fusensho (the OTHER competitor already withdrew +// elsewhere, this match defaults to the opponent) is inside withdrawalInForce +// too, but sideMarks' fusensho arm puts "Fus." on the WINNER, not the loser -- +// the opposite of kiken/fusenpai. WithdrawalMarkedName has to place whichever +// mark belongs to the rendered side, not always read .loser. +describe('a match-level fusensho marks the winner, not the barred competitor', () => { + it('team editor: the credited team carries Fus., not the barred one', async () => { + await mount(teamWithdrawal({ + decision: 'fusensho', decisionBy: 'aka', + winner: { id: 'team-osaka', name: 'Osaka' }, + })); + expect(screen.getByTestId('team-summary-result').textContent).toBe('SHIRO WIN'); + expectMarkBeside('shiro', 'Fus.'); + }); + + it('individual editor: the credited competitor carries Fus., not the barred one', async () => { + await mount(individualWithdrawal({ + decision: 'fusensho', decisionBy: 'shiro', + winner: { id: 'p-aoki', name: 'Aoki Taro' }, ipponsA: [], ipponsB: [], + })); + expectMarkBeside('aka', 'Fus.'); + }); +}); + +describe('individual editor: correcting a match a withdrawal ended', () => { + it('marks the withdrawn competitor beside their name, never in the centre', async () => { + await mount(individualWithdrawal()); + expectMarkBeside('shiro', 'Kiken'); + }); + + it('a fusenpai marks the no-show with Fus.', async () => { + await mount(individualWithdrawal({ + decision: 'fusenpai', decisionBy: 'aka', + winner: { id: 'p-endo', name: 'Endo Goro' }, ipponsA: [], ipponsB: ['○', '○'], + })); + expectMarkBeside('aka', 'Fus.'); + }); + + it('shows no mark once no withdrawal is in force', async () => { + await mount(individualWithdrawal({ status: 'running', decision: '', decisionBy: '', winner: null, ipponsA: [] })); + expect(screen.queryByTestId('withdrawal-mark-aka')).toBeNull(); + expect(screen.queryByTestId('withdrawal-mark-shiro')).toBeNull(); + }); +}); + +// bc-tmfn: DecisionPrompt (admin_scoring_shared.jsx) has no slot for extra +// copy, so the consequence is stated beside it in admin_scoring_team.jsx +// instead -- generically (the credited TEAM's name isn't known until the +// operator picks a side inside the prompt's own radio), naming the concrete +// bout count. +describe('team editor: the withdrawal confirm states the default-win consequence', () => { + it('names the bout count once a decision kind is picked', async () => { + // Running, no decision yet: bout 1 fought, bouts 2-3 (teamSize=3) not. + await mount(teamWithdrawal({ status: 'running', decision: '', decisionBy: '', winner: null, ipponsB: [] })); + fireEvent.click(screen.getByTestId('scoring-modal-kiken-voluntary-button')); + const note = screen.getByTestId('decision-consequence-note'); + expect(note.textContent).toContain('2–0'); + expect(note.textContent).toContain('each of the 2 bouts'); + }); + + // bc-cse: team_default_credit.jsx excludes kachinuki from the default-win + // credit on purpose (bouts are appended one at a time; the encounter ends + // on an explicit End match, never a match-level decision standing in for + // unplayed slots), so this note must stay silent there even with unscored + // bouts on the board -- it would otherwise promise a credit that never + // lands. + it('is silent on a kachinuki encounter, which the default-win credit excludes', async () => { + await mount(teamWithdrawal({ + status: 'running', decision: '', decisionBy: '', winner: null, ipponsB: [], + teamMatchType: 'kachinuki', + })); + fireEvent.click(screen.getByTestId('scoring-modal-kiken-voluntary-button')); + expect(screen.queryByTestId('decision-consequence-note')).toBeNull(); + }); + + it('is silent once every bout already has a result', async () => { + await mount(teamWithdrawal({ + status: 'running', decision: '', decisionBy: '', winner: null, ipponsB: [], + subResults: [ + { position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [], winner: 'Kyoto', decision: '' }, + { position: 2, sideA: '', sideB: '', ipponsA: [], ipponsB: ['K'], winner: 'Osaka', decision: '' }, + { position: 3, sideA: '', sideB: '', ipponsA: ['D'], ipponsB: [], winner: 'Kyoto', decision: '' }, + ], + })); + fireEvent.click(screen.getByTestId('scoring-modal-kiken-voluntary-button')); + expect(screen.queryByTestId('decision-consequence-note')).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/score_display.test.jsx b/web-mobile/js/__tests__/score_display.test.jsx index 67ddfa973..0219c691e 100644 --- a/web-mobile/js/__tests__/score_display.test.jsx +++ b/web-mobile/js/__tests__/score_display.test.jsx @@ -188,13 +188,17 @@ describe('formatIpponsScore', () => { expect(formatIpponsScore(['○', '○'], [], null, 'kiken-voluntary', null, false, 'left')).toBe('○○ vs Kiken'); }); - it('fusensho places NO side mark: the bout badge carries it (documented Excel divergence)', () => { - // internal/export/suffix.go SideMarks folds fusensho in as a winner-side - // "Fus." because Excel has no badges; the JS mirror deliberately does - // not. Pin the divergence from this side too so a future change folding - // "Fus." back into the string cannot land silently. - expect(formatIpponsScore(['M'], [], null, 'fusensho', null, false, 'left')).toBe('M vs –'); - expect(formatIpponsScore([], [], null, 'fusensho', null, false, 'left')).toBe('○○ vs –'); + it('fusensho places a winner-side "Fus." mark, mirroring internal/export/suffix.go SideMarks exactly (bc-tmfn)', () => { + // The earlier version of this test pinned a deliberate divergence: the + // Excel export folded fusensho in as a winner-side "Fus." (no badge to + // carry it there) while this surface omitted it on the theory that "the + // viewer surfaces it via a separate bout badge". No such badge exists + // for a MATCH-LEVEL fusensho decision, so that theory left a real + // default win with no mark anywhere on this surface. sideMarks now + // agrees with the Go mirror unconditionally; there is no divergence + // left to pin. + expect(formatIpponsScore(['M'], [], null, 'fusensho', null, false, 'left')).toBe('M Fus. vs –'); + expect(formatIpponsScore([], [], null, 'fusensho', null, false, 'left')).toBe('○○ Fus. vs –'); }); it('kiken during overtime: loser mark plus the (E) middle', () => { diff --git a/web-mobile/js/__tests__/sync_queue.test.jsx b/web-mobile/js/__tests__/sync_queue.test.jsx index 0db3a7b11..92e137c20 100644 --- a/web-mobile/js/__tests__/sync_queue.test.jsx +++ b/web-mobile/js/__tests__/sync_queue.test.jsx @@ -1298,6 +1298,50 @@ describe('_flushQueue: downstream_knockout_played 409 on a queued correction (bc }); }); +// bc-cse: a queued SCORE write can be replayed after the shiaijo it wants has +// since been taken by a different match -- the server's 409 court_busy, which +// falls through the generic non-retryable-4xx drop branch (same as +// downstream_knockout_played above). Pre-fix this reported the bare +// "court_busy" token; it must report the operator sentence instead. +describe('_flushQueue: court_busy 409 on a queued score write (bc-cse)', () => { + it('drops the entry and reports the shiaijo + blocking match, not the raw token', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockFetch(() => Promise.reject(new TypeError('offline'))); + await API.recordScore('c1', 'mcorr', { status: 'completed', winner: 'A' }, 'pw', null); + expect(API.hasPendingTerminalWrite('c1', 'mcorr')).toBe(true); + + const failures = []; + const unsubFail = mod.subscribeTerminalWriteFailed((info) => failures.push(info)); + const alerts = []; + const unsubAlert = mod.subscribeQueueAlert((a) => alerts.push(a)); + + mockFetch(() => Promise.resolve({ + ok: false, + status: 409, + json: () => Promise.resolve({ + error: 'court_busy', court: 'A', matchId: 'm-blk', compId: 'c1', label: 'Pool A · Match 2', + }), + })); + window.dispatchEvent(new Event('online')); + await tick(50); + unsubFail(); + unsubAlert(); + warnSpy.mockRestore(); + + expect(API.hasPendingTerminalWrite('c1', 'mcorr')).toBe(false); + + expect(failures.length).toBeGreaterThanOrEqual(1); + expect(failures[0].reason).toBe( + 'Shiaijo A is running Pool A · Match 2. Finish it or send it back to the queue first.' + ); + expect(failures[0].reason).not.toBe('court_busy'); + + const rejected = alerts.filter((a) => a.kind === 'rejected'); + expect(rejected.length).toBeGreaterThanOrEqual(1); + expect(rejected[0].detail).not.toBe('court_busy'); + }); +}); + // mp-y3nk: a queued override the server LWW-dropped (applied:false) must trigger // a bracketResync notification so stale optimistic local bracket state is replaced. // The queue entry is drained regardless (retry cannot change the outcome). @@ -2080,3 +2124,109 @@ describe('writeWasSuperseded (bc-lww1)', () => { expect(mod.writeWasSuperseded).toBe(owner.writeWasSuperseded); }); }); + +// A forced write ("Apply and reopen") answered ONE refusal, naming the matches +// that stood then. The queue must never replay that confirmation: a replay +// minutes later would reopen whatever stands by then, which nobody confirmed. +// _enqueueTerminalWrite strips forceDownstreamReopen, so the replay meets the +// refusal again and is dropped with words, while the live attempt still sent it. +describe('_enqueueTerminalWrite: a queued write never carries the downstream confirmation', () => { + it.each([ + ['score', () => API.recordScore('c1', 'mforce', { status: 'completed', winner: 'A', forceDownstreamReopen: true }, 'pw', null)], + ['decision', () => API.recordDecision('c1', 'mforce', { decision: 'kiken-voluntary', decisionBy: 'aka', forceDownstreamReopen: true }, 'pw')], + ])('%s: the live try sends it, the queued replay does not', async (_kind, send) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const live = []; + mockFetch((_url, opts) => { + live.push(JSON.parse(opts.body)); + return Promise.resolve({ ok: false, status: 503, json: () => Promise.resolve({ error: 'unavailable' }) }); + }); + const res = await send(); + expect(res).toEqual({ queued: true }); + expect(live[0].forceDownstreamReopen).toBe(true); + + const replays = []; + const failures = []; + const unsubFail = mod.subscribeTerminalWriteFailed((info) => failures.push(info)); + mockFetch((_url, opts) => { + replays.push(JSON.parse(opts.body)); + return Promise.resolve({ + ok: false, + status: 409, + json: () => Promise.resolve({ + error: 'downstream_knockout_played', + matchId: 'mforce', + blockingMatchId: 'm9', + blockingMatches: [{ id: 'm9', number: 9 }], + displaced: 'Aoki Taro', + qualifierChange: [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Aoki Taro' }, to: { name: 'Bob' } }], + }), + }); + }); + window.dispatchEvent(new Event('online')); + await tick(50); + unsubFail(); + warnSpy.mockRestore(); + + expect(replays.length).toBeGreaterThanOrEqual(1); + for (const body of replays) expect(body).not.toHaveProperty('forceDownstreamReopen'); + expect(API.hasPendingTerminalWrite('c1', 'mforce')).toBe(false); + expect(failures[0].reason).toContain('Match 9'); + }); + + // The enqueue strip cannot reach an entry that was queued before it + // existed: an older bundle wrote the confirmation into localStorage, and + // this bundle rehydrates that entry verbatim. The flush loop is the one + // door every replay goes through, so it must strip it too. + it('a rehydrated entry that still holds the confirmation replays without it', async () => { + const stale = [['c1:mold', { + compID: 'c1', matchID: 'mold', + payload: { status: 'completed', winner: 'A', forceDownstreamReopen: true }, + password: 'pw', kind: 'score', terminal: true, method: 'PUT', + url: '/api/competitions/c1/matches/mold/score', enqueuedAt: Date.now(), + }]]; + localStorage.setItem('bc_write_queue', JSON.stringify(stale)); + const replays = []; + mockFetch((url, opts) => { + if (String(url).includes('/matches/mold/')) replays.push(JSON.parse(opts.body)); + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + }); + vi.resetModules(); + const m = await import('../api_client.jsx'); + await tick(50); + expect(replays.length).toBeGreaterThanOrEqual(1); + for (const body of replays) { + expect(body).not.toHaveProperty('forceDownstreamReopen'); + expect(body.winner).toBe('A'); + } + expect(m.API.hasPendingTerminalWrite('c1', 'mold')).toBe(false); + }); + + it('a replay meeting a knockout match being fought is dropped with the operator copy, not the token', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockFetch(() => Promise.reject(new TypeError('offline'))); + await API.recordScore('c1', 'mrun', { status: 'completed', winner: 'A' }, 'pw', null); + expect(API.hasPendingTerminalWrite('c1', 'mrun')).toBe(true); + + const failures = []; + const unsubFail = mod.subscribeTerminalWriteFailed((info) => failures.push(info)); + mockFetch(() => Promise.resolve({ + ok: false, + status: 409, + json: () => Promise.resolve({ + error: 'downstream_knockout_running', + matchId: 'mrun', + runningMatches: [{ id: 'm-r1-0', number: 9 }], + message: 'Match 9 is being fought now. Finish it or send it back to the queue, then save again.', + }), + })); + window.dispatchEvent(new Event('online')); + await tick(50); + unsubFail(); + warnSpy.mockRestore(); + + expect(API.hasPendingTerminalWrite('c1', 'mrun')).toBe(false); + expect(failures[0].reason).toBe('Match 9 is being fought now'); + expect(failures[0].advice).toBe('Finish it or send it back to the queue, then enter this result again.'); + }); +}); diff --git a/web-mobile/js/__tests__/team_default_credit.test.jsx b/web-mobile/js/__tests__/team_default_credit.test.jsx new file mode 100644 index 000000000..2e602c9b5 --- /dev/null +++ b/web-mobile/js/__tests__/team_default_credit.test.jsx @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { + isTeamDefaultWinDecision, creditedSideKey, teamDefaultWinCreditActive, + subBoutHasResult, creditedBoutSide, creditedTotals, +} from '../team_default_credit.jsx'; + +describe('isTeamDefaultWinDecision: the default-win decision class', () => { + it('is true for every kiken variant, fusenpai and fusensho', () => { + for (const d of ['kiken', 'kiken-voluntary', 'kiken-injury', 'fusenpai', 'fusensho']) { + expect(isTeamDefaultWinDecision(d)).toBe(true); + } + }); + it('is false for fought/hikiwake/daihyosen/empty/unknown', () => { + for (const d of ['fought', 'hikiwake', 'daihyosen', '', undefined, 'nonsense']) { + expect(isTeamDefaultWinDecision(d)).toBe(false); + } + }); +}); + +describe('creditedSideKey: the OTHER side from decisionBy, else the winner (bc-cse #9)', () => { + it('shiro withdrew -> aka (sideA) is credited', () => { + expect(creditedSideKey({ decisionBy: 'shiro' })).toBe('a'); + }); + it('aka withdrew -> shiro (sideB) is credited', () => { + expect(creditedSideKey({ decisionBy: 'aka' })).toBe('b'); + }); + it('returns "" for a missing/unrecognised decisionBy and no winner to fall back to', () => { + expect(creditedSideKey({ decisionBy: '' })).toBe(''); + expect(creditedSideKey({ decisionBy: undefined })).toBe(''); + expect(creditedSideKey({ decisionBy: 'nonsense' })).toBe(''); + expect(creditedSideKey(undefined)).toBe(''); + }); + it('decisionBy wins when set, even with a winner attribution present (Go: decisionBy checked first)', () => { + expect(creditedSideKey({ + decisionBy: 'shiro', + winner: { id: 'p-shiro', name: 'Shiro Competitor' }, + sideA: { id: 'p-aka', name: 'Aka Competitor' }, + sideB: { id: 'p-shiro', name: 'Shiro Competitor' }, + })).toBe('a'); + }); + it('empty decisionBy falls back to the winner side by id (legacy row, mirrors Go AttributeWinnerSide)', () => { + expect(creditedSideKey({ + decisionBy: '', + winner: { id: 'p-aka' }, + sideA: { id: 'p-aka', name: 'Aka Competitor' }, + sideB: { id: 'p-shiro', name: 'Shiro Competitor' }, + })).toBe('a'); + expect(creditedSideKey({ + decisionBy: '', + winner: { id: 'p-shiro' }, + sideA: { id: 'p-aka', name: 'Aka Competitor' }, + sideB: { id: 'p-shiro', name: 'Shiro Competitor' }, + })).toBe('b'); + }); + it('empty decisionBy falls back to the winner side by name when no ids are present', () => { + expect(creditedSideKey({ + decisionBy: '', + winner: 'Aka Competitor', + sideA: 'Aka Competitor', + sideB: 'Shiro Competitor', + })).toBe('a'); + }); + it('returns "" when neither decisionBy nor the winner attribution can name a side', () => { + expect(creditedSideKey({ decisionBy: '', winner: '', sideA: 'Aka Competitor', sideB: 'Shiro Competitor' })).toBe(''); + expect(creditedSideKey({ + decisionBy: '', + winner: { id: 'someone-else' }, + sideA: { id: 'p-aka', name: 'Aka Competitor' }, + sideB: { id: 'p-shiro', name: 'Shiro Competitor' }, + })).toBe(''); + }); +}); + +describe('teamDefaultWinCreditActive: the ruling-in-force gate', () => { + const base = { status: 'completed', decision: 'kiken-voluntary', decisionBy: 'shiro', kachinuki: false }; + it('active for a completed non-kachinuki default-win match with decisionBy', () => { + expect(teamDefaultWinCreditActive(base)).toBe(true); + }); + it('inactive while the match is still running', () => { + expect(teamDefaultWinCreditActive({ ...base, status: 'running' })).toBe(false); + }); + it('inactive for kachinuki: no fixed teamSize of positions to pad out to', () => { + expect(teamDefaultWinCreditActive({ ...base, kachinuki: true })).toBe(false); + }); + it('inactive for a decision outside the default-win class', () => { + expect(teamDefaultWinCreditActive({ ...base, decision: 'fought' })).toBe(false); + }); + it('inactive with no decisionBy to name a credited side', () => { + expect(teamDefaultWinCreditActive({ ...base, decisionBy: '' })).toBe(false); + }); +}); + +describe('subBoutHasResult: the wire-level twin of Go SubMatchResult.HasResult', () => { + it('false for a padding row with nothing recorded', () => { + expect(subBoutHasResult({ position: 3 })).toBe(false); + expect(subBoutHasResult(undefined)).toBe(false); + }); + it('true when a winner name is recorded', () => { + expect(subBoutHasResult({ position: 1, winner: 'Taro' })).toBe(true); + }); + it('true when a decision string is recorded (even hikiwake)', () => { + expect(subBoutHasResult({ position: 1, decision: 'hikiwake' })).toBe(true); + }); + it('true for a real struck ippon on either side', () => { + expect(subBoutHasResult({ position: 1, ipponsA: ['M'], ipponsB: [] })).toBe(true); + expect(subBoutHasResult({ position: 1, ipponsA: [], ipponsB: ['K'] })).toBe(true); + }); + it('false for a row carrying only the unfilled placeholder', () => { + expect(subBoutHasResult({ position: 1, ipponsA: ['•'], ipponsB: ['•'] })).toBe(false); + }); + it('true for the hantei mark alone, with no other score', () => { + expect(subBoutHasResult({ position: 1, ipponsA: ['Ht'], ipponsB: [] })).toBe(true); + }); + it('true for a hansoku foul with no ippons', () => { + expect(subBoutHasResult({ position: 1, hansokuA: 1 })).toBe(true); + expect(subBoutHasResult({ position: 1, hansokuB: 1 })).toBe(true); + }); + it('true for an overtime period with no other score', () => { + expect(subBoutHasResult({ position: 1, encho: { periodCount: 1 } })).toBe(true); + }); + it('false for a zero-period encho block (degenerate, not real overtime)', () => { + expect(subBoutHasResult({ position: 1, encho: { periodCount: 0 } })).toBe(false); + }); +}); + +describe('creditedBoutSide: per-bout credit under the active ruling', () => { + const ctx = { status: 'completed', decision: 'fusenpai', decisionBy: 'aka', kachinuki: false }; + it('credits shiro (sideB) when aka withdrew and the bout has no result', () => { + expect(creditedBoutSide({ position: 2 }, ctx)).toBe('b'); + }); + it('credits nothing when the bout already has its own result', () => { + expect(creditedBoutSide({ position: 2, winner: 'Someone' }, ctx)).toBe(''); + }); + it('credits nothing when the ruling is not active', () => { + expect(creditedBoutSide({ position: 2 }, { ...ctx, status: 'running' })).toBe(''); + }); + it('credits nothing for kachinuki even with a matching decision', () => { + expect(creditedBoutSide({ position: 2 }, { ...ctx, kachinuki: true })).toBe(''); + }); + it('handles a bout the wire has not sent at all (undefined sub)', () => { + expect(creditedBoutSide(undefined, ctx)).toBe('b'); + }); +}); + +describe('creditedTotals: IV +1 / PW +2 per credited bout, all to one side', () => { + it('awards ivA/pwA for side "a"', () => { + expect(creditedTotals(3, 'a')).toEqual({ ivA: 3, ivB: 0, pwA: 6, pwB: 0 }); + }); + it('awards ivB/pwB for side "b"', () => { + expect(creditedTotals(2, 'b')).toEqual({ ivA: 0, ivB: 2, pwA: 0, pwB: 4 }); + }); + it('is all-zero for a falsy side regardless of count', () => { + expect(creditedTotals(5, '')).toEqual({ ivA: 0, ivB: 0, pwA: 0, pwB: 0 }); + }); + it('is all-zero for a zero count regardless of side', () => { + expect(creditedTotals(0, 'a')).toEqual({ ivA: 0, ivB: 0, pwA: 0, pwB: 0 }); + }); +}); diff --git a/web-mobile/js/__tests__/team_iv_score.test.jsx b/web-mobile/js/__tests__/team_iv_score.test.jsx index faf6fc7bb..f0a7aa0f1 100644 --- a/web-mobile/js/__tests__/team_iv_score.test.jsx +++ b/web-mobile/js/__tests__/team_iv_score.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { teamIVScore, teamIVPWScore } from '../bracket.jsx'; +import { teamIVScore, teamIVPWScore, teamMatchMarks } from '../bracket.jsx'; // teamIVScore derives the individual-victories (IV) aggregate for a team pool match // from persisted subResults. Mirrors Go engine.ComputeTeamSummary. @@ -123,6 +123,64 @@ describe('teamIVScore', () => { }; expect(teamIVScore(m)).toBe('1–0'); }); + + // bc-tmfn: a team match a match-level default-win decision closed credits + // every numbered bout with no result of its own to the OTHER side from + // decisionBy. This is the fallback path (no server teamResult); the + // authoritative count lives in m.teamResult once the server sends it. + describe('bc-tmfn default-win credit', () => { + it('credits every unfought numbered bout to the side opposite decisionBy', () => { + const m = { + sideA: 'TeamA', sideB: 'TeamB', + status: 'completed', decision: 'kiken-voluntary', decisionBy: 'shiro', + subResults: [ + { position: 1, winner: 'TeamB', sideA: 'P1', sideB: 'P2' }, // fought, keeps its own winner + { position: 2 }, // unfought → credited to Aka (sideA), decisionBy=shiro withdrew + { position: 3 }, // unfought → credited to Aka too + ], + }; + // Fought: B=1. Credited: A+=2 (shiro withdrew, aka/sideA wins by default). + expect(teamIVScore(m)).toBe('1–2'); + }); + + it('does not credit a bout that already carries a result', () => { + const m = { + sideA: 'TeamA', sideB: 'TeamB', + status: 'completed', decision: 'fusenpai', decisionBy: 'aka', + subResults: [ + { position: 1, winner: 'TeamA', sideA: 'P1', sideB: 'P2' }, // already decided, not credited + ], + }; + expect(teamIVScore(m)).toBe('0–1'); + }); + + it('does not credit while the match is still running', () => { + const m = { + sideA: 'TeamA', sideB: 'TeamB', + status: 'running', decision: 'kiken-voluntary', decisionBy: 'shiro', + subResults: [{ position: 1 }], + }; + expect(teamIVScore(m)).toBe('0–0'); + }); + + it('does not credit a kachinuki match', () => { + const m = { + sideA: 'TeamA', sideB: 'TeamB', teamMatchType: 'kachinuki', + status: 'completed', decision: 'kiken-voluntary', decisionBy: 'shiro', + subResults: [{ position: 1 }], + }; + expect(teamIVScore(m)).toBe('0–0'); + }); + + it('does not credit a decision outside the default-win class', () => { + const m = { + sideA: 'TeamA', sideB: 'TeamB', + status: 'completed', decision: 'fought', decisionBy: 'shiro', + subResults: [{ position: 1 }], + }; + expect(teamIVScore(m)).toBe('0–0'); + }); + }); }); // teamIVPWScore: full team-match result "IV shiroIV–akaIV" over "PW shiroPW–akaPW" (newline-separated). @@ -186,3 +244,68 @@ describe('teamIVPWScore', () => { expect(teamIVPWScore(m)).toBe('IV 2–1'); }); }); + +// teamMatchMarks: bc-tmfn. The one shared "which side gets which +// match-level Kiken/Fus. mark" composition (sameCompetitor + sideMarks + +// placeMarks), reused by every list/headline row that shows a TEAM's name +// separately from its (mark-free) score cell. +describe('teamMatchMarks', () => { + const toraA = { name: 'Tora A', dojo: 'Nara', id: 'a-id' }; + const toraB = { name: 'Tora B', dojo: 'Tokyo', id: 'b-id' }; + + it('kiken: the mark names the WITHDRAWER (loser), on their side', () => { + // Shiro (Tora B) withdrew; Aka (Tora A) is credited and wins. + const m = { status: 'completed', decision: 'kiken-voluntary', sideA: toraA, sideB: toraB, winner: toraA }; + expect(teamMatchMarks(m, true)).toEqual({ shiro: 'Kiken', aka: '' }); + }); + + it('fusenpai: the mark also names the no-show (loser), on their side', () => { + const m = { status: 'completed', decision: 'fusenpai', sideA: toraA, sideB: toraB, winner: toraB }; + expect(teamMatchMarks(m, true)).toEqual({ shiro: '', aka: 'Fus.' }); + }); + + it('fusensho: the mark names the WINNER (the present side), on their side', () => { + const m = { status: 'completed', decision: 'fusensho', sideA: toraA, sideB: toraB, winner: toraA }; + expect(teamMatchMarks(m, true)).toEqual({ shiro: '', aka: 'Fus.' }); + }); + + it('returns no marks for a non-team row (isTeamRow=false): an individual match already carries its own mark inline', () => { + const m = { status: 'completed', decision: 'kiken-voluntary', sideA: toraA, sideB: toraB, winner: toraA }; + expect(teamMatchMarks(m, false)).toEqual({ shiro: '', aka: '' }); + }); + + it('returns no marks before the match is completed', () => { + const m = { status: 'running', decision: 'kiken-voluntary', sideA: toraA, sideB: toraB, winner: toraA }; + expect(teamMatchMarks(m, true)).toEqual({ shiro: '', aka: '' }); + }); + + it('returns no marks for a decision outside the default-win class (e.g. fought)', () => { + const m = { status: 'completed', decision: 'fought', sideA: toraA, sideB: toraB, winner: toraA }; + expect(teamMatchMarks(m, true)).toEqual({ shiro: '', aka: '' }); + }); + + it('returns no marks for a null/undefined match', () => { + expect(teamMatchMarks(null, true)).toEqual({ shiro: '', aka: '' }); + expect(teamMatchMarks(undefined, true)).toEqual({ shiro: '', aka: '' }); + }); + + // bc-cse: isTeamRow is now OPTIONAL. Omitting it (the four list/row callers + // no longer pre-compute their own copy) derives the same + // Array.isArray(subResults) && subResults.length > 0 signal internally. + describe('isTeamRow omitted: derives the team-row signal from subResults', () => { + it('a team row (non-empty subResults) still gets its mark', () => { + const m = { status: 'completed', decision: 'kiken-voluntary', sideA: toraA, sideB: toraB, winner: toraA, subResults: [{ position: 1 }] }; + expect(teamMatchMarks(m)).toEqual({ shiro: 'Kiken', aka: '' }); + }); + + it('an individual row (no subResults) gets no marks, same as isTeamRow=false', () => { + const m = { status: 'completed', decision: 'kiken-voluntary', sideA: toraA, sideB: toraB, winner: toraA }; + expect(teamMatchMarks(m)).toEqual({ shiro: '', aka: '' }); + }); + + it('a null/undefined match still returns no marks', () => { + expect(teamMatchMarks(null)).toEqual({ shiro: '', aka: '' }); + expect(teamMatchMarks(undefined)).toEqual({ shiro: '', aka: '' }); + }); + }); +}); diff --git a/web-mobile/js/__tests__/unfinished_team_bouts.test.jsx b/web-mobile/js/__tests__/unfinished_team_bouts.test.jsx new file mode 100644 index 000000000..84e4ea20f --- /dev/null +++ b/web-mobile/js/__tests__/unfinished_team_bouts.test.jsx @@ -0,0 +1,75 @@ +// bc-tmfn: a team match cannot be finished while a numbered bout has no result +// (operator ruling 2026-09-24: every bout of a team match is fought). These pin +// the editor half of the gate: which bouts it names, that every position counts +// whoever the lineups field there, and the refusal copy, which must read word +// for word what the server says. +// Go half: internal/mobileapp/team_finish_gate_test.go. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { unfinishedTeamBouts, unfinishedTeamBoutsMessage } from '../admin_scoring_team.jsx'; + +const blank = (pos) => ({ _pos: pos, aPts: [], bPts: [], aFouls: 0, bFouls: 0, fusensho: '', draw: false, encho: 0 }); + +describe('unfinishedTeamBouts', () => { + it('names every numbered bout with no result, in bout order', () => { + const subs = [ + { ...blank(1), aPts: ['M'] }, + blank(2), + { ...blank(3), draw: true }, + { ...blank(4), fusensho: 'a' }, + blank(5), + ]; + expect(unfinishedTeamBouts({ subs, teamSize: 5 })).toEqual([2, 5]); + }); + + it('counts a foul alone or an overtime as a result', () => { + const subs = [{ ...blank(1), bFouls: 1 }, { ...blank(2), encho: 1 }]; + expect(unfinishedTeamBouts({ subs, teamSize: 2 })).toEqual([]); + }); + + it('finds rows by position, never by index, and never names the daihyosen row', () => { + const subs = [blank(-1), { ...blank(2), aPts: ['K'] }, { ...blank(1), aPts: ['M'] }]; + expect(unfinishedTeamBouts({ subs, teamSize: 3 })).toEqual([3]); + }); + + it('does not read the lineups: a position neither side fields still needs a Tie', () => { + // The old gate skipped bout 2 here, where neither lineup names a fighter. + // Every bout needs a result or a decision (operator ruling 2026-09-24), + // so the lineups are not an input at all. + const subs = [{ ...blank(1), aPts: ['M'] }, blank(2), { ...blank(3), draw: true }]; + const lineupA = { positions: { 1: 'A1' } }; + const lineupB = { positions: { 1: 'B1' } }; + expect(unfinishedTeamBouts({ subs, teamSize: 3, lineupA, lineupB })).toEqual([2]); + expect(unfinishedTeamBouts({ subs: [subs[0], { ...blank(2), draw: true }, subs[2]], teamSize: 3 })).toEqual([]); + }); +}); + +// The Go half reads the same file (TestUnfinishedTeamBoutsMessage_SharedTable). +describe('unfinishedTeamBoutsMessage Go/JS mirror', () => { + const table = JSON.parse(readFileSync( + resolve(__dirname, '..', '..', '..', 'internal', 'mobileapp', 'testdata', 'unfinished_team_bouts.json'), + 'utf8', + )); + + // it.each over an empty array produces zero tests, so a degraded table + // needs its own failure. + it('the shared table is present and non-empty', () => { + expect(table.cases?.length).toBeGreaterThan(0); + }); + + it.each(table.cases)('teamSize $teamSize, bouts $bouts', ({ teamSize, bouts, message }) => { + expect(unfinishedTeamBoutsMessage(teamSize, bouts), 'update BOTH implementations, not just this one').toBe(message); + }); + + it('never mentions the lineup', () => { + for (const { teamSize, bouts } of table.cases) { + expect(unfinishedTeamBoutsMessage(teamSize, bouts)).not.toMatch(/lineup/i); + } + }); + + it('is empty when nothing is unfinished', () => { + expect(unfinishedTeamBoutsMessage(5, [])).toBe(''); + }); +}); diff --git a/web-mobile/js/__tests__/viewer_competition_bye_results.test.jsx b/web-mobile/js/__tests__/viewer_competition_bye_results.test.jsx index a57336d44..d0c933ff6 100644 --- a/web-mobile/js/__tests__/viewer_competition_bye_results.test.jsx +++ b/web-mobile/js/__tests__/viewer_competition_bye_results.test.jsx @@ -7,12 +7,10 @@ // filter in viewer_competition.jsx used to test only `status === "completed" // && m.winner`, so those byes rendered as finished results reading // "TBD vs " under a Final badge, with nothing to say that nobody was -// ever scheduled. A bye is bracket structure, not a result; it stays -// discoverable in the Bracket tab, where the entrant renders as an unopposed -// slot tagged BYE feeding the next round (bc-bye-slot__tag in BracketTreeMeta, -// pinned by render/bracket_bye_slot.render.test.jsx; not the MatchCard's -// bc-bye-tag, which is gated on score.type === "bye" and unreachable from a -// server payload). +// ever scheduled. A bye is bracket structure, not a result; the Bracket tab +// shows it the way the printed sheets do, with the entrant appearing only in +// the card of the match they first fight (pinned by +// render/bracket_structural_bye.render.test.jsx). // // The filter's guard is the shared `hasBothSides` predicate, NOT a hand-rolled // `m.sideA && m.sideB`: normalizeMatch (api_serializers.jsx) substitutes a diff --git a/web-mobile/js/__tests__/viewer_home_secondary_ondeck.test.jsx b/web-mobile/js/__tests__/viewer_home_secondary_ondeck.test.jsx index b635c95e6..94934613b 100644 --- a/web-mobile/js/__tests__/viewer_home_secondary_ondeck.test.jsx +++ b/web-mobile/js/__tests__/viewer_home_secondary_ondeck.test.jsx @@ -48,4 +48,18 @@ describe('filterSecondaryOnDeck', () => { const m = { id: 'm1', status: 'running', sideA: { id: 'a', name: 'A' }, sideB: { id: 'b', name: 'B' } }; expect(filterSecondaryOnDeck([m], [], noPrimary)).toEqual([]); }); + + // bc-tmfn: filterSecondaryOnDeck gates on isFollowedMatchOnDeck, which is + // false for a barred scheduled match (ineligible_match.jsx) whatever its + // queuePosition reads -- so a watched player's barred "next" match never + // lights the quiet on-deck banner. + it('excludes a barred scheduled match even at queuePosition 1', () => { + const watched = [{ id: 'sato-tokyo', name: 'Sato' }]; + const barredMatch = { + id: 'm1', status: 'scheduled', queuePosition: 1, + ineligibleSides: { a: 'kiken-voluntary' }, + sideA: { id: 'sato-tokyo', name: 'Sato' }, sideB: { id: 'other', name: 'Someone' }, + }; + expect(filterSecondaryOnDeck([barredMatch], watched, noPrimary)).toEqual([]); + }); }); diff --git a/web-mobile/js/__tests__/viewer_ondeck_alert.test.jsx b/web-mobile/js/__tests__/viewer_ondeck_alert.test.jsx index b7a17e457..7148b18ba 100644 --- a/web-mobile/js/__tests__/viewer_ondeck_alert.test.jsx +++ b/web-mobile/js/__tests__/viewer_ondeck_alert.test.jsx @@ -48,6 +48,14 @@ describe('isFollowedMatchOnDeck', () => { expect(isFollowedMatchOnDeck({ status: 'scheduled', queuePosition: 'abc' })).toBe(false); expect(isFollowedMatchOnDeck({ status: 'scheduled', queuePosition: null })).toBe(false); }); + + // bc-tmfn: a barred scheduled match (ineligible_match.jsx -- its + // competitor withdrew earlier) is never on deck, whatever queuePosition + // reads. queuePosition: 1 is a realistic transient (client state before + // patch.jsx's recompute catches up), not just a defensive extra. + it('returns false for a barred scheduled match even with queuePosition 1', () => { + expect(isFollowedMatchOnDeck({ status: 'scheduled', queuePosition: 1, ineligibleSides: { a: 'kiken-voluntary' } })).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx b/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx index d5f60a0d4..5f69539a3 100644 --- a/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx +++ b/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx @@ -7,12 +7,18 @@ // that parsing. import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { bracketRoundLabel } from '../bracket.jsx'; import { downstreamKnockoutPlayedRefusal, downstreamKnockoutPlayedConfirm, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, + DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED, downstreamKnockoutReopenedNotice, + downstreamKnockoutRunningMessage, matchLabel, + courtBusyMessage, } from '../write_result.jsx'; describe('downstreamKnockoutPlayedRefusal', () => { @@ -84,6 +90,48 @@ describe('DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED', () => { }); }); +// The same refusal met by a REOPEN (Reopen match, Clear withdrawal and +// reopen): the operator is reopening a match, not applying a correction, so the +// dialog says what reopening does and the button names that act. api_client's +// reopenFailureError marks the refusal `reopen`. +describe('downstreamKnockoutPlayedConfirm for a reopen', () => { + it('says reopening this match reopens the later one, never "correction"', () => { + const { message, confirmLabel, danger } = downstreamKnockoutPlayedConfirm({ + blockingMatchId: 'm-r2-0', + blockingMatches: [{ id: 'm-r2-0', number: 3 }], + displaced: 'Suzuki Ichiro', + reopen: true, + }); + expect(message).toContain('Suzuki Ichiro already played Match 3'); + expect(message).toContain('Reopening this match also reopens Match 3'); + expect(message.toLowerCase()).toContain('cleared'); + expect(message.toLowerCase()).not.toContain('correction'); + expect(confirmLabel).toBe('Reopen both'); + expect(danger).toBe(true); + }); + + it('names every match in the plural and still never says "correction"', () => { + const { message, confirmLabel } = downstreamKnockoutPlayedConfirm({ + blockingMatchId: 'm-bronze', + blockingMatches: [{ id: 'm-bronze', number: 4 }, { id: 'm-r2-0', number: 3 }], + displaced: 'Suzuki Ichiro', + reopen: true, + }); + expect(message).toContain('Match 4'); + expect(message).toContain('Match 3'); + expect(message).toContain('Reopening this match also reopens both'); + expect(message.toLowerCase()).not.toContain('correction'); + expect(message).not.toContain('Suzuki Ichiro'); + expect(confirmLabel).toBe('Reopen all of them'); + }); + + it('has its own cancellation copy', () => { + expect(DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED).toMatch(/^Reopen cancelled/); + expect(DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED.toLowerCase()).not.toContain('correction'); + expect(DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED).not.toBe(DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED); + }); +}); + describe('downstreamKnockoutPlayedConfirm with two blocked matches', () => { // A semifinal feeds both the final and the bronze match. Both are cleared // by one confirmation, so the dialog has to NAME both: naming one while @@ -178,3 +226,215 @@ describe('matchLabel names the 3rd-place match', () => { expect(message).not.toContain('m-bronze'); }); }); + +// A POOL correction in a mixed competition that moves who holds a qualifying +// place: the server adds qualifierChange, and the dialog says who moves before +// it says which knockout match reopens. One paragraph, confirm "Apply and +// reopen". +describe('downstreamKnockoutPlayedConfirm with a qualifier change', () => { + const blocking = { blockingMatchId: 'm-r1-0', blockingMatches: [{ id: 'm-r1-0', number: 9 }] }; + + it('names the place, both occupants and the match fought with the old one', () => { + const { message, confirmLabel, danger } = downstreamKnockoutPlayedConfirm({ + ...blocking, + displaced: 'Aoki Taro', + qualifierChange: [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Aoki Taro', id: 'a' }, to: { name: 'Bob', id: 'b' } }], + }); + expect(message).toBe( + "Changing this result moves Pool A's 1st place from Aoki Taro to Bob, who takes Aoki Taro's place in the knockout. " + + 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and it must be fought again.', + ); + expect(message).not.toMatch(/\n/); + expect(confirmLabel).toBe('Apply and reopen'); + expect(danger).toBe(true); + }); + + it('lists every place a swap moves, and every match it reopens', () => { + const { message } = downstreamKnockoutPlayedConfirm({ + blockingMatchId: 'm-r1-0', + blockingMatches: [{ id: 'm-r1-0', number: 9 }, { id: 'm-r1-1', number: 10 }], + displaced: 'Aoki Taro', + qualifierChange: [ + { pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Aoki Taro' }, to: { name: 'Bob' } }, + { pool: 'Pool A', rank: 2, place: '2nd', from: { name: 'Bob' }, to: { name: 'Aoki Taro' } }, + ], + }); + expect(message).toBe( + "Changing this result changes who holds Pool A's 1st place (Aoki Taro to Bob) and Pool A's 2nd place (Bob to Aoki Taro), and the knockout is changed to match. " + + 'Match 9 and Match 10 were already fought with the competitors being replaced: they will be reopened, their results cleared, and they must be fought again.', + ); + }); + + it('a place left tied names no successor', () => { + const { message } = downstreamKnockoutPlayedConfirm({ + ...blocking, + displaced: 'Aoki Taro', + qualifierChange: [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Aoki Taro' }, to: { name: '' }, tied: true }], + }); + expect(message).toBe( + "Changing this result leaves Pool A's 1st place tied, to be settled by a tie-break, so Aoki Taro no longer holds it in the knockout. " + + 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and fought once the tie-break decides the place.', + ); + }); + + // A reopened match on a tied place cannot be fought until the tie-break + // has decided who holds that place, so the copy must not promise a + // straight re-fight. Several tied places, and a mix of moved and tied. + it('matches on tied places wait for the tie-break', () => { + const allTied = downstreamKnockoutPlayedConfirm({ + blockingMatchId: 'm-r1-0', + blockingMatches: [{ id: 'm-r1-0', number: 9 }, { id: 'm-r1-1', number: 10 }], + qualifierChange: [ + { pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Aoki Taro' }, to: { name: '' }, tied: true }, + { pool: 'Pool A', rank: 2, place: '2nd', from: { name: 'Bob' }, to: { name: '' }, tied: true }, + ], + }); + expect(allTied.message).toContain( + 'Match 9 and Match 10 were already fought with the competitors being replaced: they will be reopened, their results cleared, and fought once the tie-break decides the places.', + ); + expect(allTied.message).not.toContain('must be fought again'); + + const mixed = downstreamKnockoutPlayedConfirm({ + ...blocking, + displaced: 'Aoki Taro', + qualifierChange: [ + { pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Aoki Taro' }, to: { name: 'Bob' } }, + { pool: 'Pool A', rank: 2, place: '2nd', from: { name: 'Bob' }, to: { name: '' }, tied: true }, + ], + }); + expect(mixed.message).toContain( + 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and fought again; a match on a tied place waits for the tie-break to decide it.', + ); + }); + + // A rank recorded by hand (chusen) corrects no result, so the dialog + // does not say it does. + it('a ranking refusal leads with the ranking, not a result', () => { + const { message, confirmLabel } = downstreamKnockoutPlayedConfirm({ + ...blocking, + displaced: 'Aoki Taro', + ranking: true, + qualifierChange: [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Aoki Taro' }, to: { name: 'Bob' } }], + }); + expect(message).toBe( + "Recording this ranking moves Pool A's 1st place from Aoki Taro to Bob, who takes Aoki Taro's place in the knockout. " + + 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and it must be fought again.', + ); + expect(confirmLabel).toBe('Apply and reopen'); + }); + + it('an empty qualifierChange is a knockout correction and keeps its own copy', () => { + const { message, confirmLabel } = downstreamKnockoutPlayedConfirm({ ...blocking, displaced: 'Aoki Taro', qualifierChange: [] }); + expect(message).toContain('Aoki Taro already played Match 9'); + expect(confirmLabel).toBe('Apply correction and reopen'); + }); +}); + +// The terminal refusal: a knockout match the move reaches is being fought now. +describe('downstreamKnockoutRunningMessage', () => { + it('names the match and the way out', () => { + expect(downstreamKnockoutRunningMessage([{ id: 'm-r1-0', number: 9 }])) + .toBe('Match 9 is being fought now. Finish it or send it back to the queue, then save again.'); + }); + it('names several in the plural', () => { + expect(downstreamKnockoutRunningMessage([{ id: 'm-r1-0', number: 9 }, { id: 'm-bronze', number: 0 }])) + .toBe('Match 9 and the 3rd-place match are being fought now. Finish them or send them back to the queue, then save again.'); + }); + it('never prints an empty subject', () => { + expect(downstreamKnockoutRunningMessage(undefined)) + .toBe('A knockout match is being fought now. Finish it or send it back to the queue, then save again.'); + }); +}); + +// bc-rawm: the 409 court_busy sentence for a score write, built client-side +// from the server's `court` + `label` rather than echoed from the server's +// own `message` (api_client.jsx's recordScore, and reopenFailureError's +// reuse of the same shape). +describe('courtBusyMessage', () => { + it('names the shiaijo and the blocking match by its operator label', () => { + expect(courtBusyMessage({ court: 'A', label: 'Pool A · Match 2' })) + .toBe('Shiaijo A is running Pool A · Match 2. Finish it or send it back to the queue first.'); + }); + // bc-cse: no "This shiaijo"/"another match" fallback -- respondCourtBusy + // (handlers_match.go) always sends both `court` and a `label` + // (matchLabelOrID falls back to the raw match id rather than omitting + // it), so the two fallback strings were dead code and were removed. +}); + +// UAT (bc-tmfn): correcting "Pool A · Match 1" raised a dialog about "Match 1" +// -- the first KNOCKOUT match -- which read as the very match on screen, since +// pool matches are numbered from 1 inside each pool. The server now names each +// knockout match with its round ("Match 1 (Semifinals)", engine.MatchLabel) +// and sends that as `label`; every piece of copy that names a knockout match +// prints it as it is, so the dialog, the refusal and the reopened notice all +// say what the server's own message says. +describe('a server-named knockout match keeps its round in every message', () => { + const semi = { id: 'm-r2-0', number: 1, label: 'Match 1 (Semifinals)' }; + + it('matchLabel prints the label rather than composing its own', () => { + expect(matchLabel(semi)).toBe('Match 1 (Semifinals)'); + // A match the client names itself (no label) keeps the number form. + expect(matchLabel({ id: 'm-r2-0', number: 1 })).toBe('Match 1'); + }); + + it('the qualifier-move confirm cannot be read as the pool match being corrected', () => { + const { message } = downstreamKnockoutPlayedConfirm({ + blockingMatchId: semi.id, + blockingMatches: [semi], + displaced: 'Goto Ken', + qualifierChange: [{ pool: 'Pool A', rank: 1, place: '1st', from: { name: 'Goto Ken' }, to: { name: 'Ito Dai' } }], + }); + expect(message).toContain('Match 1 (Semifinals) was already fought with Goto Ken'); + expect(message).not.toMatch(/Match 1 was/); + }); + + it('the knockout-correction confirm names the round too', () => { + const { message } = downstreamKnockoutPlayedConfirm({ + blockingMatchId: 'm-r3-0', + blockingMatches: [{ id: 'm-r3-0', number: 3, label: 'Match 3 (Final)' }], + displaced: 'Goto Ken', + }); + expect(message).toContain('Goto Ken already played Match 3 (Final)'); + expect(message).toContain('reopens Match 3 (Final) for re-entry'); + }); + + it('the reopened notice and the running refusal name it the same way', () => { + expect(downstreamKnockoutReopenedNotice([semi])) + .toBe('Match 1 (Semifinals) was reopened: it must be fought and scored again.'); + expect(downstreamKnockoutRunningMessage([semi])) + .toBe('Match 1 (Semifinals) is being fought now. Finish it or send it back to the queue, then save again.'); + // A bracket saved before rounds were recorded is named "knockout Match + // N" by the server; the sentence still starts with a capital. + expect(downstreamKnockoutRunningMessage([{ id: 'm-r1-0', number: 1, label: 'knockout Match 1' }])) + .toBe('Knockout Match 1 is being fought now. Finish it or send it back to the queue, then save again.'); + }); +}); + +// The round a dialog names ("Match 3 (Semifinals)") must be the heading of the +// column the operator finds that match under. The server spells it +// (internal/engine/errors.go roundLabelFromEnd) and so does the bracket +// (bracket.jsx roundLabelFromEnd, via bracketRoundLabel); both are driven over +// internal/engine/testdata/round_labels.json. Go half: +// TestRoundLabelFromEnd_GoldenTable. +describe('round label Go/JS mirror', () => { + const table = JSON.parse( + readFileSync( + resolve(__dirname, '..', '..', '..', 'internal', 'engine', 'testdata', 'round_labels.json'), + 'utf8', + ), + ); + + it('the shared golden table is present and non-empty', () => { + expect( + table.cases?.length, + 'internal/engine/testdata/round_labels.json parsed to zero cases: the mirror would assert nothing', + ).toBeGreaterThan(0); + }); + + it.each(table.cases)('displayRound $displayRound is "$label"', ({ displayRound, label }) => { + expect( + bracketRoundLabel({ displayRound }, 0, 0), + 'JS round name disagrees with the shared table; update BOTH spellings, not just this one', + ).toBe(label); + }); +}); diff --git a/web-mobile/js/admin_competition.jsx b/web-mobile/js/admin_competition.jsx index 2b8fca63e..4bcc260ee 100644 --- a/web-mobile/js/admin_competition.jsx +++ b/web-mobile/js/admin_competition.jsx @@ -342,7 +342,11 @@ function AdminCompetition({ tournament, competition, pools, poolMatches, standin return (
-
+ {/* 1400px keeps every section's lines readable, except the Bracket's: + a tree needs 180px per round, so a 33 to 64 draw (six rounds) is + wider than the capped page leaves it and the page takes the whole + window there instead (operator decision). */} +
onSection("overview") }, diff --git a/web-mobile/js/admin_competition_bracket.jsx b/web-mobile/js/admin_competition_bracket.jsx index 8b2409028..7dd49af9b 100644 --- a/web-mobile/js/admin_competition_bracket.jsx +++ b/web-mobile/js/admin_competition_bracket.jsx @@ -150,7 +150,13 @@ const RunningMatchPanel = React.memo(({ match, compId, courts, matchNum, onMoveC // place. Remounting on every bout-log change also DISCARDED the // operator's unsaved scores — on a kachinuki encounter that is every // appended pairing — which is the harm, not the fix. - key={`${match.id}:${match.status}`} + // Nor the status (bc-tmfn), for the same reason: the editor + // follows a status change in place, as it does on the Scores tab + // and the court console, whose keys never carried one. Remounting + // on completed -> running threw away what a Clear withdrawal and + // reopen came back with (its notice naming a later match it also + // reopened) at the moment the operator goes on scoring here. + key={match.id} variant="inline" match={match} // Offer "close" only when there's a result card to fall back to @@ -188,8 +194,20 @@ RunningMatchPanel.displayName = "RunningMatchPanel"; function AdminBracket({ c, t, bracket, onMoveCourt, onEditScore, tweaks, password }) { const [selected, setSelected] = useStateA(null); const scrollRef = useRefA(null); + const panelRef = useRefA(null); const [autoScrollId, setAutoScrollId] = useStateA(null); + // The scoring panel sits BELOW the bracket (operator decision), so picking a + // match brings it into view. Keyed on the picked match's id, so a re-render + // of the same pick (an SSE update) does not scroll the page back to it. + const selectedId = selected?.matchId; + useEffectA(() => { + if (!selectedId || !panelRef.current) return; + const reduce = typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + panelRef.current.scrollIntoView({ block: "start", behavior: reduce ? "auto" : "smooth" }); + }, [selectedId]); + // Recenter on the running match whenever it changes (initial bracket // load, or one match finishing and the next starting). Empty deps would // miss the case where `bracket` is still null on first mount and only @@ -295,10 +313,10 @@ function AdminBracket({ c, t, bracket, onMoveCourt, onEditScore, tweaks, passwor // bye-containing brackets legitimately have. const hasUnseededPools = bracket.rounds.some(r => (r || []).some(m => hasPoolOriginPlaceholder(m))); return ( - // Flex-wrap rather than a fixed 2-col grid (see .bracket-layout in - // styles.css): the scoring panel keeps a minimum width so the reused inline - // editor never squashes/clips, and when the bracket + that min width can't - // sit side by side, the panel wraps to a full-width row BELOW the bracket. + // The bracket takes the full width and the scoring panel sits below it + // (operator decision; see .bracket-layout in styles.css): beside it, the + // panel left a four-round tree too little width at any desktop size. The + // tree's columns narrow to fit (.bracket-canvas--fit).
{hasUnseededPools && ( @@ -306,7 +324,7 @@ function AdminBracket({ c, t, bracket, onMoveCourt, onEditScore, tweaks, passwor Knockout filling in: this bracket fills in automatically as each pool finishes. Matches start once both sides are decided.
)} -
+
-
- 3rd Place Match +
+ {Array.from({ length: Math.max(0, slots - 1) }, (_, i) => ( + // Empty column slots, never reordered: the index is the key. + // oxlint-disable-next-line react/no-array-index-key + ); })()}
-
+
{hasBothSides(selectedMatch) ? ( 0); + const chusenOpen = !!c && (c.status === "pools" || (c.format === "mixed" && c.status === "knockout")); const [chusenCandidates, setChusenCandidates] = useStateA(null); // Per-member input values: keys are "${groupKey}::${identity}" -> string, // where groupKey is "${poolName}::${minPosition}" and identity is // checkinPid(member) (id when non-empty, else "name|dojo") -- never - // the member's index in the group, which reorders after a partial write - // (bc-appx item 2), and never the bare display name, which two members + // the member's index in the group, whose order is the server's standings + // order and can change between fetches (bc-appx item 2), and never the bare display name, which two members // can share. const [chusenInputs, setChusenInputs] = useStateA({}); // Per-group busy flag: keyed by groupKey "${poolName}::${minPosition}" -> bool @@ -195,6 +205,16 @@ function AdminPools({ c, pools, poolMatches, standings, tweaks, onEditScore, pas const [chusenBusy, setChusenBusy] = useStateA({}); // Per-group error: keyed by the same "${poolName}::${minPosition}" -> string. const [chusenGroupErr, setChusenGroupErr] = useStateA({}); + // Ties a chusen already settled, each carrying its recorded `ranks` + // (parallel to `teams`), so one recorded in the wrong order can be changed. + // Fetched with chusenCandidates, so it shows exactly where they would. + const [chusenRecorded, setChusenRecorded] = useStateA(null); + // groupKey -> true while the operator is changing that recorded chusen. + const [chusenChanging, setChusenChanging] = useStateA({}); + const applyChusen = (res) => { + setChusenCandidates(res ? res.candidates : null); + setChusenRecorded(res ? res.recorded : null); + }; // Lightweight signature so the effect re-runs when match results change. // Memoized so typing into a chusen position input (local state) does not @@ -215,16 +235,16 @@ function AdminPools({ c, pools, poolMatches, standings, tweaks, onEditScore, pas ); useEffectA(() => { - if (!isTeamComp || !c || c.status !== "pools" || !window.API || typeof window.API.chusenCandidates !== "function") { - setChusenCandidates(null); + if (!isTeamComp || !chusenOpen || !window.API || typeof window.API.chusenCandidates !== "function") { + applyChusen(null); return; } let cancelled = false; window.API.chusenCandidates(c.id, password) - .then(list => { if (!cancelled) setChusenCandidates(list); }) - .catch(() => { if (!cancelled) setChusenCandidates(null); }); + .then(res => { if (!cancelled) applyChusen(res); }) + .catch(() => { if (!cancelled) applyChusen(null); }); return () => { cancelled = true; }; - }, [c && c.id, c && c.status, isTeamComp, poolMatchesSig, standingsSig, password]); + }, [c && c.id, c && c.status, chusenOpen, isTeamComp, poolMatchesSig, standingsSig, password]); // Fetch candidates whenever poolMatches changes (triggered by match_updated // SSE events, which the Go handler now broadcasts for AwaitingLeagueTiebreak). @@ -315,8 +335,236 @@ function AdminPools({ c, pools, poolMatches, standings, tweaks, onEditScore, pas }); }; - // Chusen banner: shown when chusenCandidates is non-empty (team comp in - // pools stage, at least one DH cycle left unresolved). + // chusenEntry renders one tie's position entry: the team inputs, their + // validation and the overridePoolRanks write. The pending banner uses it for + // a first chusen; the recorded panel reuses it (changing=true) to change a + // chusen recorded in the wrong order, so both go through the same path and + // the same knockout warning. + const chusenEntry = (group, changing = false) => { + const { poolName, teamNames, minPosition } = group; + // Members keyed by IDENTITY (checkinPid), never by name + // (bc-cse follow-up) and never by index (bc-appx item 2): `teams` + // carries the authoritative per-member identity ({id, name, dojo}), + // positionally parallel to the legacy `teamNames` strings (server: + // handlers_competition.go builds teams[i] and names[i] from the same + // loop over g.Teams). Two members CAN share a display name -- + // reachable only via the documented enforcement hole in team-name + // uniqueness (an unreadable config.md write skips + // checkNewTeamNameCollisions) -- so keying anything by bare name + // collapses both onto one identity. `teams` is required on the wire + // (this SPA ships in the same binary as the server that emits it, + // so there is no older-server case to be compatible with); a + // teamNames-only fallback would silently collapse a same-name pair + // back onto one key, exactly the bug this comment used to guard + // against, so there is deliberately no fallback here. A payload + // without it is a server bug, and skipping the group keeps that + // contained to this banner rather than tripping the page-level + // error boundary for every other pool on the screen. + const members = group.teams; + if (!members) return null; + // A pool can hold more than one unresolved tied group (e.g. a cycle at + // 1st/2nd and a separate cycle at 3rd/4th). Key by pool + best position + // so the React key and the busy/error maps never collide across groups. + const groupKey = `${poolName}::${minPosition}`; + const isBusy = !!chusenBusy[groupKey]; + const groupErrMsg = chusenGroupErr[groupKey] || null; + // overridePoolRanks requires playerId (operator ruling bc-pnum: the + // server resolves a pool member by id only and 400s outright + // without one). Disabling the button here, with NoIdHint's remedy, + // replaces letting the operator click through to that 400. Mirrors + // the league-tiebreak buttons' own idsMissing gate below. + const idsMissing = members.some(m => !m.id); + + // Effective value for a member's input, keyed by the member's + // IDENTITY (checkinPid; data.jsx owns id-vs-name|dojo fallback rule), + // never its position in `members`: the group order comes from the + // server's standings sort, which can change between fetches (any + // write to the pool), so an index-keyed lookup can read back a + // DIFFERENT team's typed value on retry. The operator's edit if present, else the + // displayed default (minPosition + index -- + // idx is still used here only to pick a distinct default rank per + // position, not to key the input). Both validation and submit read + // this so accepting the shown defaults (already a valid permutation) + // records without forcing a manual edit. Keyed on groupKey (pool + + // minPosition) as well as identity, not bare poolName: a pool can hold + // more than one unresolved tied group (see the groupKey comment + // above), and PoolWinners has no upper bound, so e.g. a cycle at + // 1st/2nd and another at 3rd/4th in the SAME pool must not share one + // input/clear per member. + // A recorded chusen being changed starts from the ranks it was + // recorded with (group.ranks, parallel to members), so Change opens + // on the order the operator is correcting. + const defaultRank = (idx) => (group.ranks ? group.ranks[idx] : minPosition + idx); + const effRank = (member, idx) => { + const raw = chusenInputs[`${groupKey}::${checkinPid(member)}`]; + return parseInt(raw !== undefined ? raw : String(defaultRank(idx)), 10); + }; + const clearInputs = () => setChusenInputs(prev => { + const next = { ...prev }; + for (let i = 0; i < members.length; i++) delete next[`${groupKey}::${checkinPid(members[i])}`]; + return next; + }); + + const handleRecord = async () => { + // Validate: entered positions must be exactly the set + // {minPosition .. minPosition + members.length - 1}. + const expected = new Set(); + for (let i = 0; i < members.length; i++) expected.add(minPosition + i); + const entered = new Set(); + let valid = true; + for (let i = 0; i < members.length; i++) { + const val = effRank(members[i], i); + if (isNaN(val) || !expected.has(val) || entered.has(val)) { valid = false; break; } + entered.add(val); + } + if (!valid) { + const lo = minPosition; + const hi = minPosition + members.length - 1; + setChusenGroupErr(prev => ({ ...prev, [groupKey]: `Enter each of positions ${lo} to ${hi} exactly once` })); + return; + } + setChusenGroupErr(prev => ({ ...prev, [groupKey]: null })); + setChusenBusy(prev => ({ ...prev, [groupKey]: true })); + try { + // The whole order is ONE write, so the server answers for the order + // entered and nothing else: sent one rank at a time, it passed + // through orders nobody chose, and a knockout match whose place the + // final order keeps was named and reopened. An order that moves a + // qualifier out of a knockout match the old one already fought is + // refused with the names of who moves; attemptScoreWrite asks the + // operator and, confirmed, resends it with forceDownstreamReopen, + // which reopens that match. + await attemptScoreWrite({ + recordScore: (cId, pool, body, pwd) => window.API.overridePoolRanks(cId, pool, body.ranks, pwd, !!body.forceDownstreamReopen), + confirmDialog: window.confirmDialog, + compId: c.id, matchId: poolName, + result: { ranks: members.map((m, i) => ({ playerId: m.id, rank: effRank(m, i) })) }, + password, + }); + if (changing) { + // Show the new order at once and close the entry; the re-fetch + // the new standings trigger reconciles it. + const ranks = members.map((m, i) => effRank(m, i)); + setChusenRecorded(prev => (prev || []).map(g => (g.poolName === poolName && g.minPosition === minPosition ? { ...g, ranks } : g))); + setChusenChanging(prev => ({ ...prev, [groupKey]: false })); + } else { + // Optimistically hide THIS group only (a pool can hold several) - the + // effect re-fetches on the next update to reconcile. + setChusenCandidates(prev => (prev || []).filter(g => !(g.poolName === poolName && g.minPosition === minPosition))); + } + // Clear inputs for this group only (groupKey + identity, not bare + // poolName -- see the effRank comment: a sibling tied group in the + // same pool must not have its inputs wiped here too). + clearInputs(); + } catch (e) { + const msg = e && e.downstreamKnockoutPlayedCancelled + ? DOWNSTREAM_KNOCKOUT_RANKING_CANCELLED + : (e.message || "Failed to record chusen result"); + setChusenGroupErr(prev => ({ ...prev, [groupKey]: msg })); + // A refusal records none of the group, and a lost response may have + // recorded all of it. Re-fetch the candidates so the panel shows the + // order the server holds rather than waiting for the next SSE-driven + // refresh; the typed ranks are keyed by team, so they survive a + // reorder. + if (window.API && typeof window.API.chusenCandidates === "function") { + window.API.chusenCandidates(c.id, password) + .then(applyChusen) + .catch(() => {}); + } + } finally { + setChusenBusy(prev => ({ ...prev, [groupKey]: false })); + } + }; + + return ( +
+
+ {poolName} + {teamNames.join(" · ")} +
+ {changing && ( +
+ Enter the order the lots actually gave. Changing it can change who qualifies from {poolName}. If a team moves out of a knockout match that has already been fought, you are asked before that match is reopened. +
+ )} +
+ Assign positions {minPosition} to {minPosition + members.length - 1} (one per team): +
+ {members.map((member, idx) => { + // groupKey + member IDENTITY, not index: see the effRank/ + // checkinPid comments above -- the member array order is + // not stable across a re-fetch, so an index-keyed input can + // silently attach to the WRONG team after a failed write's + // re-fetch. + const memberKey = checkinPid(member); + const inputKey = `${groupKey}::${memberKey}`; + const defaultVal = defaultRank(idx); + // Stable DOM id so the label is programmatically tied to its + // input. `idx` here, NOT memberKey: memberKey can be a + // non-ASCII name|dojo string (Japanese names are the normal + // case for this roster), and the regex below collapses every + // non-ASCII run to a single "-", so two id-less members whose + // keys differ only in non-ASCII characters collided on the + // SAME DOM id (duplicate ids, and the label's htmlFor focused + // the other team's input). idx is unique within this group's + // render (label and input come from the same map iteration), + // which is all a DOM id needs -- unlike inputKey/memberKey + // above, it does not need to survive a re-fetch reorder. + const inputId = `chusen-${groupKey}-${idx}`.replace(/[^a-zA-Z0-9_-]+/g, "-"); + return ( +
+ + setChusenInputs(prev => ({ ...prev, [inputKey]: e.target.value }))} + disabled={isBusy} + /> +
+ ); + })} +
+ + {changing && ( + + )} + {idsMissing && ( + + )} +
+ {groupErrMsg && ( +
{groupErrMsg}
+ )} +
+ ); + }; + + // Chusen banner: shown when chusenCandidates is non-empty (team comp whose + // pool order is still open, chusenOpen above; at least one DH cycle left + // unresolved). const chusenBanner = chusenCandidates && chusenCandidates.length > 0 ? (
The daihyosen didn't settle the order (two or more teams tied on daihyosen wins). Draw lots and record each team's finishing position below.
- {chusenCandidates.map((group) => { - const { poolName, teamNames, minPosition } = group; - // Members keyed by IDENTITY (checkinPid), never by name - // (bc-cse follow-up) and never by index (bc-appx item 2): `teams` - // carries the authoritative per-member identity ({id, name, dojo}), - // positionally parallel to the legacy `teamNames` strings (server: - // handlers_competition.go builds teams[i] and names[i] from the same - // loop over g.Teams). Two members CAN share a display name -- - // reachable only via the documented enforcement hole in team-name - // uniqueness (an unreadable config.md write skips - // checkNewTeamNameCollisions) -- so keying anything by bare name - // collapses both onto one identity. `teams` is required on the wire - // (this SPA ships in the same binary as the server that emits it, - // so there is no older-server case to be compatible with); a - // teamNames-only fallback would silently collapse a same-name pair - // back onto one key, exactly the bug this comment used to guard - // against, so there is deliberately no fallback here. A payload - // without it is a server bug, and skipping the group keeps that - // contained to this banner rather than tripping the page-level - // error boundary for every other pool on the screen. - const members = group.teams; - if (!members) return null; - // A pool can hold more than one unresolved tied group (e.g. a cycle at - // 1st/2nd and a separate cycle at 3rd/4th). Key by pool + best position - // so the React key and the busy/error maps never collide across groups. - const groupKey = `${poolName}::${minPosition}`; - const isBusy = !!chusenBusy[groupKey]; - const groupErrMsg = chusenGroupErr[groupKey] || null; - // overridePoolRank requires playerId (operator ruling bc-pnum: the - // server resolves a pool member by id only and 400s outright - // without one). Disabling the button here, with NoIdHint's remedy, - // replaces letting the operator click through to that 400. Mirrors - // the league-tiebreak buttons' own idsMissing gate below. - const idsMissing = members.some(m => !m.id); - - // Effective value for a member's input, keyed by the member's - // IDENTITY (checkinPid; data.jsx owns id-vs-name|dojo fallback rule), - // never its position in `members`: the group order comes from the - // server's live standings sort, which reorders after a partial - // write, so an index-keyed lookup can read back a DIFFERENT team's - // typed value on retry. The operator's edit if present, else the - // displayed default (minPosition + index -- - // idx is still used here only to pick a distinct default rank per - // position, not to key the input). Both validation and submit read - // this so accepting the shown defaults (already a valid permutation) - // records without forcing a manual edit. Keyed on groupKey (pool + - // minPosition) as well as identity, not bare poolName: a pool can hold - // more than one unresolved tied group (see the groupKey comment - // above), and PoolWinners has no upper bound, so e.g. a cycle at - // 1st/2nd and another at 3rd/4th in the SAME pool must not share one - // input/clear per member. - const effRank = (member, idx) => { - const raw = chusenInputs[`${groupKey}::${checkinPid(member)}`]; - return parseInt(raw !== undefined ? raw : String(minPosition + idx), 10); - }; - - const handleRecord = async () => { - // Validate: entered positions must be exactly the set - // {minPosition .. minPosition + members.length - 1}. - const expected = new Set(); - for (let i = 0; i < members.length; i++) expected.add(minPosition + i); - const entered = new Set(); - let valid = true; - for (let i = 0; i < members.length; i++) { - const val = effRank(members[i], i); - if (isNaN(val) || !expected.has(val) || entered.has(val)) { valid = false; break; } - entered.add(val); - } - if (!valid) { - const lo = minPosition; - const hi = minPosition + members.length - 1; - setChusenGroupErr(prev => ({ ...prev, [groupKey]: `Enter each of positions ${lo} to ${hi} exactly once` })); - return; - } - setChusenGroupErr(prev => ({ ...prev, [groupKey]: null })); - setChusenBusy(prev => ({ ...prev, [groupKey]: true })); - try { - for (let i = 0; i < members.length; i++) { - const member = members[i]; - await window.API.overridePoolRank(c.id, poolName, member.name, effRank(member, i), password, member.id); - } - // Optimistically hide THIS group only (a pool can hold several) - the - // effect re-fetches on the next update to reconcile. - setChusenCandidates(prev => (prev || []).filter(g => !(g.poolName === poolName && g.minPosition === minPosition))); - // Clear inputs for this group only (groupKey + identity, not bare - // poolName -- see the effRank comment: a sibling tied group in the - // same pool must not have its inputs wiped here too). - setChusenInputs(prev => { - const next = { ...prev }; - for (let i = 0; i < members.length; i++) delete next[`${groupKey}::${checkinPid(members[i])}`]; - return next; - }); - } catch (e) { - setChusenGroupErr(prev => ({ ...prev, [groupKey]: e.message || "Failed to record chusen result" })); - // The per-member overridePoolRank writes are sequential, so a mid-loop - // failure may have persisted some ranks but not others. overridePoolRank - // is idempotent per member (retrying re-sends every rank), and the group - // stays visible on failure so the operator can retry. Re-fetch the - // candidates so the banner reflects exactly which teams still need a - // rank, rather than waiting for the next SSE-driven refresh. - if (window.API && typeof window.API.chusenCandidates === "function") { - window.API.chusenCandidates(c.id, password) - .then(list => setChusenCandidates(list)) - .catch(() => {}); - } - } finally { - setChusenBusy(prev => ({ ...prev, [groupKey]: false })); - } - }; + {chusenCandidates.map(group => chusenEntry(group))} +
+ ) : null; + // Recorded chusen: each tie a chusen already settled, shown in the recorded + // order with a Change control, because a chusen recorded in the wrong order + // must stay fixable. Only these ties are editable: the general rank override + // was removed on purpose. Change reopens chusenEntry pre-filled with the + // recorded ranks. + const chusenRecordedPanel = chusenRecorded && chusenRecorded.length > 0 ? ( +
+
Chusen (drawing lots) recorded
+ {chusenRecorded.map((group) => { + const groupKey = `${group.poolName}::${group.minPosition}`; + if (!group.teams || !group.ranks) return null; + if (chusenChanging[groupKey]) return chusenEntry(group, true); + const order = group.teams + .map((t, i) => ({ name: t.name, rank: group.ranks[i] })) + .sort((a, b) => a.rank - b.rank) + .map(o => `${rankOrdinal(o.rank)} ${o.name}`) + .join(", "); return (
- {poolName} - {teamNames.join(" · ")} + {group.poolName} + Drawing lots: {order}
-
- Assign positions {minPosition} to {minPosition + members.length - 1} (one per team): -
- {members.map((member, idx) => { - // groupKey + member IDENTITY, not index: see the effRank/ - // checkinPid comments above -- the member array order is - // not stable across a re-fetch, so an index-keyed input can - // silently attach to the WRONG team after a mid-loop failure. - const memberKey = checkinPid(member); - const inputKey = `${groupKey}::${memberKey}`; - const defaultVal = minPosition + idx; - // Stable DOM id so the label is programmatically tied to its - // input. `idx` here, NOT memberKey: memberKey can be a - // non-ASCII name|dojo string (Japanese names are the normal - // case for this roster), and the regex below collapses every - // non-ASCII run to a single "-", so two id-less members whose - // keys differ only in non-ASCII characters collided on the - // SAME DOM id (duplicate ids, and the label's htmlFor focused - // the other team's input). idx is unique within this group's - // render (label and input come from the same map iteration), - // which is all a DOM id needs -- unlike inputKey/memberKey - // above, it does not need to survive a re-fetch reorder. - const inputId = `chusen-${groupKey}-${idx}`.replace(/[^a-zA-Z0-9_-]+/g, "-"); - return ( -
- - setChusenInputs(prev => ({ ...prev, [inputKey]: e.target.value }))} - disabled={isBusy} - /> -
- ); - })} -
+
- {idsMissing && ( - - )}
- {groupErrMsg && ( -
{groupErrMsg}
- )}
); })} @@ -641,6 +754,7 @@ function AdminPools({ c, pools, poolMatches, standings, tweaks, onEditScore, pas <>
{chusenBanner} + {chusenRecordedPanel} {leagueTiebreakBanner} {/* Standings ordering is decided by FORMAT (mp-ahu6): pools always draw-order via PoolsViewer, leagues always rank-order via diff --git a/web-mobile/js/admin_schedule_lineup.jsx b/web-mobile/js/admin_schedule_lineup.jsx index 16b39d30f..9755dd9a9 100644 --- a/web-mobile/js/admin_schedule_lineup.jsx +++ b/web-mobile/js/admin_schedule_lineup.jsx @@ -513,12 +513,23 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, return (
-
+
{teamName} {isMatchOverride ? Override for this match : Inheriting round default } +
{error && ( @@ -613,16 +624,6 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, > {saving ? "Saving…" : "Save lineup"} -
); diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx index 7f8e544f7..d55a95567 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -1,17 +1,26 @@ // Score editor components extracted from admin_schedule.jsx (mp-d7tl). // startPatch, ScoreEditCourtBtn (local), AdminScoreEditor, AdminScoreEditorPage. -import { writeDidNotLand, matchLabel } from './write_result.jsx'; +import { writeDidNotLand } from './write_result.jsx'; import { matchMentions } from './competitor_search.jsx'; import { SideCell } from './side_cell.jsx'; import { allMatchesCompleted } from './admin_schedule_utils.jsx'; import { MatchLineupPanel } from './admin_schedule_lineup.jsx'; -import { boutHansokuMark } from './match_scoreboard.jsx'; +import { boutHansokuMark, teamNameMark } from './match_scoreboard.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; -import { poolMatchNumberOf } from './pool_ids.jsx'; +import { scoreRowMatchLabel } from './pool_ids.jsx'; // NumberedName: single owner of the number-chip-on-the-outer-side rule // (bc-dnst); see that file's header for why this stays an ES import. import { NumberedName } from './numbered_name.jsx'; +// bc-cse: a scheduled match a competitor is barred from cannot be fought, so +// no auto-pick may offer it and its row shows the default-win action +// instead. One leaf owns the question (ineligible_match.jsx); BarredMatchNotice +// (admin_scoring_shared.jsx) is the one component that renders it everywhere. +import { isBarredMatch } from './ineligible_match.jsx'; +// Straight from its own leaf, not admin_scoring_shared.jsx (which also +// imports bracket.jsx): see barred_match_notice.jsx's header for why that +// matters for this file's own render suite. +import { BarredMatchNotice } from './barred_match_notice.jsx'; const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA } = React; @@ -28,44 +37,14 @@ const ScoreEditorModal = window.ScoreEditorModal; const hasBothSides = window.hasBothSides; const getScoreBtnClass = window.getScoreBtnClass; -// scoreRowMatchLabel: how the scores list names a match to the operator. -// -// EVERY row carries an identity, because this list is where an operator lands -// after a dialog names a match ("Match 15 was reopened"), and a row identified -// only by its time and its two competitors cannot be found that way. -// -// The two phases number independently, so the label says which numbering it -// is quoting. A knockout match owns a number across the whole tree, the same -// one the printed tree and the correction dialogs use, so it reads bare: -// "Match 15". A pool bout is numbered inside its own pool and the numbering -// restarts per pool (operator ruling 2026-09-19), so its pool is named with -// it: "Pool A · Match 2". Without that prefix the two would collide, since -// every pool has a Match 1 and so does the bracket. -// -// The pool's own name comes from window.poolLabel (viewer_utils.jsx), the one -// owner of the pool-vs-league-vs-Swiss heading, so a Swiss round reads -// "Round 3 · Match 2" rather than the synthetic "Swiss-R3" id. -// -// A knockout row is named by matchLabel (write_result.jsx), the same owner the -// correction dialogs use, so the row an operator is sent to looking for -// "Match 15" -- or for "the 3rd-place match", the one match named rather than -// numbered -- carries exactly the words they were given. -// -// Returns "" when the match carries no number at all: a bracket match drawn -// before numbering existed, and a pool supplementary bout (daihyosen or -// tiebreaker), which is an appended rep bout rather than one of the pool's -// numbered round-robin bouts. -export function scoreRowMatchLabel(m) { - if (m.phase === "bracket") { - const label = matchLabel({ number: m.matchNumber, id: m.id }); - // matchLabel falls back to the raw id, which names nothing on screen. - return label === m.id ? "" : label; - } - const n = poolMatchNumberOf(m.id || ""); - if (!n) return ""; - const pool = (window.poolLabel ? window.poolLabel(m) : m.poolName) || ""; - return pool ? `${pool} · Match ${n}` : `Match ${n}`; -} +// scoreRowMatchLabel: relocated to pool_ids.jsx (bc-cse), which can serve +// admin_scoring_shared.jsx too without the cycle importing this file would +// create (this file -> admin_schedule_lineup.jsx -> admin_scoring_shared.jsx +// already exists). Re-exported here (of the LOCAL binding imported above, so +// the module is fetched once) so this file's own use below, and the +// existing `from './admin_schedule_score_editor.jsx'` test import, need no +// change; see pool_ids.jsx for the full rule. +export { scoreRowMatchLabel }; // ---------- Score editor ---------- export function AdminScoreEditorPage({ tournament, onBack, onEditScore, onMoveCourt, onLogout, onViewerMode, password }) { @@ -231,6 +210,15 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom const aWin = !!m.winner && !!m.sideA && sameCompetitor(m.winner, m.sideA); const bWin = !!m.winner && !!m.sideB && sameCompetitor(m.winner, m.sideB); const isCorrection = m.status === "completed" && m.score?.corrected; + // bc-tmfn: a TEAM row's score cell (window.matchScoreStr → + // teamIVPWScore) is deliberately free of marks, so the match-level + // Kiken/Fus. a default win closed a team match with rides beside + // the withdrawn team's NAME instead -- the ONE shared computation + // (window.teamMatchMarks, bracket.jsx), which derives its own + // team-row signal from m.subResults (bc-cse) so an individual + // match's own mark, already inline in its score string below, is + // never doubled here. + const { shiro: teamShiroMark, aka: teamAkaMark } = window.teamMatchMarks ? window.teamMatchMarks(m) : { shiro: "", aka: "" }; // Outstanding single hansoku → red ▲ next to the offending side (same // mark as the scoresheet). hansoku may live on the match or under // score.fouls depending on the source; fall back across both. @@ -263,7 +251,17 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom Mirrors PoolNumberedMatchRow (viewer_standings.jsx), which dropped its badge for the same tint. */} -
+ {/* bc-cse: msb-name--labelled -- see viewer_match.jsx's + VSchedItem comment for why a mark riding beside a + clip-mode name needs its own flex row: without it a + long name clips the mark off the end. justifyContent + here (not the msb-name--aka class, which is Aka's own) + because THIS row reads toward the centre score from + both sides -- Shiro right-aligned, Aka left-aligned -- + the opposite of most name cells; the parent's own + textAlign:"right" no longer reaches a flex child once + .name becomes the flex container. */} +
{teamNameMark("shiro", teamShiroMark, )}
{m.sideB?.dojo}
{/* Foul ▲ flanks the SCORE (Shiro left, Aka right): a hansoku is part of @@ -277,7 +275,12 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom {foulA && {foulA}}
-
+ {/* No msb-name--aka here: unlike most name cells this row + reads Aka LEFT-aligned (toward the centre score from + the right side), the opposite of that class's own + justify-content:flex-end/color:red base -- this host + already owns its own aka styling. */} +
{teamNameMark("aka", teamAkaMark, )}
{m.sideA?.dojo}
@@ -305,6 +308,15 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom )}
+ {/* bc-cse: a scheduled match a competitor is barred from cannot + be fought as scheduled; show why and the one-tap resolution + (BarredMatchNotice, admin_scoring_shared.jsx) on its own full- + width row rather than squeezed into the 5-column grid. */} + {isBarredMatch(m) && ( +
+ +
+ )}
); })} @@ -346,8 +358,11 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // modal to loop back to match 1 after the final save. // Guard on openIdx >= 0: when openMatch is not found in sameCourt (openIdx // === -1), slice(0) would scan the whole array and return a spurious match. + // bc-cse: skip a barred match (isBarredMatch, ineligible_match.jsx): + // Finish + Start Next and the after-decision advance both feed this + // straight into a Start write, which the server would just refuse. const nextActiveMatch = openIdx >= 0 - ? sameCourt.slice(openIdx + 1).find(m => m.status !== 'completed') || null + ? sameCourt.slice(openIdx + 1).find(m => m.status !== 'completed' && !isBarredMatch(m)) || null : null; // Minimal "start" patch (status → running, empty score). Mirrors the // modal's own buildPatch("running") for an unscored match and works for diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index aade22485..97aca333d 100644 --- a/web-mobile/js/admin_scoring_autosave.jsx +++ b/web-mobile/js/admin_scoring_autosave.jsx @@ -102,6 +102,24 @@ export function useDebouncedRunningWrite({ isRunningRef, buildPatchRef, onSubmit // Clear on unmount so the closure can't fire after the component is gone. useEffectA(() => () => { cancelDebounce(); }, []); + // The running write itself, shared by the debounce timer and flushPending so + // the two can never apply different gates. + const fireRunningWrite = () => { + if (!mountedRef.current) return; + // gate 3: re-check running at FIRE time. If the match was completed + // during the debounce window (this operator's Finish cancels the timer, + // but an SSE update or another operator can complete it out from under + // us), isRunningRef has flipped false on re-render: sending a + // status:"running" autosave now would regress the completed result. + if (!isRunningRef.current) return; + // Fire-and-forget: errors swallowed; operator's explicit Finish is + // the authoritative write. + try { + const p = onSubmitRef.current(buildPatchRef.current("running")); + if (p && typeof p.catch === "function") p.catch(() => {}); + } catch (_) { /* swallow */ } + }; + // markDirty: call from every user-driven mutation handler (addPt, // removePt, foul increment/decrement, draw toggle, encho change, team // sub-bout edits). Do NOT call from prop/SSE-driven state writes. @@ -110,21 +128,20 @@ export function useDebouncedRunningWrite({ isRunningRef, buildPatchRef, onSubmit cancelDebounce(); timerRef.current = setTimeout(() => { timerRef.current = null; - if (!mountedRef.current) return; - // gate 3: re-check running at FIRE time. If the match was completed - // during the debounce window (this operator's Finish cancels the timer, - // but an SSE update or another operator can complete it out from under - // us), isRunningRef has flipped false on re-render: sending a - // status:"running" autosave now would regress the completed result. - if (!isRunningRef.current) return; - // Fire-and-forget: errors swallowed; operator's explicit Finish is - // the authoritative write. - try { - const p = onSubmitRef.current(buildPatchRef.current("running")); - if (p && typeof p.catch === "function") p.catch(() => {}); - } catch (_) { /* swallow */ } + fireRunningWrite(); }, AUTOSAVE_DEBOUNCE_MS); }; - return { markDirty, cancelDebounce }; + // flushPending: save NOW instead of in 300ms (bc-dscn). Closing an editor on + // a running match calls this so an edit still inside the debounce window is + // written rather than dropped by the unmount's cancelDebounce. It fires + // whether or not a timer is pending, because not every edit arms one (the + // individual editor's encho counter does not call markDirty); the caller + // gates it on its own isDirty, so an untouched editor never writes. + const flushPending = () => { + cancelDebounce(); + fireRunningWrite(); + }; + + return { markDirty, cancelDebounce, flushPending }; } diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index 92f5a8a8c..af41f5586 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -51,8 +51,10 @@ function PipRow({ count, side }) { // EngiShortcutHint: quiet keyboard-shortcut reminder, matching the kendo // editor's ScoringShortcutHint style. aria-hidden (the same actions are -// reachable via the on-screen counters and Save button). -function EngiShortcutHint({ hasNav = false }) { +// reachable via the on-screen counters and Save button). It shares the +// `.scoring-shortcut-hint` class for its layout, so it hides under a coarse +// pointer too (bc-kbhn), and lists ←/→ and Esc only where they act. +function EngiShortcutHint({ hasNav = false, canClose = false }) { const kbd = { fontFamily: "var(--font-mono)", fontSize: 11, padding: "1px 5px", border: "1px solid var(--line)", borderRadius: 4, background: "var(--surface)", @@ -60,9 +62,9 @@ function EngiShortcutHint({ hasNav = false }) { }; return ( ); } @@ -276,7 +277,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // fresh state via kbRef. Escape stays owned by useEscapeToClose above. const kbRef = useRefE(null); const lastSideRef = useRefE(null); // "a" | "s" | null: which side Backspace undoes - kbRef.current = { submitting, canSubmit, showCorrectionPrompt, flagsA, flagsB, setFlagsA, setFlagsB, clamp, handleSubmit, onPrev, onNext }; + kbRef.current = { submitting, canSubmit, showCorrectionPrompt, flagsA, flagsB, setFlagsA, setFlagsB, clamp, handleSubmit, onPrev, onNext, prevMatch, nextMatch }; useEffectE(() => { const onKeyDown = (ev) => { const s = kbRef.current; @@ -288,8 +289,11 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext if (window.isTextEntry && window.isTextEntry(ev.target)) return; // ←/→ move between queued matches (parity with the kendo editor). - if (ev.key === "ArrowLeft" && s.onPrev) { ev.preventDefault(); s.onPrev(); return; } - if (ev.key === "ArrowRight" && s.onNext) { ev.preventDefault(); s.onNext(); return; } + // Keyed on the neighbour match as well as the callback: the Scores tab + // wires onPrev/onNext unconditionally, and with no neighbour they call + // scoreKeyOf(null), which throws. Same condition as the hint's hasNav. + if (ev.key === "ArrowLeft" && s.onPrev && s.prevMatch) { ev.preventDefault(); s.onPrev(); return; } + if (ev.key === "ArrowRight" && s.onNext && s.nextMatch) { ev.preventDefault(); s.onNext(); return; } if (ev.key === "Enter") { // Let a focused button/link/input handle its own Enter (e.g. Cancel). @@ -521,7 +525,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext {/* Quiet keyboard-shortcut reminder (parity with the kendo editor's ScoringShortcutHint). Hidden during the reason prompt, when keys are disabled. */} - {!(isComplete && showCorrectionPrompt) && } + {!(isComplete && showCorrectionPrompt) && }
); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 97f734fa7..5a6637818 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -12,7 +12,6 @@ import { isPoolDaihyosenBout } from './pool_ids.jsx'; import { SideLabel } from './side_cell.jsx'; import { realIppons, hanteiTied, hanteiSlot, hanteiWinnerKey, sideSlotOrder } from './result_slot.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; -import { NumberedName } from './numbered_name.jsx'; // Imported from the leaf, not read off `window`: this editor is ES-imported by // its host and by unit tests that never load api_client, and write_result.jsx // is import-only so it can be reached directly (see its header). @@ -41,11 +40,25 @@ import { CORRECTION_PRESETS, useAdoptFromServer, sideColorName, + useMatchReopen, + ReopenFeedback, + RecordedWithdrawal, + withdrawalInForce, + withdrawnKeyOf, + WithdrawalMarkedName, + BarredMatchNotice, } from './admin_scoring_shared.jsx'; +// bc-cse: a SCHEDULED match a competitor is barred from must never offer a +// Start the server would refuse; isBarredMatch (ineligible_match.jsx) is the +// one owner of that question. +import { isBarredMatch } from './ineligible_match.jsx'; import { SyncStatusPill, useDebouncedRunningWrite } from './admin_scoring_autosave.jsx'; -import { TeamScoreEditorModal } from './admin_scoring_team.jsx'; +// isKoTieBlocked: import-only, from the team editor's shared module. bc-rawm +// reuses it here for the SAME tie rule (a knockout match cannot finish with +// no winner), never a re-derivation of it; see canFinish below. +import { TeamScoreEditorModal, isKoTieBlocked } from './admin_scoring_team.jsx'; import { EngiScoreEditorModal } from './admin_scoring_engi.jsx'; export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, onAfterDecision, prevMatch, nextMatch, onPrev, onNext, password, selfReport, variant = "modal", canClose = true }) { @@ -210,7 +223,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const _autosaveIsRunningRef = useRefA(false); const _autosaveBuildPatchRef = useRefA(null); const _autosaveOnSubmitRef = useRefA(null); - const { markDirty: markScoringDirty, cancelDebounce: cancelScoringDebounce } = useDebouncedRunningWrite({ + const { markDirty: markScoringDirty, cancelDebounce: cancelScoringDebounce, flushPending: flushScoringAutosave } = useDebouncedRunningWrite({ isRunningRef: _autosaveIsRunningRef, buildPatchRef: _autosaveBuildPatchRef, onSubmitRef: _autosaveOnSubmitRef, @@ -254,6 +267,30 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // when the decision write is only queued (offline / transient failure). setPendingWrite, pendingFnRef, }); + // bc-tmfn: Clear withdrawal and reopen, the same door and component the team + // editor uses (RecordedWithdrawal / useMatchReopen, admin_scoring_shared.jsx). + // A correction here keeps a recorded withdrawal (it has no way to state a + // decision), so removing one recorded by mistake is a reopen: the match goes + // back to running with the letters the withdrawing side struck. The editor + // STAYS OPEN and follows the match to running in place, so + // the operator scores the rest here, as the consequence text tells them, + // and the ReopenFeedback in the footer can still show what else the reopen + // reopened. Called unconditionally (rules of hooks: the team and engi + // dispatch below returns after every hook). + const recordedWithdrawal = withdrawalInForce(m); + const reopenCtl = useMatchReopen({ match: m, password, isComplete }); + // While that withdrawal is in force, the WINNER's side holds the default-win + // maru, which is the ruling itself: Save correction keeps it whatever is + // sent (engine keptWithdrawalScoreline) and only the withdrawing side's + // letters are the operator's to correct. So the winner's slots are + // read-only (lockedKey) and the decided state is the withdrawer's alone, + // capped one short of a decided bout: a side with two points has won the + // bout, so it cannot be the side that withdrew (and the server refuses the + // 2-2 that two letters against two maru would make). "" when no withdrawal + // is in force, or the ruling does not say who withdrew. + const withdrawnKey = recordedWithdrawal ? withdrawnKeyOf(m) : ""; + const lockedKey = withdrawnKey === "a" ? "b" : withdrawnKey === "b" ? "a" : ""; + const sideCap = (side) => (side === lockedKey ? 0 : lockedKey ? MAX_IPPONS_PER_SIDE - 1 : MAX_IPPONS_PER_SIDE); // Hansoku Hs are now physically present in the opponent's pts array // (folded in at the 2-foul boundary by applyFoulIncrement). The counter @@ -269,13 +306,17 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const addPt = (side, letter) => { // No-op when the side is already at the 2-ippon max: don't mark dirty or // schedule an autosave PUT / SSE fan-out for a tap that changes nothing. + // sideCap: 2 normally; under a recorded withdrawal 0 for the winner's + // read-only maru and 1 for the withdrawer (see lockedKey above). The + // keyboard path reaches here without the render's disabled buttons. + const cap = sideCap(side); const cur = side === "a" ? aPts : bPts; - if (cur.length >= 2) return; // fast no-op path: don't mark dirty / autosave + if (cur.length >= cap) return; // fast no-op path: don't mark dirty / autosave // The functional updater re-checks the cap against the AUTHORITATIVE current // state (p), not the render-closure cur: so the 2-ippon invariant holds even // if addPt is called twice before a re-render (React batching / rapid taps). - if (side === "a") setAPts((p) => p.length < 2 ? [...p, letter] : p); - else setBPts((p) => p.length < 2 ? [...p, letter] : p); + if (side === "a") setAPts((p) => p.length < cap ? [...p, letter] : p); + else setBPts((p) => p.length < cap ? [...p, letter] : p); markScoringDirty(); // C1: trigger debounced autosave }; const removePt = (side, idx) => { @@ -290,6 +331,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // a tap that changed nothing. const cur = side === "a" ? aPts : bPts; if (cur[idx] === undefined) return; // fast no-op path: don't mark dirty / autosave + if (side === lockedKey) return; // the recorded default-win maru is not the operator's to remove if (side === "a") setAPts((p) => p.filter((_, i) => i !== idx)); else setBPts((p) => p.filter((_, i) => i !== idx)); markScoringDirty(); // C1 @@ -538,6 +580,9 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // sideSlotOrder: the same visual mirror the read-only scoreboard and the // team editor apply, so DOM order is visual order and no CSS mirror is // needed here any more (result_slot.jsx owns the rule). + // The winner's slots while a recorded withdrawal is in force: the maru + // shows, read-only (see lockedKey). + const locked = s.key === lockedKey; return sideSlotOrder(s.color).map((i, ordinal) => { const isHt = htSlot === i; // The spoken ordinal counts in READING order (ordinal), not by the @@ -552,9 +597,9 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on key={i} className={`sb-slot ${(isHt || s.pts[i]) ? "sb-slot--filled" : ""}`} onClick={() => removePt(s.key, i)} - disabled={decidedByHantei} - title={decidedByHantei ? (hanteiRecorded ? "Locked: hantei already recorded" : "Hantei armed: choose a winner above, or cancel") : "Click to remove"} - aria-label={`${sideColorName(s.color)} slot ${ordinal + 1}: ${isHt ? "Ht" : (s.pts[i] ? `remove ${s.pts[i]}` : "empty")}`} + disabled={decidedByHantei || locked} + title={locked ? "Default win recorded with the withdrawal" : decidedByHantei ? (hanteiRecorded ? "Locked: hantei already recorded" : "Hantei armed: choose a winner above, or cancel") : "Click to remove"} + aria-label={`${sideColorName(s.color)} slot ${ordinal + 1}: ${isHt ? "Ht" : (s.pts[i] ? (locked ? `${s.pts[i]}, default win` : `remove ${s.pts[i]}`) : "empty")}`} > {isHt ? "Ht" : (s.pts[i] || "\u00b7")} @@ -588,8 +633,15 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on ]; // Bout is decided once either side reaches 2 ippons: disable add-ippon - // buttons on BOTH sides (mirrors validateIppons on the server). - const boutDecided = isBoutDecided(aPts, bPts); + // buttons on BOTH sides (mirrors validateIppons on the server). Under a + // recorded withdrawal the winner's maru does not count: only the + // withdrawer's letters are editable, up to their cap (sideCap, lockedKey). + const withdrawerPts = lockedKey === "a" ? bPts : lockedKey === "b" ? aPts : null; + const boutDecided = withdrawerPts + ? withdrawerPts.length >= sideCap(withdrawnKey) + : isBoutDecided(aPts, bPts); + // Marks a tap can clear: under a recorded withdrawal, the withdrawer's only. + const clearableMarks = withdrawerPts ? realIppons(withdrawerPts).length : aTotal + bTotal; // While hantei is armed the operator must commit via the dedicated SHIRO / // AKA buttons (which route through submitHantei). Disable the regular @@ -599,7 +651,35 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // so the hikiwake toggle is suppressed in the bracket phase: m.phase === // "bracket" is the in-modal KO signal (see TeamScoreEditorModal). const isKnockoutPhase = m.phase === "bracket"; - const canFinish = !decidedByHantei && (isDrawToggled || aTotal > 0 || bTotal > 0); + // bc-rawm: a tied individual knockout bout (0 encho periods or several, + // 1-1 or any other equal score) has no winner, and the server refuses it + // outright -- validateBracketCompletion runs on a Finish AND on a later + // Save correction alike -- so this must block BOTH, not just the first + // Finish. That is why isComplete is always passed false below, unlike the + // team editor's own isKoTieBlocked call: a team tie is broken by an + // appended daihyosen bout, so a COMPLETED team encounter never carries a + // null teamWinner and isKoTieBlocked's isComplete exemption is safe there; + // an individual bout has no daihyosen, so the identical tie can recur + // under a correction (the operator removes a point and the score is tied + // again) and must stay blocked there too. A recorded withdrawal + // (lockedKey) already has a winner regardless of the scoreline, so it is + // exempt from the tie check, exactly as hantei is (decidedByHantei already + // disables Finish below; guarded out of koTieBlocked too so the button's + // label/title do not claim "needs a winner" while the hantei picker, the + // actual remedy, is on screen). + const individualWinner = lockedKey || (aTotal === bTotal ? null : (aTotal > bTotal ? "a" : "b")); + // bc-cse: hasPointsOrDraw gates koTieBlocked too, not just canFinish -- at + // a pristine 0-0 (or a still-SCHEDULED match, which reads 0-0 the same + // way) aTotal===bTotal is true by construction, so without this the + // button read "Needs a winner" on every knockout bout before anything was + // struck, including a scheduled barred match nobody has opened yet. The + // button was already disabled there via canFinish; only the WORDING was + // wrong, claiming a tie that never happened. + const hasPointsOrDraw = isDrawToggled || aTotal > 0 || bTotal > 0; + const koTieBlocked = !decidedByHantei && m.status !== "scheduled" && hasPointsOrDraw && + isKoTieBlocked({ isKnockoutPhase, teamWinner: individualWinner, isComplete: false }); + const KO_TIE_REASON = "Needs a winner: fight encho, then record hantei if still tied."; + const canFinish = !decidedByHantei && !koTieBlocked && hasPointsOrDraw; // Finish guard (see TeamScoreEditorModal): one tap ARMS the button — its label // becomes an explicit "Tap again to finish" INSTRUCTION (not a verdict), so the @@ -618,14 +698,22 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // is not an unsaved change of theirs, and prompting "discard unsaved scoring // changes?" on an editor nobody touched trains operators to dismiss the one // prompt that protects real work. - const isDirty = + // + // scoringDirty is what closing a RUNNING match may flush (bc-dscn): isDirty + // without the hantei ARM, which is a mode rather than a result. Flushing on + // an arm alone would send a freshly stamped write with an unchanged + // scoreline, which could win last-write-wins over another device's older + // queued result. Withdrawing a RECORDED verdict is a result, not the arm, + // and buildPatch carries it (hanteiClear), so that term stays. + const scoringDirty = !window.arraysEqual(aPts, initialAPts) || !window.arraysEqual(bPts, initialBPts) || aFouls !== initialAFouls || bFouls !== initialBFouls || isDrawToggled !== initialIsDrawToggled || enchoPeriodCount !== initialEnchoPeriods || - decidedByHantei !== hanteiRecorded; + (hanteiRecorded && !decidedByHantei); + const isDirty = scoringDirty || decidedByHantei !== hanteiRecorded; // The scoreline half of the same rule, declared HERE because the hook needs // isDirty: it reads the value from the render BEFORE the server change (see // useAdoptFromServer). It self-corrects: a re-seed makes the next render's @@ -636,11 +724,42 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on keepLocalEdits: true, isDirty, }); + // ...except when the RULING moves under the board (lockedKey changes): a + // cleared withdrawal (Clear withdrawal and reopen, which keeps this editor + // open) or one moved to the other side. Unsaved taps were made against the + // old ruling, and keeping them would keep the default-win maru on a side + // that is no longer locked: tappable, and sent as two points by the next + // autosave onto a match with no withdrawal left to explain them. This is + // not the peer disagreement keepLocalEdits protects; the operator's own + // reopen moved the server, so the board re-seeds from it. + const lockedKeyRef = useRefA(lockedKey); + useEffectA(() => { + if (lockedKeyRef.current === lockedKey) return; + lockedKeyRef.current = lockedKey; + applyServerScore(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lockedKey]); const handleDismiss = async () => { // Don't close while any save/decision request is in flight: letting // the modal unmount would orphan the pending fetch and lose the // setState landing. if (submitting || decisionSubmitting) return; + // bc-dscn: a host that cannot close (the inline court console) has nothing + // to discard INTO, so it never prompts either. + if (!canClose) return; + // bc-dscn: on a RUNNING match every scoring edit is autosaved, so closing + // discards nothing: save any edit still inside the debounce window now and + // close without asking. Two local states are NOT in buildPatch("running") + // and so are not saved by it: the hantei ARM, which is only a mode (the + // verdict is committed by the side buttons, submitHantei) and is dropped + // with no write (scoringDirty excludes it), and the hikiwake toggle, which + // is a result the operator entered; that one keeps the prompt below, + // because closing would lose it. + if (m.status === "running" && isDrawToggled === initialIsDrawToggled) { + if (scoringDirty) flushScoringAutosave(); + onClose(); + return; + } if (isDirty && !(await window.confirmDialog({ message: "Discard unsaved scoring changes?", confirmLabel: "Discard changes", danger: true }))) return; onClose(); }; @@ -652,25 +771,40 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // x / X → toggle hikiwake (draw) // ←/→ → previous / next match (skipped inside text-entry elements) // Enter → finish (or finish + start next when available) - // Esc → close the modal (respects dirty-state confirm) + // Esc → close the modal (respects dirty-state confirm) where the host + // can close; a running match saves and closes without asking // Scoring shortcuts (Enter/M/K/D/T/H/X, plus S in Naginata) are skipped when any interactive // element (input, button, link, …) has focus so native activation still works. const kbRef = React.useRef(null); - kbRef.current = { submitting, canFinish, isDrawToggled, isKnockoutPhase, aTotal, bTotal, handleDismiss, onPrev, onNext, onSubmit, onSubmitAndNext, buildPatch, addPt, doSubmit, isNaginata, decidedByHantei, isComplete, correctionReason, setShowCorrectionPrompt, markScoringDirty, cancelScoringDebounce }; + kbRef.current = { delegated: isTeam || isEngi, submitting, canFinish, isDrawToggled, isKnockoutPhase, aTotal, bTotal, handleDismiss, canClose, onPrev, onNext, prevMatch, nextMatch, onSubmit, onSubmitAndNext, buildPatch, addPt, doSubmit, isNaginata, decidedByHantei, isComplete, correctionReason, setShowCorrectionPrompt, markScoringDirty, cancelScoringDebounce }; useEffectA(() => { const onKeyDown = (ev) => { const s = kbRef.current; + // A team or engi match renders its own editor below, which owns the + // keyboard. This listener is registered anyway (hooks run before the + // dispatch), and acting here too closed a team or engi editor on Esc past + // its own discard prompt, and turned a point key on a team match into a + // phantom individual-shaped running write (match-level ippons, no + // subResults). + if (s.delegated) return; if (s.submitting) return; if (ev.ctrlKey || ev.metaKey || ev.altKey) return; - // Esc routes through handleDismiss so the dirty-state confirm still fires - if (ev.key === "Escape") { ev.preventDefault(); s.handleDismiss(); return; } + // Esc routes through handleDismiss so the dirty-state confirm still fires. + // bc-dscn: only where the host can close. On the inline court console + // Esc belongs to whatever has focus (e.g. an open fighter list), so it + // is left unhandled and not prevented. + if (ev.key === "Escape") { if (!s.canClose) return; ev.preventDefault(); s.handleDismiss(); return; } // Navigation blocked only inside text-entry elements (preserves cursor movement) if (!window.isTextEntry(ev.target)) { - if (ev.key === "ArrowLeft" && s.onPrev) { ev.preventDefault(); s.onPrev(); return; } - if (ev.key === "ArrowRight" && s.onNext) { ev.preventDefault(); s.onNext(); return; } + // Keyed on the neighbour match as well as the callback: the Scores tab + // wires onPrev/onNext unconditionally, and with no neighbour they + // call scoreKeyOf(null), which throws. Same condition as the nav + // buttons and the shortcut hint's hasNav. + if (ev.key === "ArrowLeft" && s.onPrev && s.prevMatch) { ev.preventDefault(); s.onPrev(); return; } + if (ev.key === "ArrowRight" && s.onNext && s.nextMatch) { ev.preventDefault(); s.onNext(); return; } } // Scoring shortcuts blocked when any interactive element has focus @@ -834,13 +968,15 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on the only signal. */} {/* Competitor number chip: owned by numbered_name.jsx - (the outer-side rule lives there). */} + (the outer-side rule lives there). A recorded + withdrawal's Kiken/Fus. rides beside the withdrawn + competitor (WithdrawalMarkedName, bc-kcsh). */}
- +
{getIpponButtons(isNaginata).map((cc) => ( - + ))}
@@ -874,7 +1010,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on 2026-09-15, bc-dnst: the cells stay as they are, no corner badge, no undo). Hidden under hantei, where the cells are locked. */} - {(aTotal + bTotal > 0 && !decidedByHantei) && ( + {(clearableMarks > 0 && !decidedByHantei) && (
Tap a scored mark to clear it
)}
@@ -1043,6 +1183,11 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on )}
)} + {/* Correcting a match a withdrawal ended: what is recorded, and the + way to remove it when it was a wrong entry. */} + {recordedWithdrawal && !decisionPromptKind && !withdrawnPlayer && !selfReport && ( + + )} {decisionErr && (
{decisionErr}
)} @@ -1092,6 +1237,12 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on onCancel={() => setShowCorrectionPrompt(false)} /> )} + {/* What Clear withdrawal and reopen came back with: the notice + naming a later match it also reopened, its error, and the + court-busy remedy. Here in the footer, not inside + RecordedWithdrawal, which unmounts the moment the match is + running again (the team editor places its copy the same way). */} + {/* F5: PERMANENT-failure banner: a queued terminal write was rejected (non-retryable 4xx) and dropped, so it never saved. Non-dismissible danger state; the operator must re-enter and submit again. Takes @@ -1136,6 +1287,18 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on )}
)} + {/* bc-cse: a barred match cannot be started as scheduled -- the + server would just refuse it -- so the ONE component that shows + why and offers the default-win/reinstate resolution + (BarredMatchNotice, admin_scoring_shared.jsx) stands where + Start would be. Lifted ABOVE .score-nav__actions (a centred + wrapping flex row of small buttons): the notice's own note text + plus its action buttons were squeezed into one flex item there + instead of reading as the full-width block it is everywhere + else this component renders. */} + {m.status === "scheduled" && isBarredMatch(m) && ( + + )} {/* While the correction prompt is open it owns the only Cancel/commit row: hide the footer's own nav+actions so the operator never sees two Cancels and two commit buttons at the highest-stakes moment @@ -1147,7 +1310,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on ) : }
- {m.status === "scheduled" && ( + {m.status === "scheduled" && !isBarredMatch(m) && ( ) : ( )}
@@ -1191,8 +1356,9 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on ) : }
)} - {/* Quiet, always-present keyboard-shortcut reminder. */} - + {/* Quiet keyboard-shortcut reminder. It lists only keys that act on + this host (the same conditions the keydown handler checks). */} +
); diff --git a/web-mobile/js/admin_scoring_shared.jsx b/web-mobile/js/admin_scoring_shared.jsx index 15a4089be..190d0c1c3 100644 --- a/web-mobile/js/admin_scoring_shared.jsx +++ b/web-mobile/js/admin_scoring_shared.jsx @@ -6,13 +6,21 @@ const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React; const Icon = window.Icon; -import { DAIHYOSEN_POSITION } from './pool_ids.jsx'; +import { DAIHYOSEN_POSITION, scoreRowMatchLabel } from './pool_ids.jsx'; import { - writeDidNotLand, writeWasSuperseded, SUPERSEDED_REASON, SUPERSEDED_ADVICE, - attemptScoreWrite, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, + writeDidNotLand, notLandedBanner, + attemptScoreWrite, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED, downstreamKnockoutReopenedNotice, + courtBusyMessage, } from './write_result.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; import { sideWord } from './side_cell.jsx'; +import { sideMarks } from './bracket.jsx'; +import { NumberedName, numberFollowsName } from './numbered_name.jsx'; +import { defaultWinDecisionBodyForSide, withdrawnSideKey } from './ineligible_match.jsx'; +import { isTeamDefaultWinDecision } from './team_default_credit.jsx'; +// BarredMatchNotice is a separate leaf (imports only ineligible_match.jsx + +// write_result.jsx): re-exported below, see that file's header for why. +import { BarredMatchNotice } from './barred_match_notice.jsx'; // Kendo best-of-3 cap. Mirrors the server-side `maxIpponsPerSide` in // internal/mobileapp/validation.go: the bout ends when one side reaches @@ -91,44 +99,66 @@ function IpponLegend({ isNaginata }) { ); } -// ScoringShortcutHint: quiet, always-present reminder of the keyboard -// shortcuts the modal supports. Rendered below the prev/next nav so the -// affordance sits where operators look for navigation. Clarity over -// decoration: plain muted text, no animation. Styled inline (this region of -// styles.css is owned elsewhere). +// ScoringShortcutHint: quiet reminder of the keyboard shortcuts the editor +// supports. Rendered below the prev/next nav so the affordance sits where +// operators look for navigation. Clarity over decoration: plain muted text, no +// animation. The container's layout lives in the `.scoring-shortcut-hint` +// rule in styles.css, NOT inline: an inline display:flex outranked the +// coarse-pointer `display: none` and showed the hint on the iPad (bc-kbhn). // pointKeys: the valid ippon letters ("MKDTH" / "MKDTSH") when this editor // supports keyboard scoring — individual matches and kachinuki bouts — else // "" (fixed-order team bouts score by tap only). Listed so the shortcut is -// discoverable instead of hidden in the code; the hint is display:none under -// a coarse pointer, so this only ever shows to a keyboard/mouse operator. -function ScoringShortcutHint({ pointKeys = "" }) { +// discoverable instead of hidden in the code. +// hasNav / canClose: whether ←/→ and Esc actually do something on this host. +// A key that does nothing is never listed (the inline court console wires +// neither), and with nothing to list the hint renders nothing at all. +function ScoringShortcutHint({ pointKeys = "", hasNav = false, canClose = false }) { const kbd = { fontFamily: "var(--font-mono)", fontSize: 11, padding: "1px 5px", border: "1px solid var(--line)", borderRadius: 4, background: "var(--surface)", color: "var(--ink-3)", margin: "0 1px", }; const keys = pointKeys ? [...pointKeys] : []; + const groups = []; + if (keys.length > 0) { + groups.push( + + {keys.map((k) => {k})} + Shiro + + ⇧Aka + , + ); + } + if (hasNav) { + groups.push( + + ←→ + prev/next + , + ); + } + if (canClose) { + groups.push( + + Esc + close + , + ); + } + if (groups.length === 0) return null; return ( ); } @@ -387,9 +417,11 @@ function makeSubmitDecision({ // The score path already guards this way; a decision that advanced a // bracket on a winner the server discarded is the same failure. // - // Inert today: no server path answers /decision with applied:false (the - // request carries no modifiedAt, and respondClockSkew has no decision - // call site). It is the guard, not a live branch. + // LIVE since mp-jnvl: the /decision write now carries a real + // modifiedAt (recordDecision, api_client.jsx), so the server can + // genuinely answer with applied:false for either reason (superseded or + // clock_skew) -- this is no longer a defence against a shape it could + // not yet send. // // F5: enter pending-write mode so the banner shows in the footer, and // save the submit closure so "Retry now" can re-invoke it directly. @@ -627,34 +659,27 @@ function EnchoControl({ enchoPeriodCount, setEnchoPeriodCount }) { // Render the inline kiken/fusenpai prompt that replaces the score controls // while open. Side picker uses radio inputs labelled "SHIRO (White)" / "AKA // (Red)" to stay consistent with the score board legend; the value submitted -// to the backend is "shiro" or "aka" per DecisionRequest.Validate. -// requireReason (mp-gmcg): the match was reopened without a reason, so the -// server rejects ANY finalization that carries none — including this one, since -// a decision is just the other way to end a match. Without this the operator -// meets a 400 they cannot satisfy: the reason box is only rendered for kiken, -// so fusenpai on a reopened encounter would have no field to fill at all. -// Forcing the box open AND blocking submit turns that dead end into a prompt. -function DecisionPrompt({ kind, sideA, sideB, defaultSide, askReason, requireReason, onCancel, onSubmit, submitting }) { +// to the backend is "shiro" or "aka" per DecisionRequest.Validate. The reason +// is always optional, a reopened match included: a match can be reopened +// without any reason, and ending it again asks for none (operator ruling +// 2026-09-25). +function DecisionPrompt({ kind, sideA, sideB, defaultSide, askReason, onCancel, onSubmit, submitting }) { const [side, setSide] = useStateA(defaultSide || "shiro"); const [reason, setReason] = useStateA(""); - const showReason = askReason || requireReason; - const reasonMissing = requireReason && reason.trim() === ""; + const showReason = askReason; // Display rule (locked, glossary.md §Display rule): render the // romaji term ALONE: the popover (via ) carries the gloss. // We keep "Decision" untouched (it's already plain English) and // wrap the kendo terms so a volunteer hovering/tapping the title // gets the full tooltip. const isKiken = window.isKikenDecision(kind); - const kikenLabel = kind === "kiken-injury" ? "Kiken – Injury" : "Kiken – Voluntary"; - const title = isKiken - ? React.createElement(TermAS, { name: kind }, kikenLabel) - : kind === "fusenpai" - ? React.createElement(TermAS, { name: kind }, "Fusenpai") - : "Decision"; + const title = isKiken || kind === "fusenpai" + ? React.createElement(TermAS, { name: kind }, withdrawalLabel(kind)) + : "Decision"; const submit = (e) => { e?.preventDefault?.(); - if (submitting || reasonMissing) return; + if (submitting) return; onSubmit({ decisionBy: side, decisionReason: showReason ? reason.trim() : "" }); }; @@ -676,7 +701,7 @@ function DecisionPrompt({ kind, sideA, sideB, defaultSide, askReason, requireRea {showReason && ( )}
-
@@ -716,6 +736,7 @@ function RemainingMatchesPanel({ compID, password, withdrawnPlayer, onAwarded, o const [err, setErr] = useStateA(""); const [busyId, setBusyId] = useStateA(""); const mountedRef = useRefA(true); + const playerName = withdrawnPlayer?.name || "player"; useEffectA(() => { return () => { mountedRef.current = false; }; @@ -748,11 +769,11 @@ function RemainingMatchesPanel({ compID, password, withdrawnPlayer, onAwarded, o }, [compID, withdrawnPlayer?.id, withdrawnPlayer?.name]); const award = async (m) => { - // Figure out which side the withdrawn player occupies in THIS match: - // that's the side that gets the fusenpai (default loss). Pool matches: - // sideA = Aka, sideB = Shiro. Same wire mapping in bracket matches. + // Figure out which side the withdrawn player occupies in THIS match: + // that's the side that gets the default loss. Pool matches: sideA = Aka, + // sideB = Shiro. Same wire mapping in bracket matches. const isOnA = sameCompetitor(m.sideA, withdrawnPlayer); - const decisionBy = isOnA ? "aka" : "shiro"; + const barredKey = isOnA ? "a" : "b"; setBusyId(m.id); // Clear any previous verdict before this attempt. Without it the panel's // error is sticky: a refusal on match A stays on screen while the operator @@ -761,22 +782,40 @@ function RemainingMatchesPanel({ compID, password, withdrawnPlayer, onAwarded, o // defect, so this covers both rather than only the new branch. setErr(""); try { - const updated = await window.API.recordDecision(m.compId || compID, m.id, { - decision: "fusenpai", - decisionBy, - decisionReason: `auto: ${wname} withdrawn`, - }, password); + // bc-rawm: fusensho, not fusenpai. This panel exists BECAUSE the + // operator just recorded a withdrawal for this competitor, so every + // OTHER scheduled match of theirs is against an already-ineligible + // competitor: fusenpai there is refused with 409 already_ineligible + // (the concurrent-kiken guard). fusensho is the per-bout default win: + // it writes no ineligibility of its own, so it never trips that guard + // (engine.TestRecordDecision_FusenshoSkipsConcurrentCheck). decisionBy + // is unchanged: it still names the WITHDRAWN competitor's side, the + // side that gets the default loss, exactly as fusenpai's did. + // bc-cse: the body itself has ONE owner (ineligible_match.jsx), keyed + // directly by the barred side this panel already knows, so a default + // win's wire shape cannot drift between this panel and the other + // surfaces that build it off the server's `ineligibleSides` stamp. + const updated = await window.API.recordDecision( + m.compId || compID, m.id, defaultWinDecisionBodyForSide(barredKey, withdrawnPlayer), password, + ); if (!mountedRef.current) return; // A default win the server REFUSED must not leave the list: dropping it // here would hide the one match the operator still has to resolve, and // nothing else on this panel would ever mention it again (bc-lww1). - // writeWasSuperseded, not writeDidNotLand, is the right ask: a QUEUED + // notLandedBanner, not writeDidNotLand, is the right ask: a QUEUED // award lands on reconnect, so an offline court must keep walking the - // list exactly as it does today. Inert today for the same reason as the - // guard in makeSubmitDecision above: nothing answers /decision with - // applied:false yet. - if (writeWasSuperseded(updated)) { - setErr(`Not saved: ${SUPERSEDED_REASON}. ${SUPERSEDED_ADVICE}`); + // list exactly as it does today (notLandedBanner is null for a queued + // write, same as writeWasSuperseded's old narrower check was). LIVE + // since mp-jnvl stamped the /decision write (see recordDecision, + // api_client.jsx): the server can genuinely answer with applied:false + // now, so this is no longer a defence against a shape it could not yet + // send. bc-cse: notLandedBanner over writeWasSuperseded alone -- that + // predicate is TRUE for both the superseded AND the clock_skew + // refusal, so it always showed the superseded copy even when the + // clock, not a newer result, was the reason. + const banner = notLandedBanner(updated); + if (banner) { + setErr(`Not saved: ${banner.reason}. ${banner.advice}`); return; } // Drop the awarded match from the list so the operator can keep walking. @@ -790,8 +829,6 @@ function RemainingMatchesPanel({ compID, password, withdrawnPlayer, onAwarded, o } }; - const playerName = withdrawnPlayer?.name || "player"; - return (
@@ -827,7 +864,7 @@ function RemainingMatchesPanel({ compID, password, withdrawnPlayer, onAwarded, o className="btn btn--sm" onClick={() => award(m)} disabled={busyId === m.id} - title="Record fusenpai: opponent receives the default win" + title="Record fusensho: opponent receives the default win" > {busyId === m.id ? "Saving…" : "Award default win to opponent"} @@ -840,6 +877,13 @@ function RemainingMatchesPanel({ compID, password, withdrawnPlayer, onAwarded, o ); } +// BarredMatchNotice lives in its own leaf (barred_match_notice.jsx): it +// imports only ineligible_match.jsx and write_result.jsx, so a surface that +// does not otherwise need this module's graph (which pulls in bracket.jsx +// for sideMarks) can import it directly without that graph riding along -- +// see that file's header. Re-exported here so the editors' existing +// `from './admin_scoring_shared.jsx'` import lists need no change. + // sideColorName: the human-readable side name for a "shiro"/"aka" colour key. // Named for the colour it takes so it cannot be confused with admin_helpers.jsx's // sideName(side), which takes a side object and returns a participant name. @@ -1138,18 +1182,605 @@ window.ReasonPrompt = ReasonPrompt; const CORRECTION_PRESETS = ["Scoring error", "Wrong competitor", "Data entry", "Other"]; -// mp-gmcg: presets for the REOPEN audit reason, collected on the write that -// closes a reopened kachinuki encounter back out (admin_scoring_team.jsx). -// "Ended by mistake" is FIRST, and therefore the default selection, because it -// is the overwhelmingly common reason a shiaijo operator reopens a match: the -// correction presets offer no honest option for it, so an operator forced -// through that list either mislabels the reopen "Scoring error" or picks -// "Other" and types the same sentence every time. The remaining entries mirror -// CORRECTION_PRESETS so a reopen made for a genuine scoring/identity/data -// problem still lands in the same audit vocabulary — so it is DERIVED from -// CORRECTION_PRESETS rather than restated, making that mirror structural -// instead of a comment-only obligation that drifts on the next edit. -const REOPEN_PRESETS = ["Ended by mistake", ...CORRECTION_PRESETS]; +// withdrawalLabel: the operator's name for a match-level withdrawal decision, +// the ONE copy of it: DecisionPrompt's title and the Recorded line in both +// editors read it. Any kiken that is not the injury kind reads as voluntary, +// the legacy bare "kiken" included, which the server loads as voluntary too. +function withdrawalLabel(decision) { + if (decision === "fusenpai") return "Fusenpai"; + // bc-rawm: a match-level fusensho, the shape RemainingMatchesPanel.award + // writes for a scheduled match against an already-withdrawn competitor + // (see the file header there). Short label for the same "fact" slots + // kiken/fusenpai use (e.g. admin_scoring_team.jsx's team-summary-decision); + // the fuller "Default win (fusensho) for " sentence is composed in + // RecordedWithdrawal, which needs the winner's name this function does not + // have. + if (decision === "fusensho") return "Default win (fusensho)"; + return decision === "kiken-injury" ? "Kiken – Injury" : "Kiken – Voluntary"; +} + +// withdrawnSideOf: the side a recorded withdrawal names as the one that +// withdrew (or did not appear), or null when the match cannot say. +// decisionBy is authoritative (aka = sideA, shiro = sideB, the server's own +// mapping in RecordDecisionTx). A ruling without it falls back to the side the +// recorded winner is NOT, attributed by sameCompetitor (id first), never by a +// bare name comparison. +function withdrawnSideOf(m) { + const key = withdrawnKeyOf(m); + if (key === "a") return m.sideA || null; + if (key === "b") return m.sideB || null; + return null; +} + +// withdrawnKeyOf: the same answer as withdrawnSideOf, as the editors' side key +// ("a" = Aka/sideA, "b" = Shiro/sideB), or "" when the match cannot say. +function withdrawnKeyOf(m) { + return withdrawnSideKey(m); +} + +// withdrawalInForce: the one statement of "a recorded withdrawal is in force +// on this correction": the match is completed and a withdrawal (any kiken, or +// fusenpai) decided it. Both editors ask it: it gates RecordedWithdrawal, and +// the individual editor locks the winner's default-win maru while it holds +// (Save correction keeps the withdrawal and its maru, engine +// keptWithdrawalScoreline, so the maru is not the operator's to edit). It stops +// holding the moment the withdrawal is cleared, because the reopen puts the +// match back to running. +function withdrawalInForce(m) { + // bc-rawm: widened to the default-win class. A match-level fusensho is + // RemainingMatchesPanel.award's shape for a SCHEDULED match against a + // competitor already withdrawn elsewhere (fusenpai is refused there with + // 409 already_ineligible, which is why that panel writes fusensho + // instead): same default-win outcome as a kiken/fusenpai on this match, so + // it belongs in the same "a recorded withdrawal decided this" class as + // kiken/fusenpai, not a separate one. So this asks the match-level + // default-win class, whose one JS owner is isTeamDefaultWinDecision + // (team_default_credit.jsx), rather than ORing a fusensho arm onto a + // narrower kiken/fusenpai check. + return m.status === "completed" && isTeamDefaultWinDecision(m.decision); +} + +// useMatchReopen: the one client of POST .../reopen and its court-busy remedy +// POST .../requeue-blocker-and-reopen, for every editor that reopens a match: +// the kachinuki Reopen and Clear withdrawal and reopen in the individual and +// team editors, both one tap with no reason. It was +// the kachinuki editor's own state machine; lifting it here is what lets the +// individual editor offer the same door without a second copy of it. +// +// Three outcomes, each of which the returned state carries: +// - success: the editor STAYS OPEN and follows the match to running in +// place, for both doors (the kachinuki Reopen and Clear withdrawal and +// reopen): `notice`, which names any later match the reopen also +// reopened, is only readable in an editor that is still there. busy +// clears on the success response and `landed` is set, so the button says +// the reopen landed rather than "Reopening…" even when the match_updated +// broadcast that flips the prop never arrives, while a double tap still +// cannot post a second reopen the server would 409 as "not completed". +// The effect below clears `landed` once the match is running; +// - court_busy: `conflict` names the match holding the court, and +// requeueBlocker() is the remedy (it carries the same reason and, once +// given, the same downstream confirmation); +// - a later knockout match with its own result (downstream_knockout_played): +// attemptScoreWrite, the score path's confirm-and-retry loop, asks the +// operator with the same dialog a correction gets and retries with the +// force flag (operator ruling 2026-09-24: "The operator just needs to be +// aware of the consequences"). What that reopened is named in `notice`; +// declining leaves everything as it was and says so in `err`. +// Every other refusal is the server's sentence, shown verbatim in `err`. +function useMatchReopen({ match, password, isComplete }) { + const mountedRef = useRefA(true); + useEffectA(() => () => { mountedRef.current = false; }, []); + const [busy, setBusy] = useStateA(false); + // The server answered success; the match prop may not have flipped yet. + const [landed, setLanded] = useStateA(false); + const [err, setErr] = useStateA(""); + // { court, matchId, compId, message, label, reason, force }: the match + // ALREADY running on this court, plus what the refused reopen carried. + // `label` is the server's operator-facing name for it, when sent -- it + // names no COMPETITOR (bc-cse), so ReopenFeedback prefers the fetched + // blockerLabel below over it. `message` is the server's raw refusal + // sentence, kept on the object but no longer rendered (see ReopenFeedback: + // it named an internal id and told the operator to finish that match, + // contradicting this panel's own button). + const [conflict, setConflict] = useStateA(null); + // Best-effort "Match 2 · Shiro vs Aka" for the blocking match. The operator + // is about to wipe that match's score, so naming its competitors (not just + // the server's opaque id) is a safety property, not decoration. + const [blockerLabel, setBlockerLabel] = useStateA(""); + const [notice, setNotice] = useStateA(""); + // Set once the operator has confirmed the downstream reopen, so the + // court-busy remedy that may follow does not ask them a second time. + const confirmedRef = useRefA(false); + + // Once the match reopens (status flips to running), a stale error or + // conflict describes a completed-state action that no longer applies. When it + // completes AGAIN (a new result or decision), the reopen notice is history: + // it described that reopen, not the match now on screen. Keyed on the value, + // so the notice set while the match is still completed survives the flip. + useEffectA(() => { + if (!isComplete) { setErr(""); setConflict(null); setBusy(false); setLanded(false); } + else setNotice(""); + }, [isComplete]); + + const applyFailure = (e, reason) => { + if (e && e.downstreamKnockoutPlayedCancelled) { + setConflict(null); + setErr(DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED); + return; + } + const msg = String(e?.message || "Failed to reopen match"); + if (e?.code === "court_busy" && e?.matchId) { + setErr(""); + setConflict({ + court: e.court || match.court || "", + matchId: e.matchId, + compId: e.compId || match.compId, + message: msg, + // The server's own operator label for the blocking match + // (api_client.jsx's reopenFailureError carries it through when + // present). bc-cse: it names no competitor ("Pool A · Match 2"), so + // ReopenFeedback prefers the best-effort FETCHED blockerLabel over + // it and falls back to this only when that fetch has not resolved; + // either way the heading never falls back to printing the raw + // matchId. + label: e.label || "", + reason, + force: confirmedRef.current, + }); + return; + } + setConflict(null); + setErr(msg); + }; + + const run = (call, reason, force) => attemptScoreWrite({ + recordScore: (_compId, _matchId, result, pw) => call(pw, { reason, force: !!result.forceDownstreamReopen }), + confirmDialog: async (opts) => { + const ok = await window.confirmDialog(opts); + if (ok) confirmedRef.current = true; + return ok; + }, + compId: match.compId, + matchId: match.id, + result: force ? { forceDownstreamReopen: true } : {}, + password: resolveDecisionPassword(password), + match, + }); + + const finish = (res) => { + const reopened = downstreamKnockoutReopenedNotice(res && res.downstreamReopened); + if (reopened) setNotice(reopened); + setBusy(false); + setLanded(true); + }; + + const reopen = async (reason = "") => { + if (busy || landed) return; + setErr(""); + setConflict(null); + setNotice(""); + confirmedRef.current = false; + setBusy(true); + try { + const res = await run((pw, opts) => window.API.reopenMatch(match.compId, match.id, pw, opts), reason, false); + if (!mountedRef.current) return; + finish(res); + } catch (e) { + if (!mountedRef.current) return; + applyFailure(e, reason); + setBusy(false); + } + }; + + // The remedy: send the blocking match back to the queue and reopen this one, + // in ONE server call under one court lock (mp-gmcg review A4). DESTRUCTIVE: + // revert-to-queue clears that match's partial score, which is why the panel + // spells the consequence out before this can be tapped. + const requeueBlocker = async () => { + const c = conflict; + if (!c || busy || landed) return; + setErr(""); + setBusy(true); + try { + const res = await run( + (pw, opts) => window.API.requeueBlockerAndReopen(match.compId, match.id, c.compId, c.matchId, pw, opts), + c.reason, c.force, + ); + if (!mountedRef.current) return; + setConflict(null); + finish(res); + } catch (e) { + if (!mountedRef.current) return; + // One atomic call, so one failure path: a DIFFERENT match that has since + // taken the court is offered the remedy again; anything else is shown. + applyFailure(e, c.reason); + } finally { + if (mountedRef.current) setBusy(false); + } + }; + + useEffectA(() => { + setBlockerLabel(""); + if (!conflict?.matchId || !conflict?.compId) return; + if (typeof window.compMatchesForCompetition !== "function" || typeof window.API?.fetchCompetitionDetails !== "function") return; + let cancelled = false; + (async () => { + try { + const detail = await window.API.fetchCompetitionDetails(conflict.compId); + if (cancelled || !mountedRef.current) return; + // compMatchesForCompetition (viewer_utils.jsx) owns the recombination + // of the details payload's config with its matches (mp-dej2). + const hit = (window.compMatchesForCompetition + ? window.compMatchesForCompetition(detail.config || detail, detail) + : []).find(x => x.id === conflict.matchId); + if (!hit) return; + const shiro = hit.sideB?.name || hit.sideB || ""; + const aka = hit.sideA?.name || hit.sideA || ""; + // bc-rawm: a POOL match has no matchNumber (bracket matches alone are + // numbered), so it fell through to the bare "Shiro vs Aka" -- correct + // but silent about which pool, unlike every bracket blocker, which + // names its round via matchNumber's "Match N". window.poolLabel is + // the one owner of that pool-name translation (mp-dej2; the same + // helper RemainingMatchesPanel above uses), so a pool blocker now + // leads with it exactly as a bracket blocker leads with its number. + const lead = hit.phase === "pool" && typeof window.poolLabel === "function" + ? window.poolLabel(hit) + : (hit.matchNumber ? `Match ${hit.matchNumber}` : ""); + const label = [ + lead, + shiro && aka ? `${shiro} vs ${aka}` : "", + ].filter(Boolean).join(" · "); + if (label) setBlockerLabel(label); + } catch { /* best effort: the panel falls back to the server's own label, or "another match" */ } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conflict?.compId, conflict?.matchId]); + + return { busy, landed, err, conflict, blockerLabel, notice, reopen, requeueBlocker, dismissConflict: () => setConflict(null) }; +} + +// ReopenFeedback: what a reopen made through useMatchReopen came back with, +// rendered ONCE per editor, outside the control that started the reopen +// (testIdPrefix names the door). It must outlive that control: Clear +// withdrawal and reopen (RecordedWithdrawal) unmounts the moment the match is +// running again, which is exactly when its `notice` has something to say, so +// a copy inside it could never be seen. The court-busy panel's +// warning is NOT optional chrome: the remedy WIPES the blocking match's partial +// score, and the operator tapping it is looking at their own match, not that +// one, so the consequence is stated in full above the button. +function ReopenFeedback({ ctl, testIdPrefix }) { + const c = ctl.conflict; + return ( + <> + {ctl.notice && ( +
{ctl.notice}
+ )} + {ctl.err && ( +
{ctl.err}
+ )} + {c && ( +
+ {/* bc-cse: the best-effort FETCHED blockerLabel wins over the + server's own `label` -- the server always sends one, but it is + "Pool A · Match 2" with no names, never the "Match 2 · Shiro vs + Aka" this panel is about to wipe the score of; that safety + property only exists in the fetched label. Only when the fetch + cannot resolve it (still loading, or a DIFFERENT match has since + taken the court) does the server's label stand in, and only + "another match" when neither is available. Reuses + courtBusyMessage (write_result.jsx) rather than repeating its + sentence here. The raw c.message line that used to sit below + this heading is gone -- it named the internal id and told the + operator to "finish that match before reopening", the opposite + of the remedy this panel's own button offers. */} +
+ {courtBusyMessage({ court: c.court, label: ctl.blockerLabel || c.label || "another match" })} +
+
+ Sending it back to the queue clears any score already entered for it. Finishing that + match instead keeps its score. +
+
+ + +
+
+ )} + + ); +} + +// RecordedWithdrawal: what a correction of a withdrawal-decided match shows +// about the withdrawal, and the one way to remove it, identical in the +// individual and team editors (operator ruling 2026-09-24: "Everything should +// be able to be fixed, in case of a wrong entry"). +// +// Clearing a withdrawal is a REOPEN, not a score write: a withdrawal means the +// opponent received the default score, so without it the match was never +// decided. The match goes back to running with what was fought kept and the +// withdrawn side eligible again, and the operator scores the rest and +// finishes it normally (engine.ReopenMatch). The reason is asked for BEFORE +// the reopen posts and rides it, with the consequence spelled out above it +// (operator ruling 2026-09-24: the operator "just needs to be aware of the +// consequences"). On a single bout (singleBout: the individual editor, which +// also scores a team's -DH-/-TB- rep bout) the consequence names the one +// thing the reopen cannot keep, the winner's points, which the recorded +// withdrawal had already replaced with the default win. +// +// A kachinuki encounter a withdrawal decided renders this too, in place of +// its plain one-tap Reopen, so a withdrawal has one control and one +// consequence text in every editor; the plain Reopen stays for every other +// kachinuki result. Switching the withdrawal to the other side stays with the +// editor's own withdrawal controls. What the reopen came back with (the +// notice, an error, the court-busy remedy) is rendered by the editor through +// ReopenFeedback, never here: this unmounts as soon as the match is running. +// +// A POOL match of a pools-then-knockout competition adds one line: finishing +// the reopened match may change who qualifies from its pool, and the save +// that finishes it shows which knockout matches that affects before anything +// is saved (the server's qualifierChange refusal, confirmed through +// attemptScoreWrite like any other). +// +// bc-rawm: widened to the match-level DEFAULT-WIN class alongside kiken and +// fusenpai (withdrawalInForce above), for a match RemainingMatchesPanel.award +// closed with a fusensho because the competitor was already ineligible from +// an earlier withdrawal. Reads and the reopen remedy are otherwise identical; +// only the copy differs (the Recorded line names the winner, and "Clear +// default win and reopen" replaces "Clear withdrawal and reopen"). +function RecordedWithdrawal({ match, ctl, disabled = false, singleBout = false }) { + const withdrawnKey = withdrawnKeyOf(match); + const withdrawn = withdrawnSideOf(match); + const who = withdrawn?.name || ""; + const winner = withdrawnKey === "a" ? match.sideB : withdrawnKey === "b" ? match.sideA : null; + const winnerName = winner?.name || ""; + const isDefaultWin = match.decision === "fusensho"; + const what = match.decision === "fusenpai" ? "did not appear" : "withdrew"; + const feedsKnockout = match.phase === "pool" && match.compFormat === "mixed"; + + // bc-cse: "Everything should be able to be fixed... the operator just needs + // to be aware of the consequences" (operator ruling 2026-09-24) extends past + // THIS match: if the withdrawn competitor has LATER matches RemainingMatchesPanel + // already closed with a default win (fusensho, naming this competitor's side + // as the one that withdrew), clearing THIS withdrawal does not touch those -- + // they keep their own recorded result and must be reopened separately to be + // fought. Fetched the same way RemainingMatchesPanel finds them (fetch + + // compMatchesForCompetition), when the recorded withdrawal is shown: the + // clear is one tap (operator ruling 2026-09-25), so the consequences are + // stated beside the button, before it is tapped, rather than in a confirm + // step. Best-effort: a fetch failure just omits the list rather than + // blocking the reopen the operator came here to do. + const [laterDefaultWins, setLaterDefaultWins] = useStateA(null); + useEffectA(() => { + if (!withdrawn || !match.compId) { setLaterDefaultWins(null); return; } + let cancelled = false; + (async () => { + try { + const detail = await window.API.fetchCompetitionDetails(match.compId); + if (cancelled) return; + const all = window.compMatchesForCompetition + ? window.compMatchesForCompetition(detail.config || detail, detail) + : []; + const hits = all.filter(x => ( + x.id !== match.id && + x.status === "completed" && + x.decision === "fusensho" && + ((x.decisionBy === "aka" && sameCompetitor(x.sideA, withdrawn)) || + (x.decisionBy === "shiro" && sameCompetitor(x.sideB, withdrawn))) + )); + if (!cancelled) setLaterDefaultWins(hits); + } catch (_e) { + if (!cancelled) setLaterDefaultWins([]); + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [match.compId, match.id, withdrawn?.id, withdrawn?.name]); + + // bc-cse: whether the FUSENSHO's barred competitor (`withdrawn`, the side + // THIS match's decisionBy names -- barred elsewhere, not by anything on + // this match) is STILL barred, whether that withdrawal is reinstateable, + // or whether they are eligible again already (reinstated, or their + // earlier withdrawal cleared), so the consequence can name the right + // remedy instead of guessing. isDefaultWin-only: a kiken/fusenpai clear on this + // match is an ordinary withdrawal, not another competitor's barring, so + // it never needs this. window.API.fetchCompetitorStatuses always exists + // in the app (api_client.jsx); best-effort is only about the FETCH, same + // pattern as laterDefaultWins above -- unknown (fetch failed, or no + // matching row) reads as "still barred, not reinstateable", the more + // conservative of the wrong guesses. + const [withdrawnStatus, setWithdrawnStatus] = useStateA(null); + useEffectA(() => { + if (!isDefaultWin || !withdrawn?.id || !match.compId) { + setWithdrawnStatus(null); + return; + } + let cancelled = false; + (async () => { + try { + const statuses = await window.API.fetchCompetitorStatuses(match.compId); + if (cancelled) return; + setWithdrawnStatus((statuses || []).find(s => s.playerId === withdrawn.id) || null); + } catch (_e) { + if (!cancelled) setWithdrawnStatus(null); + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDefaultWin, match.compId, withdrawn?.id]); + const canReinstate = !!(withdrawnStatus && withdrawnStatus.eligible === false && withdrawnStatus.reinstateable); + // bc-cse: the barred competitor's own status record now reads eligible -- + // reinstated, or their earlier withdrawal cleared elsewhere -- so the + // server reopens THIS match straight to running rather than the queue + // (see the fusensho branch below). + const isEligibleAgain = !!(withdrawnStatus && withdrawnStatus.eligible === true); + + return ( +
+
+ {/* bc-rawm: a match-level fusensho (RemainingMatchesPanel.award) names + the WINNER first -- "Default win (fusensho) for " -- so the + operator sees who benefits from the same reading the wire's + decisionBy already carries, then names who had withdrawn as a + second sentence. withdrawalLabel cannot build this on its own: it + takes only the decision string, not the winner's name. */} + + {isDefaultWin + ? `Recorded: Default win (fusensho) for ${winnerName || "the opponent"}. ${who || "The withdrawn competitor"} had withdrawn.` + : `Recorded: ${withdrawalLabel(match.decision)}${who ? `, ${who} ${what}` : ""}.`} + + {" "} + {/* One tap, no reason (operator ruling 2026-09-25: a match can be + reopened without any reason, and nothing is gated on that). What + the clear does is stated below, before it is tapped. The one + second step left is useMatchReopen's own, when a later knockout + match has already been fought ("Reopen both"). */} + +
+ {isDefaultWin ? ( + // bc-cse: a fusensho names the OTHER competitor as barred, not + // this match's own withdrawal, so clearing it does not simply + // "reopen to running" -- the barred competitor is almost always + // still barred (whatever withdrew them elsewhere is still on + // record), so that is the default the copy states; only when a + // live check finds them reinstateable does it also offer that + // route. When the live check instead finds them ELIGIBLE + // AGAIN (reinstated, or their earlier withdrawal cleared), the + // server reopens this match straight to running, so the copy + // says that instead of promising the queue. +

+ {isEligibleAgain + ? `${who || "The barred competitor"} can fight again, so the match reopens in progress. Score it and finish it as usual.` + : <> + The match goes back to the queue. {who || "The barred competitor"} is still withdrawn, so + record the default win again{canReinstate ? `, or reinstate ${who || "them"} first to fight it` : ""}. + } +

+ ) : singleBout && match.decision === "fusenpai" ? ( + // A no-show fought nothing: there are no points to keep or lose, + // so the reopen only removes the default win. +

+ This reopens the match: it goes back to running, the default win given + to {winnerName || "the other side"} is removed, and {who || "the side marked absent"} can + compete again. Then score the match and finish it. +

+ ) : singleBout ? ( + // A single bout (an individual match, or a team -DH-/-TB- rep + // bout) loses the WINNER's points on a reopen: recording the + // kiken replaced them with the default win (recordDecisionTx) + // and the reopen drops that verdict, so only what the withdrawing + // side struck is still there to keep (engine singleBoutFightOf). +

+ This reopens the match: it goes back to running and {who || "the withdrawn side"} can + compete again. {winnerName || "The winner"}'s points were replaced by the default + win when the withdrawal was recorded, so enter them again; {who ? `${who}'s` : "the withdrawn side's"} points + are kept. Then score the rest and finish it. +

+ ) : ( +

+ This reopens the match: it goes back to running with what was fought kept,{" "} + {who || "the withdrawn side"} can compete again, and you score the rest and finish it. +

+ )} + {feedsKnockout && ( + // A pool match of a pools-then-knockout competition feeds the + // knockout through its standings, so the result it is finished + // with may seat someone else there. The reopen itself moves + // nobody; the finishing save is what is checked, and it names + // any knockout match already fought before anything is saved. +

+ Finishing it may change who qualifies from {match.poolName || "its pool"}. If that moves + someone who has already fought in the knockout, you will be shown which knockout + matches it affects before anything is saved. +

+ )} + {laterDefaultWins && laterDefaultWins.length > 0 && ( +
+ {/* bc-cse: named by scoreRowMatchLabel first -- a pairing alone + cannot be found in the scores list, which is where the + operator has to go to reopen it -- with the pairing appended + the same way ReopenFeedback's own fetched blockerLabel joins + a lead onto a pairing ("Pool A · Match 2 · Shiro vs Aka"), + so the two operator lines that name a match this way agree. + Falls back to the bare pairing when the match carries no + number at all (see scoreRowMatchLabel's own doc). */} + {laterDefaultWins.map((x) => { + const label = scoreRowMatchLabel(x); + const pairing = `${x.sideB?.name || "Shiro"} vs ${x.sideA?.name || "Aka"}`; + return ( +

+ {label ? `${label} · ${pairing}` : pairing} keeps its default win; reopen it to fight it. +

+ ); + })} +
+ )} +
+ ); +} + +// WithdrawalMarkedName: a side's name in an editor header, carrying the RESULT +// mark (Kiken / Fus.) of a recorded withdrawal beside the competitor it names +// while that withdrawal is in force, so a correction shows the recorded +// decision before any edit (bc-kcsh). WHICH mark is sideMarks (bracket.jsx), +// the owner the score strings and the bracket card already read +// ("M Kiken vs ○○"); WHICH side is withdrawnKeyOf, the answer the Recorded line +// under the board gives, so the two cannot disagree. The mark sits on the +// INNER side of the name, across it from the number (numberFollowsName), and +// never in the centre: the middle is a closed set, and Kiken/Fus. name one +// competitor. Both editors render their header names through here, the +// individual board and the team encounter header alike. +function WithdrawalMarkedName({ match, sideKey, side, name, number }) { + // bc-cse: take BOTH marks once and place the right one on the right name, + // rather than always reading .loser -- for a match-level fusensho the + // withdrawal names the BARRED (losing) side, and sideMarks' fusensho arm + // puts its "Fus." on .winner, not .loser, so reading .loser alone showed + // NO mark at all on either header while the bracket, list rows, TV + // headline and export all marked the winner "Fus.". + const inForce = withdrawalInForce(match); + const withdrawnKey = inForce ? withdrawnKeyOf(match) : ""; + let mark = ""; + if (inForce && withdrawnKey) { + const marks = sideMarks(match.decision, false); + mark = sideKey === withdrawnKey ? marks.loser : marks.winner; + } + if (!mark) return ; + const markEl = {mark}; + return numberFollowsName(side) + ? <>{markEl}{" "} + : <>{" "}{markEl}; +} // A match has ONE result and every surface asking for it shows the same one, // and the score editors are such surfaces: while one is open, the viewer card, @@ -1271,9 +1902,17 @@ export { EnchoControl, DecisionPrompt, RemainingMatchesPanel, + BarredMatchNotice, FoulCounter, LineupNameInput, ReasonPrompt, CORRECTION_PRESETS, - REOPEN_PRESETS, + withdrawalLabel, + withdrawnSideOf, + withdrawnKeyOf, + withdrawalInForce, + useMatchReopen, + ReopenFeedback, + RecordedWithdrawal, + WithdrawalMarkedName, }; diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index b934bddd8..e1e91474a 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -28,9 +28,20 @@ import { LineupNameInput, ReasonPrompt, CORRECTION_PRESETS, - REOPEN_PRESETS, useAdoptFromServer, + useMatchReopen, + ReopenFeedback, + RecordedWithdrawal, + withdrawalInForce, + withdrawnKeyOf, + withdrawalLabel, + WithdrawalMarkedName, + BarredMatchNotice, } from './admin_scoring_shared.jsx'; +// bc-cse: a SCHEDULED match a competitor is barred from must never offer a +// Start the server would refuse; isBarredMatch (ineligible_match.jsx) is the +// one owner of that question. +import { isBarredMatch } from './ineligible_match.jsx'; import { useDebouncedRunningWrite, SyncStatusPill } from './admin_scoring_autosave.jsx'; import { SideLabel } from './side_cell.jsx'; @@ -45,7 +56,13 @@ import { notLandedBanner } from './write_result.jsx'; // chain (CLAUDE.md § Match Decision Types: the middle rule lives in ONE place). import { boutMiddle, winnerSideLR } from './bracket.jsx'; import { realIppons, hanteiTied, hanteiSlot, hanteiWinnerKey, nameOf, sideSlotOrder, attributeWinnerSide, subBoutAttribution } from './result_slot.jsx'; -import { NumberedName } from './numbered_name.jsx'; +import { creditedSideKey, creditedTotals } from './team_default_credit.jsx'; + +// bc-kbrw: how long after a fought kachinuki bout opens a further pointer tap +// on the bout list is ignored, so the second tap of a double tap cannot land +// on the row that moved under the finger. Longer than a double tap's gap, +// shorter than a deliberate second tap. Exported for the render test. +export const DONE_BOUT_OPEN_TAP_GUARD_MS = 400; // renderTeamBoutMiddle: the ONE place the editor turns a sub-bout into its // centre value, for BOTH the read-only done row and the live entry row. Derives @@ -217,6 +234,39 @@ export function isKoTieBlocked({ isKnockoutPhase, teamWinner, isComplete }) { return !!isKnockoutPhase && teamWinner === null && !isComplete; } +// unfinishedTeamBouts: the numbered bouts (1..teamSize, in order) of a team +// match that have no result, which Finish refuses while any remain: every +// bout of a team match is fought (operator ruling 2026-09-24). "Has a result" +// is subBoutHasBeenPlayed, the one played-bout predicate, and state. +// SubMatchResult.HasResult is its Go twin on the server gate. Rows are found +// by their `_pos`, never by index. The daihyosen row is never numbered, so it +// is never returned. +// +// Every position counts, whoever the lineups field there: a vacancy on one +// side is a fusensho for the fighter who is present, and a position neither +// side fields is recorded as a Tie (operator ruling 2026-09-24). Callers skip +// kachinuki, which ends on End match instead. +export function unfinishedTeamBouts({ subs, teamSize }) { + const out = []; + for (let bout = 1; bout <= teamSize; bout++) { + const row = (subs || []).find(s => s && s._pos === bout); + if (!subBoutHasBeenPlayed(row)) out.push(bout); + } + return out; +} + +// unfinishedTeamBoutsMessage: the refusal copy, word for word the server's +// (unfinishedTeamBoutsMessage in internal/mobileapp/team_finish_gate.go; both +// are pinned to internal/mobileapp/testdata/unfinished_team_bouts.json). It +// names the bouts and what to record, and never mentions the lineup: a vacancy +// is legitimate play, not something to complete. +export function unfinishedTeamBoutsMessage(teamSize, bouts) { + const labels = (bouts || []).map(b => `Bout ${b}${teamSize === 5 && b >= 1 && b <= 5 ? ` (${POS_LABELS_5[b - 1]})` : ""}`); + if (labels.length === 0) return ""; + const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`; + return `${list} ${labels.length === 1 ? "has" : "have"} no result. Record a score, a Tie, or a Fusensho before finishing.`; +} + // isKachinukiBoutMode: while a kachinuki encounter is being fought (not a // correction, no legacy daihyosen row) the modal's primary actions are // [Record bout] (a running write flagged kachinukiBoutFinal that the server @@ -241,9 +291,17 @@ export function isKachinukiBoutMode({ isKachinuki, isComplete, hasDaihyosen }) { // winner/decision cleared, bout log kept, more bouts addable). The backend // endpoint 400s non-kachinuki competitions, whose only sanctioned edit of a // finished result remains the correction path, so the button must not -// render for them at all. -export function canReopenKachinukiMatch({ isKachinuki, isComplete }) { - return !!isKachinuki && !!isComplete; +// render for them at all. Nor does it render on an encounter a withdrawal +// decided (recordedWithdrawal, admin_scoring_shared.jsx withdrawalInForce): +// that reopen also makes the withdrawn team eligible again, so it goes +// through RecordedWithdrawal, which states that consequence and asks why +// first, rather than one silent tap. The argument is required: a caller that +// forgets it must not quietly bring the silent path back. +export function canReopenKachinukiMatch({ isKachinuki, isComplete, recordedWithdrawal }) { + if (typeof recordedWithdrawal !== "boolean") { + throw new TypeError("canReopenKachinukiMatch: recordedWithdrawal is required"); + } + return !!isKachinuki && !!isComplete && !recordedWithdrawal; } // isKachinukiBoutRemovable: whether the [× Remove this bout] undo renders for @@ -630,8 +688,10 @@ export function fusenshoSideFromSub(sub) { // positions, but kachinuki appends bouts dynamically, so emitting unplayed // positions as 0–0 hikiwake would corrupt advancement (AdvanceKachinuki keys // off the LAST SubResult having an outcome) and inflate individual-draw -// standings. Fixed-position matches keep all positions: a 0–0 there is a -// legitimate hikiwake. +// standings. Team matches keep all positions on the wire, but every bout +// is fought (operator ruling 2026-09-24): an unplayed row goes out with +// decision "" rather than as a hikiwake, and Finish refuses while one +// remains (unfinishedTeamBouts, which asks this predicate per bout). // // This is THE single played-bout primitive for kachinuki (operator input // determines the bout outcome): the wire filter, the Record-bout gate, @@ -804,27 +864,17 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const [decisionSubmitting, setDecisionSubmitting] = useStateA(false); const [decisionErr, setDecisionErr] = useStateA(""); const [withdrawnPlayer, setWithdrawnPlayer] = useStateA(null); - // Audit reason collected when correcting a completed team match, or when - // closing out a REOPENED one: mirrors the ScoreEditorModal correction flow - // (same ReasonPrompt), and rides the completing write as correctionReason - // either way. One reason value, because the wire field is one field. + // Audit reason collected when correcting a completed team match: mirrors + // the ScoreEditorModal correction flow (same ReasonPrompt), and rides the + // completing write as correctionReason. const [correctionReason, setCorrectionReason] = useStateA(""); - // Which audit prompt owns the footer: "" | "correction" | "reopen". Both are - // the same ReasonPrompt and both ARE the confirm step of a high-stakes - // write, so exactly one may ever be on screen: a single selector makes that - // true by construction rather than by a pair of flags that must be kept - // from overlapping. + // Whether the correction's audit prompt owns the footer: "" | "correction". + // The prompt IS the confirm step of a high-stakes write. const [reasonPromptKind, setReasonPromptKind] = useStateA(""); - // mp-gmcg: [Reopen match] on a completed kachinuki match. Reopening is ONE - // TAP (operator ruling): the operator who ended a match by mistake is at the - // shiaijo with the competitors still standing there, so nothing may stand - // between them and getting back into the encounter. The justification is - // collected on the way OUT instead (see reopenReasonRequired below). - // 409s from the server ("not completed" / "downstream match already - // fought") surface inline; the court-busy 409 gets an actionable remedy - // panel instead of a dead end (reopenConflict). - const [reopenBusy, setReopenBusy] = useStateA(false); - const [reopenErr, setReopenErr] = useStateA(""); + // mp-gmcg: [Reopen match] on a completed kachinuki match, and (bc-tmfn) + // Clear withdrawal and reopen on a completed match a withdrawal decided: + // both run through useMatchReopen (reopenCtl, below), which owns the busy + // flag, the inline error, the court-busy remedy and the downstream confirm. // mp-gmcg: [Remove this bout] busy/error, kept separate from the reopen // channel above (that one is the completed-match flow; this is a running- // match empty-bout undo). @@ -843,15 +893,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // knows the real later results. const [editingDoneBoutIdx, setEditingDoneBoutIdx] = useStateA(-1); const editingDoneOriginalRef = useRefA(null); - // The structured court_busy 409, unpacked: { court, matchId, compId, - // message } describing the match ALREADY RUNNING on this court. Non-null - // means the remedy panel is on screen. - const [reopenConflict, setReopenConflict] = useStateA(null); - // Best-effort "Shiro vs Aka" for the blocking match. The operator is about - // to wipe that match's score, so naming the competitors (not just the - // server's opaque match id) is a safety property, not decoration. Empty - // until/unless the lookup lands; the panel falls back to the id. - const [blockerLabel, setBlockerLabel] = useStateA(""); // T131: lineup data so each bout cell can show the assigned player // name + canonical position label. Falls back gracefully when the // lineup hasn't been submitted yet (404 → null). @@ -948,9 +989,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // and invisible on the wire. Hoisting the shared terms into this one const // makes that impossible: both callers see the same verdict-dirty answer by // construction. - const daihyosenVerdictDirty = + // + // daihyosenResultDirty is the same test WITHOUT the hantei arm, which is a + // mode rather than a result (bc-dscn): closing a running match flushes on + // it, so an arm alone never sends a freshly stamped write whose scoreline is + // unchanged, which could win last-write-wins over another device's older + // queued result. daihyosenVerdictDirty is built FROM it, so the two cannot + // disagree about anything but the arm. + const daihyosenResultDirty = enchoPeriodCount !== initialEnchoPeriods || - daihyosenHantei !== recordedDaihyosenSide || + daihyosenHantei !== recordedDaihyosenSide; + const daihyosenVerdictDirty = + daihyosenResultDirty || daihyosenHanteiArmed !== daihyosenHanteiRecorded; // The ONE hantei undo, shared by the Ht chip and the panel Cancel so the // two paths cannot drift. Like the pick buttons, it is LOCAL state only: @@ -976,7 +1026,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const _autosaveIsRunningRef = useRefA(false); const _autosaveBuildPatchRef = useRefA(null); const _autosaveOnSubmitRef = useRefA(null); - const { markDirty: markScoringDirty, cancelDebounce: cancelScoringDebounce } = useDebouncedRunningWrite({ + const { markDirty: markScoringDirty, cancelDebounce: cancelScoringDebounce, flushPending: flushScoringAutosave } = useDebouncedRunningWrite({ isRunningRef: _autosaveIsRunningRef, buildPatchRef: _autosaveBuildPatchRef, onSubmitRef: _autosaveOnSubmitRef, @@ -1530,10 +1580,80 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // result, so a KO encounter tied solely on 0–0 draws must read "DAIHYOSEN", // not pending ("-"). teamEncounterHasResult folds those draws in. const teamHasAnyScore = teamEncounterHasResult({ ivA, ivB, pwA, pwB, subTotals, daihyosenIdx }); - const teamVerdictText = teamResultLabel({ teamWinner, isKnockoutPhase, hasAnyScore: teamHasAnyScore, isKachinuki }); + // bc-kcsh: a match a recorded withdrawal (kiken/fusenpai) ended was decided + // by that ruling, not by its bouts, which stopped when it was recorded, so + // reading the band off them said DRAW over a won match. While the withdrawal + // is in force the band states the RECORDED result instead: the side the + // ruling leaves as the winner (the other side from withdrawnKeyOf, the same + // answer as the Recorded line and the header's Kiken/Fus. mark) in the + // band's own wording, and the decision under it (withdrawalLabel). That is + // also the result Save correction keeps (keepsWithdrawal writes m.winner). + // teamWinner itself is left alone (the Finish gates below read it, and + // Finish is refused while a withdrawal is in force anyway). + // + // bc-tmfn: IV/PW no longer "stay the bout-derived figures" while a + // withdrawal is in force -- every bout the withdrawal left with no result + // of its own IS a default win for the OTHER side, exactly as the header's + // Kiken/Fus. mark and every other surface (the viewer card's summary row, + // the TV headline, the list rows) already read it. The band below reads + // the SAME credited totals: `unscoredBouts`, computed once here, is fed + // to unfinishedTeamBoutsMessage's Finish-refusal gate below (which STILL + // never fires while a withdrawal is in force -- keepsWithdrawal blanks + // `unfinishedBouts`, not `unscoredBouts`) and to creditedTotals (the ONE + // owner of "what a credited bout is worth", team_default_credit.jsx), + // which awards the OTHER side from decisionBy, IV+1/PW+2 per bout. + const unscoredBouts = unfinishedTeamBouts({ subs, teamSize }); + const recordedWithdrawal = withdrawalInForce(m); + const withdrawalWinner = recordedWithdrawal ? ({ a: "b", b: "a" }[withdrawnKeyOf(m)] || null) : null; + const teamVerdictText = withdrawalWinner + ? teamResultLabel({ teamWinner: withdrawalWinner }) + : teamResultLabel({ teamWinner, isKnockoutPhase, hasAnyScore: teamHasAnyScore, isKachinuki }); // Block Finish while a KO encounter has no winner: the operator must add and // score a daihyosen first (the affordance below). Pool draws stay finishable. const koTieBlocked = isKoTieBlocked({ isKnockoutPhase, teamWinner, isComplete }); + // bc-tmfn: Finish (and Save correction: corrections are not exempt) refuses + // while a numbered bout has no result. Kachinuki ends on End match instead. + // The refusal is shown once the operator taps Finish, and then follows the + // board live: it names fewer bouts as they are scored and goes away when + // none remain. The server refuses the same write with the same words. + // A correction to a match a withdrawal ended is not refused: that write + // keeps the recorded kiken/fusenpai (operator ruling 2026-09-24, "Save + // correction should just save what the operator enters"), so the bouts + // nobody fought after it are not a finish (engine.KeepsWithdrawalRuling). + // Removing a withdrawal recorded by mistake is not a save at all: it is + // Clear withdrawal and reopen (RecordedWithdrawal), after which the match is + // running and every bout needs a result like any other. A kachinuki + // encounter a withdrawal decided gets the same line and control, in place + // of its plain Reopen (canReopenKachinukiMatch), but no Save correction, so + // keepsWithdrawal (which shapes that save) stays off for it. + const keepsWithdrawal = recordedWithdrawal && !isKachinuki; + // bc-tmfn: the band's IV/PW (ts.iv/ts.pw below, via teamSides) include the + // credited bouts while keepsWithdrawal holds. bc-cse: withdrawalInForce + // now DELEGATES to isTeamDefaultWinDecision (team_default_credit.jsx), so + // this is no longer narrower than the full default-win class -- it covers + // fusensho too, which the team editor CAN reach: BarredMatchNotice + // (mounted below) records a match-level fusensho for the opponent of a + // competitor already barred elsewhere. keepsWithdrawal stays !isKachinuki + // because that credit never applies to kachinuki (team_default_credit.jsx's + // own exclusion); it reuses the SAME condition that already governs the + // header's Kiken/Fus. mark (WithdrawalMarkedName) and the Recorded line + // (RecordedWithdrawal) here. + if (keepsWithdrawal) { + const credit = creditedTotals(unscoredBouts.length, creditedSideKey(m)); + ivA += credit.ivA; ivB += credit.ivB; pwA += credit.pwA; pwB += credit.pwB; + } + const unfinishedBouts = (isKachinuki || keepsWithdrawal) ? [] : unscoredBouts; + const [finishRefused, setFinishRefused] = useStateA(false); + const finishRefusal = finishRefused && unfinishedBouts.length > 0 + ? unfinishedTeamBoutsMessage(teamSize, unfinishedBouts) + : ""; + // Returns true when Finish must not proceed, and surfaces why. + const refuseUnfinishedFinish = () => { + if (unfinishedBouts.length === 0) return false; + setFinishRefused(true); + setFinishArmed(false); + return true; + }; // While a kachinuki match is being fought, the primary actions record // the current BOUT (running write + kachinukiBoutFinal flag) or END the // match (operator-led, mp-gmcg): completion is never inferred from @@ -1543,28 +1663,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // legitimate; the match is not being completed); End match has its own // knockout-tie gate via deriveKachinukiEndOutcome. const kachinukiBoutMode = isKachinukiBoutMode({ isKachinuki, isComplete, hasDaihyosen }); - // mp-gmcg: the server stamps `reopenPending` on a match it reopened for a - // mistake and REJECTS the write that completes it again unless that write - // carries a correctionReason (400, field correctionReason). So the reason - // the reopen skipped is demanded here, on [End match]. - // - // This MUST be driven off the server field, never off local state: the - // editor mounts per match, so an operator who reopens, walks away to another - // court and comes back has no local memory of the reopen at all — which is - // precisely the case this design exists to survive. m.reopenPending survives - // it because the match carries it. - const reopenReasonRequired = !!m.reopenPending; - // The two audit prompts that can take over the footer, derived from the one - // reasonPromptKind selector so only one Cancel/Confirm row may ever be on - // screen. Each also hides the footer's own nav+actions below. The extra - // condition on each is the state that makes that prompt meaningful at all, - // so a stale kind (e.g. the match completing underneath an open prompt) - // closes itself rather than hanging over a footer it no longer belongs to. + // The audit prompt that can take over the footer (a correction's reason), + // from the reasonPromptKind selector. It also hides the footer's own + // nav+actions below. isComplete is the state that makes it meaningful at + // all, so a stale kind closes itself rather than hanging over a footer it no + // longer belongs to. Ending a REOPENED match asks for no reason (operator + // ruling 2026-09-25: a match can be reopened without any reason, and nothing + // is gated on that), so there is no second prompt for it. const correctionPromptOpen = reasonPromptKind === "correction" && isComplete; - // kachinukiBoutMode because this prompt belongs to [End match] specifically: - // that keeps kachinukiEndOutcome non-null wherever the prompt commits it, - // and closes the prompt if the encounter completes underneath it. - const reopenPromptOpen = reasonPromptKind === "reopen" && reopenReasonRequired && kachinukiBoutMode; // Rows to render: kachinuki shows only bouts that exist in the server log // (kachinukiVisiblePositions handles the bout-1 bootstrap, the running // current-bout selection, the correction show-all branch, and the @@ -1812,149 +1918,28 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub useEffectA(() => { setFinishArmed(false); setEndArmed(false); setReasonPromptKind(""); }, [operatorEditSeq, daihyosenHantei]); // mp-gmcg: reopen a completed kachinuki match (POST .../reopen): status - // back to running, winner/decision cleared, bout log kept. This POST is a - // direct API call, NOT a score write, so it does NOT flow through the host's - // onEditScore/setOpenMatch channel that keeps the modal live across a - // running write — the `match` prop here is the host's openMatch snapshot, - // captured completed, and nothing repaints it in place. So on success we - // CLOSE the editor: the host's SSE match_updated handler flips the match - // row to running, and the operator taps Score to resume bout-by-bout on the - // now-running encounter. The server's 409s are full-sentence messages ("... - // is not completed ..." / "cannot reopen: a downstream knockout match ...") - // and surface inline verbatim, never silently; the court-busy one carries a - // remedy panel with them (below). - // - // NO REASON IS ASKED FOR HERE (operator ruling): the tap posts. The - // justification rides the write that closes the encounter back out - // (reopenReasonRequired above), which is where the operator knows what - // actually happened anyway. + // back to running, winner/decision cleared, bout log kept; and (bc-tmfn) + // the same door for Clear withdrawal and reopen (RecordedWithdrawal below). + // This POST is a direct API call, NOT a score write, so it does NOT flow + // through the host's onEditScore channel. Neither door closes the editor: + // the hosts resolve the open match from live data (the Scores tab, the + // pools page, the bracket page, the court console), so the server's + // match_updated broadcast follows it to running in place, where the + // operator carries on (the kachinuki Reopen) or scores the rest and + // finishes it (Clear withdrawal and reopen), and where the ReopenFeedback + // below can still show what else the reopen reopened. Closing the kachinuki + // editor on success, as it used to, hid that notice at the moment it had + // something to say. useMatchReopen (admin_scoring_shared.jsx) owns the + // rest: the server's 409s surface inline verbatim, the court-busy one with + // its remedy panel, and a later knockout match with its own result is + // confirmed with the operator before it is reopened too. // - // A reopen failure is either actionable here or a sentence to read. The - // court-busy 409 is the actionable one: it names the match holding the - // court, so the operator can clear it from this panel instead of being told - // to go somewhere else. Note the asymmetry that makes this mandatory: a - // plain correction bypasses the court gate entirely, so kachinuki operators - // — for whom reopen is the ONLY way to fix a bout log — would otherwise be - // the one group with no way out of a busy court. - const applyReopenFailure = (e) => { - const msg = String(e?.message || "Failed to reopen match"); - if (e?.code === "court_busy" && e?.matchId) { - setReopenErr(""); - setReopenConflict({ - court: e.court || m.court || "", - matchId: e.matchId, - compId: e.compId || m.compId, - message: msg, - }); - return; - } - setReopenConflict(null); - setReopenErr(msg); - }; - - // Once the match reopens (status flips to running / bout mode), drop any - // stale reopen error or court-busy conflict and re-enable the control: they - // describe a completed-state action that no longer applies. Without this a - // failed-then-succeeded reopen could leave an error lingering in bout mode - // (the inline variant does not remount on the transition). - useEffectA(() => { - if (!isComplete) { setReopenErr(""); setReopenConflict(null); setReopenBusy(false); } - }, [isComplete]); - - const onReopenMatch = async () => { - if (reopenBusy) return; - setReopenErr(""); - setReopenConflict(null); - setReopenBusy(true); - try { - await window.API.reopenMatch(m.compId, m.id, resolveDecisionPassword(password)); - if (!mountedRef.current) return; - // Success: KEEP the button disabled (reopenBusy stays true). onClose is a - // no-op in the inline (shiaijo) variant, so the completed snapshot lingers - // through the SSE refetch window; re-enabling now would let a double-tap - // fire a second reopen that the server rejects ("not completed", 409). The - // refetch flips the match to running, and the effect above clears - // reopenBusy. The overlay variant unmounts on onClose, so this is moot. - onClose(); - } catch (e) { - if (!mountedRef.current) return; - applyReopenFailure(e); - setReopenBusy(false); - } - }; - - // The remedy: send the blocking match back to the queue, then retry the - // reopen the conflict refused. DESTRUCTIVE — revert-to-queue clears that - // match's partial score — which is why the panel spells the consequence out - // before this can be tapped. - const requeueBlockerAndReopen = async () => { - const c = reopenConflict; - if (!c || reopenBusy) return; - setReopenErr(""); - setReopenBusy(true); - try { - // ONE atomic server call (mp-gmcg review A4): requeue the blocker AND - // reopen the target under a single court lock, closing the race the old - // two-call revert-then-reopen had (a peer could take the freed court in - // between). A court_busy failure means a DIFFERENT match has since taken - // the court; applyReopenFailure re-offers the remedy for that one. - await window.API.requeueBlockerAndReopen(m.compId, m.id, c.compId, c.matchId, resolveDecisionPassword(password)); - if (!mountedRef.current) return; - setReopenConflict(null); - onClose(); - } catch (e) { - if (!mountedRef.current) return; - // One atomic call now, so a single failure path: applyReopenFailure - // re-offers the remedy if a DIFFERENT match has since taken the court - // (court_busy), and otherwise shows the server's sentence — a completed - // or unknown blocker, a downstream-fought target, etc. The requeue and - // reopen commit together or not at all, so there is no partial state to - // describe separately. - applyReopenFailure(e); - } finally { - if (mountedRef.current) setReopenBusy(false); - } - }; - - // Name the blocking match's competitors when we can. Best effort by design: - // the server's match id is enough to ACT on, so a failed or unavailable - // lookup degrades to that rather than blocking the remedy. - useEffectA(() => { - setBlockerLabel(""); - if (!reopenConflict?.matchId || !reopenConflict?.compId) return; - if (typeof window.compMatchesForCompetition !== "function" || typeof window.API?.fetchCompetitionDetails !== "function") return; - let cancelled = false; - (async () => { - try { - const detail = await window.API.fetchCompetitionDetails(reopenConflict.compId); - if (cancelled || !mountedRef.current) return; - // compMatches expects a competition in LIST shape, where `status` and - // `teamMatchType` sit at the TOP level. The details payload keeps those - // under `config`, and compMatches bails on a missing status via its - // "setup" guard, so passing `detail` straight in always returned [] and - // every conflict panel fell back to naming the blocker by its raw id - // ("Shiaijo A is running m-r1-1"), which tells an operator nothing. - // - // compMatchesForCompetition (viewer_utils.jsx) owns that recombination - // now. This site had its own copy, and the withdrawal panel in - // admin_scoring_shared.jsx had none at all and silently listed nothing, - // which is why the rule was given one home (mp-dej2). - const hit = (window.compMatchesForCompetition(detail.config || detail, detail) || []).find(x => x.id === reopenConflict.matchId); - if (!hit) return; - const shiro = hit.sideB?.name || hit.sideB || ""; - const aka = hit.sideA?.name || hit.sideA || ""; - // Match number first: it is how the console labels every other match - // ("09:16 · KTeam · Match 2"), so it is what the operator is looking for. - const label = [ - hit.matchNumber ? `Match ${hit.matchNumber}` : "", - shiro && aka ? `${shiro} vs ${aka}` : "", - ].filter(Boolean).join(" · "); - if (label) setBlockerLabel(label); - } catch { /* best effort: the panel falls back to the match id */ } - })(); - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reopenConflict?.compId, reopenConflict?.matchId]); + // The kachinuki Reopen asks for NO REASON (operator ruling): the tap posts, + // and ending the encounter again asks for none either. Note the asymmetry that makes the court-busy + // remedy mandatory: a plain correction bypasses the court gate entirely, so + // kachinuki operators, for whom reopen is the ONLY way to fix a bout log, + // would otherwise be the one group with no way out of a busy court. + const reopenCtl = useMatchReopen({ match: m, password, isComplete }); // mp-4pc: when a daihyosen exists the encho counter belongs to that // sub-bout (attached per-sub in buildPatch), so suppress the top-level @@ -2108,8 +2093,23 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const openDoneBoutEdit = (idx) => { const t = subTotals[idx]; editingDoneOriginalRef.current = { winner: t ? t.winner : null }; + doneBoutOpenedAtRef.current = Date.now(); setEditingDoneBoutIdx(idx); }; + // bc-kbrw: opening a fought bout expands it under the finger, so the second + // tap of a double tap landed on whatever moved there: another bout, or a + // control of the row just opened. For DONE_BOUT_OPEN_TAP_GUARD_MS after a + // row opens, a pointer click anywhere in the bout list is swallowed in the + // capture phase, before it reaches any row. A ref, not state, so a batched + // double tap cannot read a stale value. Keyboard activation is never + // swallowed: a click synthesized from Enter/Space carries detail === 0. + const doneBoutOpenedAtRef = useRefA(0); + const swallowDoubleTapAfterOpen = (ev) => { + if (ev.detail === 0) return; + if (Date.now() - doneBoutOpenedAtRef.current >= DONE_BOUT_OPEN_TAP_GUARD_MS) return; + ev.stopPropagation(); + ev.preventDefault(); + }; const closeDoneBoutEdit = () => { editingDoneOriginalRef.current = null; setEditingDoneBoutIdx(-1); }; // mp-gmcg: the non-destructive warning shown beneath a past bout opened for @@ -2249,10 +2249,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const w = wKey === "a" ? m.sideA : wKey === "b" ? m.sideB : null; // T096/FR-031: per-bout fusensho overrides the default hikiwake/fought // mapping. The daihyosen always carries decision="daihyosen". + // A bout nobody has fought yet stays decision "": it is not a hikiwake, + // and writing one made an untouched row indistinguishable from an + // operator-marked Tie on the server and on the next open, which is + // what let a team match finish with bouts unfought (bc-tmfn). let decision = ""; if (isDaihyo) decision = "daihyosen"; else if (s.fusensho) decision = "fusensho"; - else if (t.winner === null) decision = "hikiwake"; + else if (t.winner === null && subBoutHasBeenPlayed(s)) decision = "hikiwake"; const teamWinnerName = nameOf(w); // Competition-type-aware sub-bout identity: a kachinuki bout is consumed // per-competitor (advancement + bout-log export), so persist player-name @@ -2380,8 +2384,8 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub }); // Kachinuki appends bouts dynamically, so the all-positions map above leaves // untouched trailing positions. Drop them (keep the daihyosen and any played - // bout): see subBoutHasBeenPlayed. Fixed-position matches keep every - // position because a 0–0 there is a legitimate hikiwake. + // bout): see subBoutHasBeenPlayed. Team matches keep every position; an + // unplayed one carries decision "" and Finish refuses until it has a result. if (isKachinuki) { subResults = subResults.filter((_entry, idx) => idx === daihyosenIdx || subBoutHasBeenPlayed(subs[idx])); } @@ -2390,12 +2394,15 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // null + hikiwake here while the sub row keeps its verdict silently // flipped a pool encounter's W/L/T to a draw (knockout saves bounced off // "cannot mark completed with no winner"). - const winner = teamWinner === "a" ? m.sideA : teamWinner === "b" ? m.sideB : (dhKeep ? (m.winner || null) : null); - // correctionReason rides any write that AMENDS a finalized result: a - // correction to a completed match, and (mp-gmcg) the write that completes - // a REOPENED one, which the server refuses without it. Same field, same - // audit trail, two ways in. - const correctionBlock = (isComplete || reopenReasonRequired) && correctionReason ? { correctionReason } : {}; + // A correction that keeps a recorded withdrawal (keepsWithdrawal) keeps its + // winner too: the server stores the ruling's winner whatever the bouts + // say, so this write names it rather than the bouts' leader, and the + // shiaijo page's local bracket advance (maybeAdvanceLocal) cannot move the + // team that withdrew into the next round. + const winner = keepsWithdrawal ? (m.winner || null) + : teamWinner === "a" ? m.sideA : teamWinner === "b" ? m.sideB : (dhKeep ? (m.winner || null) : null); + // correctionReason rides a correction to a completed match. + const correctionBlock = isComplete && correctionReason ? { correctionReason } : {}; // When transitioning to "running" (▶ Start), teamWinner is typically // null (0–0). Don't emit score.type: "hikiwake": toBackendMatchResult // maps score.type to decision, which would persist a draw decision on @@ -2465,7 +2472,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // slots - labelling the loser's IV as the winner's whenever the two // differ. max/min cannot name the side either, but it can never invert // them, and in every reachable daihyosen IV is tied so both agree. - score: { type: (teamWinner || dhKeep) ? "ippon" : "hikiwake", winnerPts, loserPts, fouls: { a: 0, b: 0 }, corrected: isComplete }, + score: { type: (teamWinner || dhKeep || keepsWithdrawal) ? "ippon" : "hikiwake", winnerPts, loserPts, fouls: { a: 0, b: 0 }, corrected: isComplete }, subResults, ...enchoBlock(), ...correctionBlock, @@ -2516,6 +2523,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // repetition on the busiest path in the file. const serverSubsSig = JSON.stringify(serverSubs); const isDirty = JSON.stringify(subs) !== serverSubsSig || daihyosenVerdictDirty; + // bc-dscn: what closing a RUNNING match may flush. isDirty without the + // hantei arm (see daihyosenResultDirty). + const scoringDirty = JSON.stringify(subs) !== serverSubsSig || daihyosenResultDirty; + // bc-dscn: an edit the running patch would NOT carry. Under kachinuki + // buildPatch drops every row subBoutHasBeenPlayed rejects, so a changed but + // unplayed row (a fighter picked on the current bout past the first, which + // rides the bout and not a lineup PUT, or a bout cleared back to 0-0) never + // reaches the server by a flush. Closing would lose it, so it keeps the + // prompt. Same per-row server comparison isDirty makes: subs is aligned to + // serverSubs by reconcileRowsToPositions, so index idx is the same position. + const runningPatchDropsAnEdit = isKachinuki && subs.some((s, idx) => + idx !== daihyosenIdx && !subBoutHasBeenPlayed(s) && JSON.stringify(s) !== JSON.stringify(serverSubs[idx])); // RE-SEED the bout rows when the stored result moves, PER ROW. // // Without this an editor left open kept showing the board it opened with @@ -2585,6 +2604,23 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // Same contract as ScoreEditorModal: never close while a save, // decision, or daihyosen request is mid-flight. if (submitting || decisionSubmitting || daihyosenBusy) return; + // bc-dscn: a host that cannot close (the inline court console) has nothing + // to discard INTO, so it never prompts either. + if (!canClose) return; + // bc-dscn: on a RUNNING match every scoring edit is autosaved, so closing + // discards nothing: save any edit still inside the debounce window now and + // close without asking. buildPatch("running") carries what scoringDirty + // compares: every played bout row in subResults, match-level encho, and + // the daihyosen verdict (encho count and hantei pick via + // daihyosenEnchoFields). Not carried, and handled here: a hantei ARM with + // no side picked is only a mode, not a result, so it is dropped with no + // write (the individual editor's rule too); and a kachinuki row the + // played-row filter drops (runningPatchDropsAnEdit) keeps the prompt. + if (m.status === "running" && !runningPatchDropsAnEdit) { + if (scoringDirty) flushScoringAutosave(); + onClose(); + return; + } if (isDirty && !(await window.confirmDialog({ message: "Discard unsaved scoring changes?", confirmLabel: "Discard changes", danger: true }))) return; onClose(); }; @@ -2594,16 +2630,22 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // unambiguous target — see scoreCurrentBoutWaza); fixed-format team scoring // is many sub-matches and stays tap-only, and Enter-to-finish isn't wired. const kbRef = React.useRef(null); - kbRef.current = { submitting, handleDismiss, onPrev, onNext, kachinukiBoutMode, isNaginataTeam, scoreCurrentBoutWaza }; + kbRef.current = { submitting, handleDismiss, canClose, onPrev, onNext, prevMatch, nextMatch, kachinukiBoutMode, isNaginataTeam, scoreCurrentBoutWaza }; useEffectA(() => { const onKeyDown = (ev) => { const s = kbRef.current; if (s.submitting) return; if (ev.ctrlKey || ev.metaKey || ev.altKey) return; - if (ev.key === "Escape") { ev.preventDefault(); s.handleDismiss(); return; } + // bc-dscn: Esc closes only where the host can close. On the inline court + // console it belongs to whatever has focus (e.g. an open fighter list), + // so it is left unhandled and not prevented. + if (ev.key === "Escape") { if (!s.canClose) return; ev.preventDefault(); s.handleDismiss(); return; } if (window.isTextEntry(ev.target)) return; - if (ev.key === "ArrowLeft" && s.onPrev) { ev.preventDefault(); s.onPrev(); return; } - if (ev.key === "ArrowRight" && s.onNext) { ev.preventDefault(); s.onNext(); return; } + // Keyed on the neighbour match as well as the callback, as in + // ScoreEditorModal: the Scores tab wires onPrev/onNext unconditionally, + // and with no neighbour they call scoreKeyOf(null), which throws. + if (ev.key === "ArrowLeft" && s.onPrev && s.prevMatch) { ev.preventDefault(); s.onPrev(); return; } + if (ev.key === "ArrowRight" && s.onNext && s.nextMatch) { ev.preventDefault(); s.onNext(); return; } // mp-gmcg: keyboard ippon entry, KACHINUKI bout mode only (one current // bout → unambiguous target; the general team editor has many). Mirrors // the individual editor: blocked when any interactive element @@ -2678,9 +2720,11 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub board now reads one way. */} {/* Team number chip: owned by numbered_name.jsx - (the outer-side rule lives there). */} + (the outer-side rule lives there). A recorded + withdrawal's Kiken/Fus. rides beside the withdrawn team + (WithdrawalMarkedName, bc-kcsh), never in the centre. */}
- +
{idx === 0 && ( @@ -2718,7 +2762,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub The .team-bouts-scroll wrapper gives the roomy (non-compact) layout an independent scroll region for the bout list so the team header / summary / decision / footer stay anchored. */} -
+
{[ // mp-gmcg: operator-led completion. The banner reads "ended" // ONLY for a completed match (correction view): a running @@ -3289,9 +3333,15 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub ) : ( <>
RESULT
-
{teamVerdictText}
+
{teamVerdictText}
)} + {/* bc-kcsh: the recorded decision under the verdict + while a withdrawal is in force (see + teamVerdictText). */} + {recordedWithdrawal && ( +
{withdrawalLabel(m.decision)}
+ )}
)} @@ -3476,6 +3526,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub active bout. Native
keeps the buttons in the DOM (still queryable/testable); it just reclaims the ambient attention the scoring task should own. */} + {/* Correcting a match a withdrawal ended: say what is recorded, and + offer to remove it when it was a wrong entry (operator ruling + 2026-09-24), by reopening the match. Switching it to the other + team stays with the Withdrawal or no-show controls below. The + same component serves the individual editor. */} + {recordedWithdrawal && !decisionPromptKind && !withdrawnPlayer && !selfReport && ( + + )} {!withdrawnPlayer && !decisionPromptKind && !selfReport && (
Withdrawal or no-show (kiken · fusenpai) @@ -3507,6 +3565,25 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub {decisionErr && (
{decisionErr}
)} + {/* bc-tmfn: DecisionPrompt (admin_scoring_shared.jsx) has no slot for + extra copy -- its side radio is local state the parent never + sees, so the credited TEAM cannot be named before the operator + picks a side inside the form. Stated generically instead, right + where the operator commits: the side they name here is the one + that stayed and is credited, not the one they pick as withdrawn + (the radio labels "Which side withdrew?"). Silent when nothing + is left to credit (every bout already has a result), and on a + KACHINUKI encounter (bc-cse): team_default_credit.jsx excludes + kachinuki from the default-win credit on purpose (bouts are + appended one at a time and the encounter ends on an explicit + End match, never a match-level decision standing in for + unplayed slots), so this note would promise a credit the + server never applies. */} + {decisionPromptKind && !isKachinuki && unscoredBouts.length > 0 && ( +
+ A withdrawal credits the other team with a default win, 2–0, in {unscoredBouts.length === 1 ? "the 1 bout" : `each of the ${unscoredBouts.length} bouts`} with no result yet. +
+ )} {decisionPromptKind && ( { setDecisionPromptKind(""); setDecisionErr(""); }} onSubmit={({ decisionBy, decisionReason }) => submitDecision(decisionPromptKind, { decisionBy, decisionReason })} @@ -3569,40 +3640,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub onCancel={() => setReasonPromptKind("")} /> )} - {/* mp-gmcg: the same audit prompt on the way OUT of a reopen. The - reopen itself was one tap; this write is the one that re-finalizes - the result, so this is where the justification is owed — and the - server enforces it (400 correctionReason on a completing write - while reopenPending). Its Confirm IS the commit, so the verdict - being committed is shown above it (the End button's armed label is - hidden while a prompt owns the footer). setCorrectionReason is - async, so the confirmed string is spread onto the patch directly - rather than read back through buildPatch. */} - {reopenPromptOpen && ( - <> -
- Ending this match: {kachinukiEndOutcomeLabel(kachinukiEndOutcome)} -
- { - setCorrectionReason(r); - setReasonPromptKind(""); - const patch = { ...buildPatch("completed", { endOutcome: kachinukiEndOutcome }), correctionReason: r }; - doSubmit(() => onSubmit(patch)); - }} - onCancel={() => setReasonPromptKind("")} - /> - - )} {/* While a reason prompt is open it owns the only Cancel/commit row: hide the footer's own nav+actions so the operator never sees two Cancels and two commit buttons at the highest-stakes moment (amending or discarding a recorded result). Mirrored in Score/EngiScoreEditorModal. */} - {!(correctionPromptOpen || reopenPromptOpen) && ( + {!correctionPromptOpen && ( <> {/* mp-gmcg: inline End-match hint (plain text, no modal). Shown while End is blocked (nothing scored yet, or a knockout tie: @@ -3629,6 +3672,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub )}
)} + {/* bc-tmfn: why Finish did not finish. Visible text, not a title + (a tablet has no hover), and only after a Finish tap, so a + match in progress is not nagged about bouts still to come. */} + {finishRefusal && ( +
+ {finishRefusal} +
+ )} {/* mp-gmcg: inline reason for the disabled [Record bout]. The tired operator sees the greyed button but the "why" was tooltip-only, unreachable on a tablet (critique P2). Shown only in the common @@ -3639,53 +3690,17 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub Score this bout to enable Record bout, or End match to finish on the last scored bout.
)} - {reopenErr && ( -
{reopenErr}
- )} - {/* mp-gmcg: court-busy remedy. A busy court used to be a dead end - for the one group that has no alternative (a correction bypasses - the court gate; a kachinuki bout log can only be fixed by - reopening). So name the match holding the court and offer to - clear it from here. - - The warning is NOT optional chrome: revert-to-queue WIPES that - match's partial score, and the operator tapping this is looking - at their own match, not that one. It is stated in full, in the - danger palette, above the button — never hidden behind a tooltip - or implied by a red button. */} - {reopenConflict && ( -
-
- Shiaijo {reopenConflict.court || "?"} is running {blockerLabel || reopenConflict.matchId}. -
-
- Sending it back to the queue clears any score already entered for it. Finishing that - match instead keeps its score. -
-
{reopenConflict.message}
-
- - -
-
- )} + {/* mp-gmcg: a reopen's outcome: its notice, its error, and the + court-busy remedy (a busy court used to be a dead end for the one + group that has no alternative: a correction bypasses the court + gate; a kachinuki bout log can only be fixed by reopening). ONE + copy for both doors, the kachinuki Reopen and Clear withdrawal + and reopen, and outside both: RecordedWithdrawal unmounts when + the match is running again, so a notice rendered inside it was + never seen (bc-tmfn). The prefix names the editor's door; a + kachinuki encounter keeps its existing ids whichever control + reopened it. */} + {/* F5: "not saved" banner, shared class with ScoreEditorModal so both editors read identically. Placed once, above score-nav, which `inner` shares between the wide overlay and the narrow shiaijo @@ -3704,12 +3719,24 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub Not saved: {writeFailed.reason}. {writeFailed.advice || "Re-enter the result and submit again."}
)} + {/* bc-cse: a barred match cannot be started as scheduled -- the + server would just refuse it -- so the ONE component that shows + why and offers the default-win/reinstate resolution + (BarredMatchNotice, admin_scoring_shared.jsx) stands where Start + would be. Lifted ABOVE .score-nav__actions (a centred wrapping + flex row of small buttons): the notice's own note text plus its + action buttons were squeezed into one flex item there instead + of reading as the full-width block it is everywhere else this + component renders. */} + {m.status === "scheduled" && isBarredMatch(m) && ( + + )}
{prevMatch ? ( ) : }
- {m.status === "scheduled" && ( + {m.status === "scheduled" && !isBarredMatch(m) && ( )} {canClose && } @@ -3816,13 +3843,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub )} ) : (
)} - {/* Quiet, always-present keyboard-shortcut reminder. */} - + {/* Quiet keyboard-shortcut reminder. It lists only keys that act on + this host (the same conditions the keydown handler checks). */} +
); diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 96a05bceb..6b995698c 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -35,6 +35,19 @@ import { NumberedName } from './numbered_name.jsx'; // mp-jnvl: the recency rule is shared with the public viewer's Recent // results, so it lives in its own leaf rather than in this page. import { resultRecencyDesc } from './result_recency.jsx'; +// bc-cse: a scheduled match a competitor is barred from (withdrew earlier) +// cannot be fought, so no auto-pick may offer it and the queue row shows the +// default-win action instead of Start. One leaf owns the question +// (ineligible_match.jsx); BarredMatchNotice (admin_scoring_shared.jsx) is the +// one component that renders the note plus that action across every surface. +import { isBarredMatch } from './ineligible_match.jsx'; +// Straight from its own leaf, NOT admin_scoring_shared.jsx: that module also +// imports bracket.jsx (for sideMarks), and admin_shiaijo.jsx's render suite +// stubs window.BracketTree before importing this file -- routing through +// admin_scoring_shared.jsx pulled bracket.jsx's module body in ahead of that +// stub taking effect and silently overwrote it. See barred_match_notice.jsx's +// header. +import { BarredMatchNotice } from './barred_match_notice.jsx'; const { useState: useStateSh, useMemo: useMemoSh, useEffect: useEffectSh, useRef: useRefSh, useCallback: useCallbackSh } = React; @@ -556,6 +569,19 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const wname = _bracketSideName(w); if (wname) applyLocalBracketWin(match.compId, match.id, wname); }, [applyLocalBracketWin]); + // The last Start the server refused, as { key, compId, msg } for the match + // it was refused for (null = none). It is shown under Up next only while + // that match IS Up next, and it is dropped once it may no longer apply: + // whenever Up next changes to a different match, whichever match the + // refusal was for (the effect beside upNext below), and when any + // competitor's eligibility changes in that competition (the + // competitor_status_updated handler just below), which is what restoring + // a withdrawn competitor broadcasts. A refusal that + // still applies comes straight back on the next tap. Before this it was a + // bare string nothing cleared, so "kiken-voluntary at Pool A-2" outlived + // the restore and then sat under the NEXT match (UAT, bc-tmfn). Declared + // above the feed effect that clears it. + const [startError, setStartError] = useStateSh(null); useEffectSh(() => { if (!court || !window.API || typeof window.API.fetchCourtMatches !== "function") return; let cancelled = false; @@ -582,6 +608,14 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const off = window.API.subscribeToEvents( (event) => { if (cancelled || !event || !REFRESH_EVENTS.has(event.type)) return; + if (event.type === "competitor_status_updated") { + // Eligibility moved in that competition (a withdrawal + // cleared, a competitor reinstated): a Start refused + // there may now succeed, so the refusal stops claiming + // otherwise. An event naming no competition clears too. + const cid = event.data && event.data.competitionId; + setStartError((prev) => (prev && (!cid || prev.compId === cid) ? null : prev)); + } scheduleRefresh(); }, onStatus @@ -608,7 +642,6 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const [calledKey, setCalledKey] = useStateSh(null); const [callingKey, setCallingKey] = useStateSh(null); const [startingKey, setStartingKey] = useStateSh(null); - const [startError, setStartError] = useStateSh(""); const [contextOpen, setContextOpen] = useStateSh(true); // The whole queue column folds away so the scorer can take the full width; // the choice is per device, like the operator's other console preferences. @@ -668,7 +701,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // with the Scores page's "Correct" button). Kept separate from pickedKey so // it takes priority over the running bout WITHOUT disturbing it, and so it // never interferes with the finish-advance fallback that pickedKey drives. - // Cleared by "Back to court". null = not correcting anything. + // Cleared by "Back to court", and handed over to pickedKey once the match + // is reopened (see the effect beside correctingMatch below): a running + // match is the live bout, not a correction. null = not correcting anything. const [correctingKey, setCorrectingKey] = useStateSh(null); // Pending court reassignment, awaiting operator confirmation. Moving a match // off this shiaijo is disruptive (it leaves the court and joins another's @@ -793,16 +828,40 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, () => pickedKey ? sorted.find((x) => matchKey(x) === pickedKey && x.status !== "completed") || null : null, [pickedKey, sorted] ); - // The completed match being corrected. NO status filter: it must stay - // selected across a kachinuki Reopen (completed → running) and the fresh - // End (running → completed) so the operator never loses the panel - // mid-correction. "Back to court" clears correctingKey to fall back to the - // live bout. Takes priority over the running match so the panel follows the - // deliberate correction the operator asked for. + // The completed match being corrected. Takes priority over the running + // match so the panel follows the deliberate correction the operator asked + // for. "Back to court" clears correctingKey to fall back to the live bout. + // No status filter here, on purpose: a filter would leave the key set, and + // the key would re-pin the match the moment it completed again. The + // effect below ends the correction instead. const correctingMatch = useMemoSh( () => correctingKey ? sorted.find((x) => matchKey(x) === correctingKey) || null : null, [correctingKey, sorted] ); + // A correction that is REOPENED (a kachinuki Reopen, or Clear withdrawal + // and reopen: completed -> running) is no longer a correction of a past + // result. It is the bout being fought on this court, so it becomes the + // live match: the pick takes it over (same key, so the editor does not + // remount and nothing entered is lost) and the correction ends. Without + // this, Finish + Start Next left the panel pinned on the old match in + // CORRECTION mode with the match it had just started hidden until "Back + // to court" (UAT, bc-tmfn). Keyed on the status VALUE, never the match + // object, which every refetch re-creates. + // + // A cleared match-level fusensho whose competitor is still barred + // reopens to "scheduled" instead (engine.reopenTargetStatus): there is + // no bout to pick, but the correction still ends, so the panel returns + // to the court's ordinary view rather than staying pinned to it. + const correctingStatus = correctingMatch ? correctingMatch.status : null; + useEffectSh(() => { + if (!correctingKey) return; + if (correctingStatus === "running") { + setPickedKey(correctingKey); + setCorrectingKey(null); + } else if (correctingStatus === "scheduled") { + setCorrectingKey(null); + } + }, [correctingKey, correctingStatus]); const selectedMatch = useMemoSh(() => correctingMatch || pickedMatch || running[0] || null, [correctingMatch, pickedMatch, running]); // For pool daihyosen/tiebreaker bouts, enrich the selected match with @@ -880,8 +939,33 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, [showAllCompleted, filteredCompleted] ); - // Up Next = the first scheduled match in the selected competition. - const upNext = filteredScheduled[0] || null; + // Up Next = the first scheduled match in the selected competition that + // can actually be fought. A barred match (isBarredMatch, bc-cse) is + // skipped by every auto-pick: the operator resolves it directly from its + // queue row (the default-win action, or Reinstate) rather than having it + // offered as the next thing to start. + const upNext = filteredScheduled.find((m) => !isBarredMatch(m)) || null; + // Everything else in Upcoming: the whole scheduled list minus whichever + // match became Up Next (by key, not index: Up Next may not be [0] when a + // barred match sits ahead of it). Any barred match stays here, rendered + // as a normal row that shows its own resolution instead of Start. + const upcomingQueueMatches = upNext + ? filteredScheduled.filter((m) => matchKey(m) !== matchKey(upNext)) + : filteredScheduled; + // A Start refusal describes ONE match at one moment. Whenever Up next + // changes to a different match (that one started, was moved, the operator + // switched competition, or the court moved on), any stored refusal is + // dropped, whichever match it was for. That includes a refusal for a match + // picked from further down the queue: when that match later reaches Up + // next, its cause (e.g. a competitor then fighting on another court) is + // usually gone, and nothing else would clear it (a finished match sends no + // competitor_status_updated). A refusal that still applies comes straight + // back on the next tap. Keyed on the key VALUE: a refetch that keeps the + // same Up next leaves a refusal for it where it is. + const upNextKey = upNext ? matchKey(upNext) : null; + useEffectSh(() => { + setStartError(null); + }, [upNextKey]); // "Which pool is next" for the context panel: the first upcoming pool on // this court (within the selected comp) whose pool differs from the one @@ -904,7 +988,11 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const pool = [...running, ...scheduled].filter((x) => x.compId === m.compId); const idx = pool.findIndex((x) => matchKey(x) === matchKey(m)); if (idx < 0) return null; - return pool.slice(idx + 1).find((x) => x.status !== "completed") || null; + // bc-cse: skip a barred match. Both Finish + Start Next and the + // after-decision advance feed this straight into a Start write, which + // the server would just refuse (409 ineligible_competitor); the + // barred match is left for its own queue row to resolve. + return pool.slice(idx + 1).find((x) => x.status !== "completed" && !isBarredMatch(x)) || null; }; // Amber nudge banner logic (AC6): fires ONLY when the SELECTED competition @@ -956,8 +1044,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // (a blocked-by-eligibility start must not steal the panel). const startMatch = async (m) => { if (startingKey) return false; - setStartError(""); + setStartError(null); setStartingKey(matchKey(m)); + const refusalFor = (msg) => ({ key: matchKey(m), compId: m.compId, msg }); try { // Starting makes the match running; the scoring panel shows // running[0], so it picks the match up on the next refetch. @@ -972,13 +1061,13 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // the operator that, instead of leaving a dead first tap. if (writeWasRefusedForClock(res)) { const msg = "Could not start: " + CLOCK_SKEW_REASON_TEXT + ". The clock has been resynced; try again."; - if (mountedRef.current) setStartError(msg); + if (mountedRef.current) setStartError(refusalFor(msg)); if (showToast) showToast(msg, "error"); return false; } return true; } catch (e) { - if (mountedRef.current) setStartError((e && e.message) || "Could not start the match: check eligibility and try again."); + if (mountedRef.current) setStartError(refusalFor((e && e.message) || "Could not start the match: check eligibility and try again.")); if (showToast) showToast((e && e.message) || "Could not start the match", "error"); return false; } finally { @@ -1054,16 +1143,19 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // done), so they must be able to fix it here rather than leaving for the // competition admin view. This deliberately does NOT touch the running bout: // a past-match correction must never defer or revert the live match. The - // editor then offers Reopen (kachinuki: back to running, keep the bout log) - // or a direct score re-write (other formats), same as the Scores page. + // editor then offers Reopen (kachinuki: back to running, keep the bout log; + // a withdrawal-decided match: Clear withdrawal and reopen) or a direct + // score re-write (other formats), same as the Scores page. A reopen makes + // the match the court's live bout and ends the correction (the effect + // beside correctingMatch). const correctMatch = (m) => { if (!m || m.status !== "completed") return; setCorrectingKey(matchKey(m)); }; // Leave the correction and return to the live court (running bout or the - // done state). Only offered on a COMPLETED correcting match: while it is - // reopened (running) the operator finishes via End match / Send back to - // queue, so a stray "Back to court" can't strand a result-less running bout. + // done state). Only offered on a COMPLETED correcting match: once it is + // reopened it is the live bout, finished via End match / Finish or sent + // back to the queue like any other. const stopCorrecting = () => setCorrectingKey(null); // Call to court: optional. Broadcasts a tournament announcement so the @@ -1152,10 +1244,11 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (showToast) showToast(`${label} sent back to queue`); setPendingRevert(null); setPickedKey(null); - // Release a correction pin too: reverting a REOPENED correction sends - // it back to scheduled, so keeping correctingKey would re-pin the panel - // to a now-scheduled match with no exit. Clearing it falls back to the - // live court. Harmless (no-op) when reverting a plain running bout. + // Release a correction pin too. A reopened correction hands its key + // to pickedKey (cleared above) once the refetch shows it running, but + // one sent back before that refetch landed would otherwise re-pin the + // panel to a now-scheduled match with no exit. Harmless (no-op) when + // reverting a plain running bout. setCorrectingKey(null); } catch (e) { if (mountedRef.current) { @@ -1407,7 +1500,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, )}
- {startError &&
{startError}
} + {startError && startError.key === matchKey(upNext) &&
{startError.msg}
}
{calledKey === matchKey(upNext) ? "Announced to spectators. Start the match when both are at the line." @@ -1421,14 +1514,15 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, scoring panel on the right, so repeating it in the queue is redundant. */} - {filteredScheduled.length > (upNext ? 1 : 0) && ( + {upcomingQueueMatches.length > 0 && ( )} @@ -1616,8 +1710,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, {/* Exit for the LIVE running bout AND for a reopened correction (completed -> Reopen -> running). NO - `!correctingMatch` guard: while a correction is - reopened it IS the running bout on this court, so + `!correctingMatch` guard: a reopened correction IS + the running bout on this court (it becomes the live + pick as soon as the refetch shows it running), so "Send back to queue" is its sanctioned exit - the twin of "Back to court" above, which stopCorrecting deliberately withholds while running (a stray Back @@ -1643,7 +1738,15 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,

Ready when you are

- Start the next match from the Up Next card to begin scoring on this court. + {/* bc-cse: when every scheduled match here is barred there + is no Up Next card at all (upNext skips a barred match on + purpose), so telling the operator to use it points at + something not on screen. Name the actual remedy instead: + each barred row in the queue carries its own one-tap + resolution (BarredMatchNotice). */} + {!upNext && filteredScheduled.length > 0 + ? "Every scheduled match on this court is barred. Resolve a withdrawal in the queue to bring one back." + : "Start the next match from the Up Next card to begin scoring on this court."}

)} @@ -1790,13 +1893,14 @@ export function groupQueueMatches(matches) { return order.map((k) => byKey.get(k)); } -function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, onCall, callingKey, calledKey, startingKey }) { +function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, onCall, callingKey, calledKey, startingKey, password }) { const renderRow = (m) => ( ); const groups = subGroup ? groupQueueMatches(matches) : null; @@ -1821,8 +1925,12 @@ function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMove ); } -export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, onCall, callingKey, calledKey, startingKey, pending, onResolve, slotLabel }) { +export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, onCall, callingKey, calledKey, startingKey, pending, onResolve, slotLabel, password }) { const isComplete = m.status === "completed"; + // bc-cse: a scheduled match a competitor is barred from. `pending` + // placeholder finals are excluded on purpose: their sides are still + // feeder placeholders, not a resolved competitor the stamp could name. + const barred = !pending && isBarredMatch(m); // Slot text through the one shared rule (bracket.jsx). Actionable rows never // hold a placeholder (hasBothSides filtered them out), so this only ever // changes the `pending` "Later" rows; the fallbacks keep any other caller @@ -1894,6 +2002,11 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE
+ {/* bc-cse: a barred match cannot be started (the server refuses it), + so the row shows why and the one-tap resolution here instead of a + dead Start button. BarredMatchNotice (admin_scoring_shared.jsx) is + the one component: same note/action/reinstate on every surface. */} + {barred && } {/* Completed result on its own centred line BELOW the names: the canonical "marks in the centre" position, but stacked so the (often long) names keep the full-width line and never crowd. The @@ -1924,7 +2037,11 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE btnClassName="score-edit-row__court score-edit-row__court--btn" /> )} - {onEnterLineup && isTeamMatch(m) && ( + {/* bc-cse: never offered on a barred row -- the server + would just refuse the Start this exists to prepare + for, and BarredMatchNotice above already owns the + one-tap resolution instead. */} + {onEnterLineup && isTeamMatch(m) && !barred && ( )} {onMove && ( @@ -1934,16 +2051,21 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE )} {/* Optional announce: mirrors the Up Next card so any queued match is a - complete view: call it to the floor, or start it directly. */} - {onCall && window.API && typeof window.API.sendAnnouncement === "function" && ( + complete view: call it to the floor, or start it directly. + bc-cse: never offered on a barred row -- there is nobody to + call to the court for a match the server would refuse to + start. */} + {onCall && window.API && typeof window.API.sendAnnouncement === "function" && !barred && ( )} {/* Start match is the primary per-row action: pushed to the end (the "go" slot). Same pickMatch path as the Up Next card: defers an unscored running bout, - blocks while one is being scored, then starts this match for scoring. */} - {onPick && } + blocks while one is being scored, then starts this match for scoring. + bc-cse: never offered on a barred row -- BarredMatchNotice above owns its + one-tap resolution instead, since the server would just refuse a Start. */} + {onPick && !barred && }
)} {/* Pending placeholder final: the ONLY affordance is the opt-in diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index 72c27794f..52de3d9c4 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -19,7 +19,7 @@ // the 200 body (mp-y3nk), so the caller can tell a landed write from a stale // reconnect replay the server dropped. // -// Empty-body methods (overridePoolRank, +// Empty-body methods (overridePoolRanks, // resetOverrides, updateMatchTime, moveMatchCourt, // deleteCompetition) deliberately return `true` // rather than `res.json()`: calling res.json() on a 200/204 with no @@ -40,7 +40,8 @@ import { writeDidNotLand, writeWasSuperseded, writeWasRefusedForClock, SUPERSEDED_REASON, SUPERSEDED_ADVICE, CLOCK_SKEW_REASON_TEXT, CLOCK_SKEW_ADVICE, CLOCK_SKEW_UNHEALED_ADVICE, - downstreamKnockoutPlayedQueueDrop, + downstreamKnockoutPlayedQueueDrop, downstreamKnockoutRunningMessage, downstreamKnockoutRunningReopenMessage, + downstreamKnockoutRunningQueueDrop, courtBusyMessage, } from './write_result.jsx'; // --------------------------------------------------------------------------- @@ -75,14 +76,55 @@ function fetchWithTimeout(url, opts, ms = 12000) { // server field is threaded once, not per endpoint. Async: it awaits the body. async function reopenFailureError(res) { const err = await res.json().catch(() => ({})); + // A later knockout match already has its own result: the same refusal a + // knockout correction gets, parsed by the same helper, so the caller asks + // downstreamKnockoutPlayedRefusal(e) and confirms before retrying with force. + // `reopen` marks it as met by a reopen rather than a corrected result, for + // the dialog's copy (write_result.jsx), the way overridePoolRanks marks + // `ranking`. + // + // bc-rawm: routed through the shared _downstreamRefusalError rather than + // _downstreamKnockoutPlayedError alone, so a kachinuki reopen blocked by a + // DOWNSTREAM MATCH STILL RUNNING (not yet played) gets a copy for THIS + // being a reopen (bc-cse: `{ reopen: true }` selects + // downstreamKnockoutRunningReopenMessage, "...then reopen again" rather + // than the score path's "...then save again", which has no save step to + // retry here), instead of falling through to the bare + // "downstream_knockout_running" token below. The PLAYED shape ALSO marks + // `reopen` on its own structured field below, for the confirm dialog's + // copy; the running shape carries no `.downstreamKnockoutPlayed` to mark, + // its message alone already says what to do about a match still being + // fought. + const downstream = _downstreamRefusalError(err, { reopen: true }); + if (downstream) { + if (downstream.downstreamKnockoutPlayed) downstream.downstreamKnockoutPlayed.reopen = true; + return downstream; + } const e = new Error(err.message || err.error || "Failed to reopen match"); if (err.error) e.code = err.error; if (err.court) e.court = err.court; if (err.matchId) e.matchId = err.matchId; if (err.compId) e.compId = err.compId; + // bc-rawm: the court-busy conflict reuses the score path's structured + // court_busy payload (see recordScore below), which now carries `label` + // -- the blocking match named the way the operator sees it. Carried + // through so ReopenFeedback (admin_scoring_shared.jsx) can prefer it over + // its own best-effort fetched label, and never fall back to printing the + // raw matchId. + if (err.label) e.label = err.label; return e; } +// reopenBody is the optional half of both reopen requests: the audit reason +// and the downstream confirmation, each sent only when set, so the one-tap +// kachinuki Reopen still posts {} exactly as before. +function reopenBody(reason, force) { + const body = {}; + if (reason) body.reason = reason; + if (force) body.forceDownstreamReopen = true; + return body; +} + // normalizeViewerCompItem maps one {config, poolMatches, bracket} item from the // aggregate GET /api/viewer/competitions or the court-scoped GET // /api/viewer/court/:court/matches into the flattened, normalized competition shape @@ -148,18 +190,88 @@ function _downstreamKnockoutPlayedError(body) { err.downstreamKnockoutPlayed = { matchId: body.matchId, blockingMatchId: body.blockingMatchId, - // Every match the confirmation will clear, each as {id, number}: the - // NUMBER is what the operator is shown ("Match 3"), the id only - // addresses it. One entry, except when a semifinal fed both the final + // Every match the confirmation will clear, each as {id, number, + // label}: the LABEL is what the operator is shown ("Match 3 + // (Final)", write_result.jsx matchLabel), the id only addresses it. One entry, except when a semifinal fed both the final // and the bronze match. blockingMatches: body.blockingMatches && body.blockingMatches.length ? body.blockingMatches : (body.blockingMatchId ? [{ id: body.blockingMatchId }] : []), displaced: body.displaced, + // A POOL correction in a mixed competition names the qualifying + // places it moves ({pool, rank, place, from, to, tied}); empty for a + // knockout correction, which moves no pool place. + qualifierChange: body.qualifierChange || [], }; return err; } +// Parses the 409 downstream_knockout_running refusal: a pool correction that +// would move a qualifier out of a knockout match being fought now. Unlike the +// played refusal it is NOT confirmable, so it carries no confirm fields; the +// thrown Error's message is the operator's copy (write_result.jsx's +// downstreamKnockoutRunningMessage) rather than the bare code, which is what +// every `new Error(data.error)` fallback below would otherwise show. Returns +// null when the body is not this refusal. bc-cse: `opts.reopen` swaps in +// downstreamKnockoutRunningReopenMessage instead -- the score path's "then +// save again" is wrong for a REOPEN, which has no save step to retry. +function _downstreamKnockoutRunningError(body, opts) { + if (!body || body.error !== 'downstream_knockout_running') return null; + const message = (opts && opts.reopen) + ? downstreamKnockoutRunningReopenMessage(body.runningMatches) + : downstreamKnockoutRunningMessage(body.runningMatches); + const err = new Error(message); + err.code = body.error; + err.downstreamKnockoutRunning = { matchId: body.matchId, runningMatches: body.runningMatches || [] }; + return err; +} + +// _downstreamRefusalError is the one parse every score/decision write throws +// through for a knockout-consequence 409: the confirmable played refusal, or +// the terminal running one. +function _downstreamRefusalError(body, opts) { + return _downstreamKnockoutPlayedError(body) || _downstreamKnockoutRunningError(body, opts); +} + +// _courtBusyError (bc-rawm): the 409 court_busy refusal on a score write -- +// this match's shiaijo is not free, a DIFFERENT match already holds it. Built +// into an operator sentence via write_result.jsx's courtBusyMessage rather +// than echoing the server's own `message` field, so the sentence names the +// blocking match the way the operator sees it (the server's `label`) and +// never falls back to printing the internal id the body also carries. +// Deliberately separate from _downstreamRefusalError: this is not a +// downstream-knockout refusal, and reopenFailureError's own court_busy +// handling (the "TWO 409 SHAPES" branch below) keeps using the server's raw +// `message` for its own Error, unaffected by this helper. +function _courtBusyError(body) { + if (!body || body.error !== 'court_busy') return null; + const err = new Error(courtBusyMessage({ court: body.court, label: body.label })); + err.code = body.error; + if (body.court) err.court = body.court; + if (body.matchId) err.matchId = body.matchId; + if (body.compId) err.compId = body.compId; + if (body.label) err.label = body.label; + return err; +} + +// _downstreamQueueDropCopy is what a DROPPED queued write is reported with when +// the server refused its replay for a knockout reason: the played refusal's +// queue copy (it cannot be confirmed from the flush loop, nobody is there), or +// the running refusal's. Both are write_result.jsx's words. bc-cse: also a +// court_busy refusal (_courtBusyError, whose message is courtBusyMessage's), +// so a queued write dropped for a busy shiaijo alerts the operator sentence +// rather than the bare "court_busy" token both drop sites below used to fall +// through to. null for any other body. +function _downstreamQueueDropCopy(body) { + const played = _downstreamKnockoutPlayedError(body); + if (played) return downstreamKnockoutPlayedQueueDrop(played.downstreamKnockoutPlayed); + const running = _downstreamKnockoutRunningError(body); + if (running) return downstreamKnockoutRunningQueueDrop(running.downstreamKnockoutRunning.runningMatches); + const courtBusy = _courtBusyError(body); + if (courtBusy) return { reason: courtBusy.message }; + return null; +} + // --------------------------------------------------------------------------- // mp-y3nk: server-clock offset for timestamp reconciliation. // @@ -919,7 +1031,9 @@ async function _flushQueue() { const res = await fetchWithTimeout(effectiveUrl, { method: effectiveMethod, headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password }, - body: JSON.stringify(payload), + // Never a replayed confirmation, whatever the entry holds: + // see _withoutDownstreamConfirmation. + body: JSON.stringify(_withoutDownstreamConfirmation(payload)), }); if (res.ok) { // Success (HTTP 200/201, including a stale {stale:true} no-op): remove @@ -1158,14 +1272,11 @@ async function _flushQueue() { // human copy, same reason this can't just retry: there is no // operator here for attemptScoreWrite's confirm dialog to // prompt, and nothing sets forceDownstreamReopen automatically. - const downstreamRefusal = _downstreamKnockoutPlayedError(body); - const dropCopy = downstreamRefusal - ? downstreamKnockoutPlayedQueueDrop(downstreamRefusal.downstreamKnockoutPlayed) - : null; + const dropCopy = _downstreamQueueDropCopy(body); const reason = dropCopy ? dropCopy.reason : (body.reasonHuman || body.error || 'conflict (409)'); _notifyTerminalWriteFailed({ compID, matchID, kind, status: 409, reason, - ...(dropCopy ? { advice: dropCopy.advice } : {}), + ...(dropCopy && dropCopy.advice ? { advice: dropCopy.advice } : {}), }); _notifyQueueAlert({ kind: 'rejected', count: 1, terminalCount: 1, compID, matchID, @@ -1205,15 +1316,12 @@ async function _flushQueue() { // "downstream_knockout_played" token, which is what an operator // reading this alert used to see with no way to understand it or // move forward. - const downstreamRefusal = _downstreamKnockoutPlayedError(body); - const dropCopy = downstreamRefusal - ? downstreamKnockoutPlayedQueueDrop(downstreamRefusal.downstreamKnockoutPlayed) - : null; + const dropCopy = _downstreamQueueDropCopy(body); const reason = dropCopy ? dropCopy.reason : (body.reasonHuman || body.error || `HTTP ${res.status}`); if (terminal) { _notifyTerminalWriteFailed({ compID, matchID, kind, status: res.status, reason, - ...(dropCopy ? { advice: dropCopy.advice } : {}), + ...(dropCopy && dropCopy.advice ? { advice: dropCopy.advice } : {}), }); } _notifyQueueAlert({ @@ -1326,6 +1434,25 @@ function enqueueRunningWrite(compID, matchID, payload, password) { }); } +// _withoutDownstreamConfirmation is the ONE owner of "a queued write NEVER +// carries the operator's downstream confirmation". A "Proceed anyway" answered +// one specific refusal, naming the knockout matches that stood then; a replay +// minutes or hours later would reopen whatever stands by then, which nobody +// confirmed. So a replay that still meets the refusal gets the 409 and is +// dropped with its queue copy (downstreamKnockoutPlayedQueueDrop). +// +// Applied at BOTH ends of the queue: _enqueueTerminalWrite, so the stored copy +// (and localStorage) never holds it, and _flushQueue, the one door EVERY +// replay goes through, so an entry that already holds it cannot send it +// either. That second call is not redundant: an entry queued by an older +// bundle, before the enqueue strip existed, is rehydrated from localStorage +// verbatim and replayed by this bundle's flush loop. Returns a COPY, never a +// mutation: the live fetch and _broadcastPatch share the caller's object. +function _withoutDownstreamConfirmation(payload) { + const { forceDownstreamReopen: _confirmation, ...rest } = payload || {}; + return rest; +} + /** * F5: Enqueue a terminal write for offline-resilient delivery. * Terminal entries (completed score, decision, lineup) supersede any running @@ -1341,7 +1468,7 @@ function enqueueRunningWrite(compID, matchID, payload, password) { */ function _enqueueTerminalWrite(key, kind, method, url, payload, password, compID, matchID) { _commitEnqueue(key, { - compID, matchID, payload, password, + compID, matchID, payload: _withoutDownstreamConfirmation(payload), password, kind, terminal: true, method, url, enqueuedAt: Date.now(), @@ -1782,7 +1909,7 @@ const API = { } // Backend returns 204 No Content: calling .json() on an empty // body throws SyntaxError per the Fetch spec (same pattern as - // overridePoolRank etc. above). + // overridePoolRanks etc. above). return true; }, // Set or rotate the elevated (destructive-ops) admin password (spec 004 @@ -2329,7 +2456,7 @@ const API = { if (retryBody.error === "ineligible_competitor" || retryBody.error === "already_ineligible") { throw new Error(retryBody.reasonHuman || retryBody.reason || retryBody.error || "Failed to record score"); } - throw _downstreamKnockoutPlayedError(retryBody) || new Error(retryBody.error || "Failed to record score"); + throw _courtBusyError(retryBody) || _downstreamRefusalError(retryBody) || new Error(retryBody.error || "Failed to record score"); }; let res; @@ -2416,12 +2543,18 @@ const API = { if (data.error === "ineligible_competitor" || data.error === "already_ineligible") { throw new Error(data.reasonHuman || data.reason || data.error || "Failed to record score"); } + // bc-rawm: 409 court_busy (the shiaijo this match wants is running a + // different match) is built into an operator sentence naming the + // shiaijo and the blocking match, never the bare "court_busy" token; + // see _courtBusyError above. + const courtBusy = _courtBusyError(data); + if (courtBusy) throw courtBusy; // bc-kcdg: 409 downstream_knockout_played (correcting a completed // knockout match whose later round already played) is parsed into a // structured error rather than a plain message; see // _downstreamKnockoutPlayedError and write_result.jsx's // downstreamKnockoutPlayedRefusal. - throw _downstreamKnockoutPlayedError(data) || new Error(data.error || "Failed to record score"); + throw _downstreamRefusalError(data) || new Error(data.error || "Failed to record score"); }, // T093–T095: kiken / fusenpai / fusensho / daihyosen: server auto-fills // scoreline and Winner from {decision, decisionBy, encho}. Body shape is @@ -2476,6 +2609,18 @@ const API = { // can surface the error. The decision-locked-as-success rule is ONLY // for queued retries in _flushQueue, not for direct calls. const err = await res.json().catch(() => ({})); + // bc-rawm: the eligibility gate (mp-dc52 Phase 3) refuses a + // decision exactly as it refuses a score write -- 409 + // ineligible_competitor / already_ineligible carrying a full + // operator sentence in reasonHuman -- so prefer it the same way + // recordScore does below: reasonHuman, then reason, then the bare + // code. Checked BEFORE the downstream parse: neither downstream + // helper matches these two codes, so without this branch the + // fallback `new Error(err.error ...)` would have thrown the raw + // machine token instead of the server's sentence. + if (err.error === "ineligible_competitor" || err.error === "already_ineligible") { + throw new Error(err.reasonHuman || err.reason || err.error || "Failed to record decision"); + } // bc-cse: 409 downstream_knockout_played (a kiken/fusenpai/daihyosen // decision that corrects a completed knockout match whose later // round already played) gets the SAME structured parse as the @@ -2486,29 +2631,29 @@ const API = { // a decision retry (submitDecisionRequest, admin_scoring_shared.jsx, // is what now reads .downstreamKnockoutPlayed off this error via // attemptScoreWrite). - throw _downstreamKnockoutPlayedError(err) || new Error(err.error || "Failed to record decision"); + throw _downstreamRefusalError(err) || new Error(err.error || "Failed to record decision"); } const data = await res.json(); - // bc-cse defence in depth, and INERT today by construction: the - // /decision handler maps a superseded write to 200 {"applied": false}, - // but RecordDecisionTx builds its MatchResult with no ModifiedAt, so the - // write takes ApplyByTimestamp's unstamped bypass and the server cannot - // currently emit this body. Asked anyway for the same reason the server - // maps it: a 200 the caller reads as success while nothing was stored is - // precisely bc-lww1, and a decision resolves a match, so swallowing it - // would leave the operator looking at a verdict the disk never held. + // bc-cse, LIVE since mp-jnvl: the /decision handler maps a superseded + // write to 200 {"applied": false}, and a decision now carries a real + // modifiedAt (stamped above, exactly as recordScore stamps its own + // payload) that reaches RecordDecisionTx through its variadic + // modifiedAt parameter, which the handler always passes. So + // ApplyByTimestamp's LWW guard runs on a decision exactly as it does + // on a score write, and the server can genuinely emit this body for + // either reason (superseded or clock_skew) -- this is no longer a + // defence against a shape the server cannot send. Handled for the + // same reason bc-lww1 exists at all: a 200 the caller reads as + // success while nothing was stored would leave the operator looking + // at a verdict the disk never held. // - // ONE verdict for every applied:false here, and deliberately NOT the - // terminal SCORE path's split. That path earns its clock-specific copy - // by healing the offset and RETRYING once: only after the retry is - // refused again may it tell the operator their clock cannot be - // reconciled. This path does neither -- a decision carries no - // modifiedAt to re-stamp, so there is nothing to heal and retry -- and - // saying "the device clock could not be reconciled" without having - // tried would be a verdict on an attempt that never happened. So the - // decision path REPORTS rather than recovering, with the plain - // superseded copy. The offset is still relearned, since a skewed - // device is worth correcting for the next write that IS stamped. + // ONE verdict for every applied:false here, unlike the terminal SCORE + // path's split: that path earns its clock-specific copy by healing + // the offset and RETRYING once before reporting. This path does not + // attempt that heal-and-resend, so a decision refused for either + // reason is reported with the plain superseded copy rather than the + // dedicated clock-skew one. The offset is still relearned either way, + // since a skewed device is worth correcting for the next write. if (writeWasSuperseded(data)) { _relearnClockThrottled(); _notifyScoreSuperseded(compID, matchID); @@ -2537,15 +2682,31 @@ const API = { } return res.json(); }, - // playerId is REQUIRED (operator ruling bc-pnum): the server resolves a - // pool member by id only (resolvePoolOverrideTarget, + // overridePoolRanks records a whole group of pool ranks, a chusen's order, + // as ONE write: ranks is [{ playerId, rank }], and the server records + // every rank together and answers for the final order only. Sent one rank + // at a time, the order passed through states nobody chose, so a knockout + // match whose place the final order keeps could be named and reopened. + // + // playerId is REQUIRED on every entry (operator ruling bc-pnum): the + // server resolves a pool member by id only (poolHasPlayerID, // handlers_competition.go) and rejects the request with 400 when it is // missing -- two pool members can legally share a display name from - // different dojos, so playerName alone can never disambiguate them. - // playerDojo is NOT sent: the server never read it (id-only resolution - // has no use for it), so a caller has nothing to gain by supplying it. - async overridePoolRank(compID, poolID, playerName, rank, password, playerId) { - const body = { playerName, rank, playerId }; + // different dojos, so a name can never disambiguate them. Neither a name + // nor a dojo is sent: the server never reads them. + // + // forceDownstreamReopen is the operator's confirmation of a 409 + // downstream_knockout_played refusal: a new order that moves a qualifier + // out of a knockout match the old one already fought is refused exactly + // like a pool result correction (and one being fought is refused outright, + // downstream_knockout_running). Both are parsed into the same structured + // errors recordScore throws, so the chusen panel offers the same + // confirm-and-retry (write_result.jsx's attemptScoreWrite); `ranking` + // marks the refusal as a rank change rather than a corrected result, for + // the dialog's copy. + async overridePoolRanks(compID, poolID, ranks, password, forceDownstreamReopen) { + const body = { ranks }; + if (forceDownstreamReopen) body.forceDownstreamReopen = true; const res = await fetch(`/api/competitions/${compID}/pools/${poolID}/override-rank`, { method: 'PUT', headers: { @@ -2556,12 +2717,16 @@ const API = { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || "Failed to override rank"); + const refusal = _downstreamRefusalError(err); + if (refusal && refusal.downstreamKnockoutPlayed) refusal.downstreamKnockoutPlayed.ranking = true; + throw refusal || new Error(err.error || "Failed to override rank"); } - // Backend returns 200 with empty body. Calling .json() on an - // empty body throws SyntaxError per the Fetch spec, which would - // surface to the user as an alert("Failed: Unexpected end of - // JSON input") right after a successful save. + // The body ({reopenedMatches}) is deliberately not read: an older + // server answered 200 with an EMPTY body, and calling .json() on one + // throws SyntaxError per the Fetch spec, which surfaced as an + // alert("Failed: Unexpected end of JSON input") right after a + // successful save. The confirm dialog already named what a forced + // override reopens, and each reopened match is announced over SSE. return true; }, // bc-kcdg: forceDownstreamReopen is the operator's confirmed override of a @@ -2788,24 +2953,28 @@ const API = { } return true; }, - // mp-gmcg: reopen a COMPLETED kachinuki team match: status back to - // running, winner/decision cleared, bout log kept. + // mp-gmcg: reopen a COMPLETED kachinuki team match, or (bc-tmfn) any + // completed match a withdrawal decided: status back to running, + // winner/decision cleared, fought bouts kept. // - // NO REASON IS SENT, deliberately (operator ruling). An operator who ended - // a match BY MISTAKE at a shiaijo must be able to get back into it in ONE - // TAP; making them justify the mistake before they may undo it is friction - // at the worst possible moment. The audit trail is collected on the way - // OUT instead: the server stamps `reopenPending` on the reopened match and - // rejects the later COMPLETING write unless it carries a correctionReason - // (400, field correctionReason), which admin_scoring_team.jsx prompts for - // on [End match]. The body stays an empty JSON object (not absent) so a - // handler that binds JSON still parses the request. + // The kachinuki Reopen sends NO REASON, deliberately (operator ruling). An + // operator who ended a match BY MISTAKE at a shiaijo must be able to get + // back into it in ONE TAP; making them justify the mistake before they may + // undo it is friction at the worst possible moment, and ending the match + // again asks for no reason either (operator ruling 2026-09-25: a match can + // be reopened without any reason, and nothing is gated on that). Clear + // withdrawal and reopen is one tap with no reason too; opts.reason stays + // for any caller that has one to record. opts.force is the operator's confirmation after a + // downstream_knockout_played refusal. The body stays a JSON object (never + // absent) so a handler that binds JSON still parses the request. // - // 400 = a non-kachinuki competition; 409 = not completed / downstream - // bracket match already fought (the propagated winner cannot be retracted) - // / another match is already running on this match's court. The editor - // shows whichever came back verbatim, so the unwrap keeps the server's own - // words. + // 400 = neither kachinuki nor withdrawal-decided; 409 = not completed / a + // later match in progress or resolved by a bye / a later match with its + // own result (downstream_knockout_played, parsed like the score path's, + // so the caller confirms and retries with force) / another match is + // already running on this match's court. The editor shows whichever came + // back verbatim, so the unwrap keeps the server's own words. Resolves to + // the server's { reopenedMatches: [{id, number, label}] }. // // TWO 409 SHAPES: most carry the sentence in `error`, but the court-busy // conflict reuses the score path's structured payload, where `error` is @@ -2816,35 +2985,36 @@ const API = { // conflict, and a busy court would be a dead end (kachinuki reopen is the // ONLY way to fix a bout log). The server broadcasts match_updated on // success. - async reopenMatch(compID, matchID, password) { + async reopenMatch(compID, matchID, password, { reason = "", force = false } = {}) { const res = await fetch(`/api/competitions/${compID}/matches/${matchID}/reopen`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password }, - body: JSON.stringify({}) + body: JSON.stringify(reopenBody(reason, force)) }); if (!res.ok) throw await reopenFailureError(res); - return true; + return res.json().catch(() => ({})); }, // mp-gmcg (review A4): atomically requeue the match holding the court AND // reopen the target, in ONE server call under one court lock — replaces the // former two-call revert-then-reopen, which raced (a peer could take the // freed court between the calls). Surfaces the SAME court_busy detail shape // as reopenMatch, so the panel re-offers the remedy when a DIFFERENT match - // has since taken the court. - async requeueBlockerAndReopen(targetComp, targetMatch, blockerComp, blockerMatch, password) { + // has since taken the court. opts as reopenMatch's: the remedy carries the + // same reason and confirmation the reopen it retries did. + async requeueBlockerAndReopen(targetComp, targetMatch, blockerComp, blockerMatch, password, { reason = "", force = false } = {}) { const res = await fetch(`/api/competitions/${targetComp}/matches/${targetMatch}/requeue-blocker-and-reopen`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password }, - body: JSON.stringify({ blockerCompId: blockerComp, blockerMatchId: blockerMatch }) + body: JSON.stringify({ blockerCompId: blockerComp, blockerMatchId: blockerMatch, ...reopenBody(reason, force) }) }); if (!res.ok) throw await reopenFailureError(res); - return true; + return res.json().catch(() => ({})); }, // mp-gmcg: remove a trailing UNSCORED kachinuki bout appended by mistake // ([Record bout] / [Add next bout]). Kachinuki-only; targets a numbered @@ -3388,7 +3558,7 @@ const API = { // chusenCandidates: GET /competitions/:id/chusen-candidates // Consequential team-pool ties the daihyosen could not settle; the operator - // resolves each by chusen (drawing lots), recorded via overridePoolRank. + // resolves each by chusen (drawing lots), recorded via overridePoolRanks. async chusenCandidates(compID, password) { // Lives in the admin-gated competition router, so it needs the // tournament password header (unlike the public league candidates GET). @@ -3402,7 +3572,10 @@ const API = { throw new Error(err.error || `Failed to load chusen candidates (${res.status})`); } const data = await res.json(); - return data.candidates || []; + // candidates: ties still waiting for a chusen. recorded: ties a chusen + // already settled, each with the recorded `ranks` parallel to `teams`, + // so a chusen recorded in the wrong order can be changed. + return { candidates: data.candidates || [], recorded: data.recorded || [] }; }, // leagueTiebreakGenerate: POST /competitions/:id/league-tiebreak diff --git a/web-mobile/js/barred_chip.jsx b/web-mobile/js/barred_chip.jsx new file mode 100644 index 000000000..0900bbfdb --- /dev/null +++ b/web-mobile/js/barred_chip.jsx @@ -0,0 +1,51 @@ +// barred_chip.jsx: the "Withdrawn" chip beside a barred competitor's name on +// a still-scheduled match. +// +// A scheduled match stays visible in every public schedule and pool list +// even when it cannot be fought (every entry stays fixable, nothing is +// hidden): ineligible_match.jsx is the one reader of the server's +// `ineligibleSides` stamp, and this file is the one renderer of the chip it +// names (BARRED_CHIP), so every list row places it the same way. +// +// Placement mirrors teamNameMark (match_scoreboard.jsx), the existing +// Kiken/Fus. result-mark composer these same rows already call: the chip +// rides on the competitor's INNER side (toward the match's centre "vs"), +// via the same numberFollowsName predicate that decides where a competitor +// number sits. Not reused directly: teamNameMark wraps in `.sb-result-mark` +// with a `team-summary-mark-*` testid, both specific to the completed-match +// team mark, and only ever fires for a completed match, so a scheduled +// barred row never fires it — the two never collide on one name. +// +// Placed at each of the four call sites where a name renders inline with a +// "vs" centre (bracket.jsx's MatchCard has no such centre -- the two sides +// stack vertically -- so it renders BarredChip directly instead of going +// through barredNameMark; see its own comment there). The a/b <-> aka/shiro +// mapping barredNameMark needs to read barredSides() is NOT spelled here: +// sideKeyForDecisionBy (ineligible_match.jsx) is the one owner of it (bc-cse). + +import { BARRED_CHIP, barredSides, sideKeyForDecisionBy } from './ineligible_match.jsx'; +import { numberFollowsName } from './numbered_name.jsx'; + +// The visible chip itself: plain readable text, same as "Kiken"/"Fus." are +// already announced by teamNameMark -- no aria-label (the cells this rides +// in are bare inline spans, role generic, where ARIA prohibits naming via +// aria-label) and no aria-hidden (the text IS the label). +export function BarredChip() { + return {BARRED_CHIP}; +} + +// barredNameMark: wraps `nameEl` (a string or a JSX element) with BarredChip +// on `side`'s inner edge when the server has barred that side of `m`. `side` +// is "aka" | "shiro" -- the same value every caller already passes to +// NumberedName / SideLabel / teamNameMark. Returns `nameEl` unchanged when +// that side isn't barred, so nesting this around an existing teamNameMark +// call is free: the two marks can never both be non-empty for one match +// (teamMatchMarks requires `status === "completed"`; barredSides requires +// `status === "scheduled"`). +export function barredNameMark(m, side, nameEl) { + const key = sideKeyForDecisionBy(side) || null; + const isBarred = !!(key && barredSides(m)[key]); + if (!isBarred) return nameEl; + const chip = ; + return numberFollowsName(side) ? <>{chip}{" "}{nameEl} : <>{nameEl}{" "}{chip}; +} diff --git a/web-mobile/js/barred_match_notice.jsx b/web-mobile/js/barred_match_notice.jsx new file mode 100644 index 000000000..943d00fa0 --- /dev/null +++ b/web-mobile/js/barred_match_notice.jsx @@ -0,0 +1,138 @@ +// barred_match_notice.jsx: the ONE component that renders "this scheduled +// match cannot be fought because a competitor is barred" -- the note, the +// one-tap default-win action, and (where reinstateable) the Reinstate +// action -- across the court console, the Scores tab, and both score +// editors. Reads ineligible_match.jsx's stamp; writes through the ordinary +// /decision and /reinstate calls, exactly like every other operator action. +// +// A DELIBERATE LEAF: imports only ineligible_match.jsx and write_result.jsx, +// both leaves themselves with no imports of their own (see their headers for +// why). admin_scoring_shared.jsx re-exports this component so the editors' +// existing `from './admin_scoring_shared.jsx'` import lists need no change, +// but admin_shiaijo.jsx and admin_schedule_score_editor.jsx import it +// directly from here. Importing it via admin_scoring_shared.jsx from either +// of those two pulls in that module's own import of bracket.jsx (for +// sideMarks), which assigns `window.BracketTree` etc. at module-eval time +// (bracket.jsx's bottom) and silently overwrote those two render suites' +// `BracketTree: () => null` stub, installed before the dynamic import -- +// found via the two shiaijo render suites failing on the REAL bracket tree +// mounting (ResizeObserver) and relabelling ("Winner of r2-m0" no longer +// matched `getByText`) the moment this component's import was added there. +// Route straight to this leaf from any surface that does not already need +// admin_scoring_shared.jsx's much larger graph. + +const { useState, useEffect, useRef } = React; + +import { + barredNote, awaitedDefaultWin, defaultWinDecisionBody, defaultWinActionLabel, bothBarredDrawAction, +} from './ineligible_match.jsx'; +import { notLandedBanner, writeDidNotLand } from './write_result.jsx'; + +// No onDone/refresh plumbing here on purpose: recording the default win, or +// reinstating the competitor, is an ordinary /decision or /reinstate write, +// and every host already re-renders from live data (SSE match_updated / +// competitor_status_updated) once it lands. `onDone`, when a caller passes +// one, still fires so a host that wants to react sooner (e.g. close a modal) +// can. +export function BarredMatchNotice({ match, password, onDone }) { + const [busy, setBusy] = useState(false); + // bc-cse: without this, a successful write's button re-enables the instant + // busy clears and stays tappable until the SSE refetch repaints this + // component away (or replaces `match` with one no longer barred) -- a + // second tap in that window sends a second /decision (or /reinstate). + // Mirrors useMatchReopen's landed state: lock the button on ANY response + // that is not refused, queued included (a queued write still lands later, + // so a second tap would double-enqueue it). + const [landed, setLanded] = useState(false); + const [err, setErr] = useState(""); + const mountedRef = useRef(true); + useEffect(() => () => { mountedRef.current = false; }, []); + + const note = barredNote(match); + if (!note) return null; + const w = awaitedDefaultWin(match); + const drawAction = bothBarredDrawAction(match); + const pw = password || ""; + + // bc-cse: shared by the default-win decision AND the both-barred draw + // decision -- both are ordinary /decision writes and must land/queue/fail + // identically. + const submitDecision = async (body) => { + if (!body || !window.API || typeof window.API.recordDecision !== "function") return; + setErr(""); + setBusy(true); + try { + const res = await window.API.recordDecision(match.compId, match.id, body, pw); + if (!mountedRef.current) return; + // bc-cse: notLandedBanner, not writeWasSuperseded alone -- a clock_skew + // refusal used to read "a newer result for this match is already + // recorded", which sends the operator to check a result that was never + // written and tells them not to do the one thing (re-enter it) that + // would actually save it. See write_result.jsx for why the two must + // never share a banner. + const banner = notLandedBanner(res); + if (banner) { + setErr(`Not saved: ${banner.reason}. ${banner.advice}`); + return; + } + if (writeDidNotLand(res)) { + setErr("Not saved yet: queued, and will be recorded once the connection returns."); + } + setLanded(true); + if (typeof onDone === "function") onDone(res); + } catch (e) { + if (!mountedRef.current) return; + setErr(e?.message || "Failed to record the decision"); + } finally { + if (mountedRef.current) setBusy(false); + } + }; + + const recordDefaultWin = () => submitDecision(defaultWinDecisionBody(match)); + const recordDrawn = () => submitDecision(drawAction?.body); + + const reinstate = async () => { + const id = w?.barred?.id; + if (!w || !w.reinstateable || !id || !window.API || typeof window.API.reinstateCompetitor !== "function") return; + setErr(""); + setBusy(true); + try { + const res = await window.API.reinstateCompetitor(match.compId, id, pw); + if (!mountedRef.current) return; + setLanded(true); + if (typeof onDone === "function") onDone(res); + } catch (e) { + if (!mountedRef.current) return; + setErr(e?.message || "Failed to reinstate the competitor"); + } finally { + if (mountedRef.current) setBusy(false); + } + }; + + return ( +
+
{note}
+ {err &&
{err}
} +
+ {w && ( + + )} + {w && w.reinstateable && ( + + )} + {drawAction && ( + + )} +
+
+ ); +} diff --git a/web-mobile/js/bracket.jsx b/web-mobile/js/bracket.jsx index ed6eaef44..99947cbb1 100644 --- a/web-mobile/js/bracket.jsx +++ b/web-mobile/js/bracket.jsx @@ -13,9 +13,12 @@ const { useRef, useLayoutEffect: useLayoutEffectBC, useState: useStateBC, useEffect: useEffectBC } = React; import { DAIHYOSEN_POSITION } from './pool_ids.jsx'; +import { barredSides } from './ineligible_match.jsx'; +import { BarredChip } from './barred_chip.jsx'; import { realIppons } from './result_slot.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; import { NumberedName } from './numbered_name.jsx'; +import { creditedBoutSide, isTeamDefaultWinDecision } from './team_default_credit.jsx'; // TermBC: kendo-glossary tooltip wrapper. Lazy lookup so the script // load order between glossary.jsx and this module doesn't matter. @@ -223,8 +226,9 @@ const joinSp = (a, b) => [a, b].filter(Boolean).join(" "); // resultSlot in result_slot.jsx — a separate leaf; the dependency reasoning is // stated ONCE, in that file's header. Flat score strings have no slots, so // they concatenate instead and never call it. -const placeMarks = (marks, firstWins, secondWins) => - firstWins ? [marks.winner, marks.loser] : secondWins ? [marks.loser, marks.winner] : ["", ""]; +function placeMarks(marks, firstWins, secondWins) { + return firstWins ? [marks.winner, marks.loser] : secondWins ? [marks.loser, marks.winner] : ["", ""]; +} // isDrawResult: a result is a draw when the recorded decision OR the // client-derived score.type says hikiwake (quick-score paths set only @@ -232,8 +236,9 @@ const placeMarks = (marks, firstWins, secondWins) => const isDrawResult = (decision, score) => isHikiwakeBC(decision) || isHikiwakeBC(score?.type); // isDefaultWinBC: the decisions that award the match points without a -// technique. Mirrors domain.IsDefaultWinDecisionStr (Go). -const isDefaultWinBC = (d) => isKikenDecisionBC(d) || d === "fusenpai" || d === "fusensho"; +// technique. Delegates to team_default_credit.jsx's isTeamDefaultWinDecision, +// THE one JS owner of this class (bc-cse) -- see that function's own comment. +const isDefaultWinBC = isTeamDefaultWinDecision; // defaultWinMaru: the maru cells a default win awards — one "○" per point, // per the FIK Regulations (Article 32 and the Score Board appendix p.15: @@ -278,18 +283,66 @@ function matchMiddleMark(match) { // hantei → winner "Ht" (FIK 7-5 / 29-6: judges picked the winner) // kiken → loser "Kiken" (the competitor who withdrew) // fusenpai → loser "Fus." (the no-show) -// `fusensho` (the per-bout default WIN) is deliberately absent here: the -// viewer surfaces it via a separate bout badge. The Excel export has no -// badges, so its SideMarks (internal/export/suffix.go) folds fusensho in as -// a winner-side "Fus." — the one deliberate divergence between the mirrors. +// fusensho → winner "Fus." (the default WIN names the present side) +// Mirrors internal/export/suffix.go SideMarks exactly (CLAUDE.md documents +// the pair as one mirrored rule). bc-tmfn removed the earlier fusensho gap +// here: this surface used to omit the winner-side "Fus." mark on the theory +// that "the viewer surfaces it via a separate bout badge", but no such badge +// exists for a MATCH-LEVEL fusensho decision (only a per-bout team row's ○○ +// fill, which is a different thing), so a match-level default win used to +// render with no mark on this surface at all. There is no divergence left to +// document: a fusensho match now reads identically here and in the export. function sideMarks(decision, decidedByHantei) { let winner = "", loser = ""; if (isKikenDecisionBC(decision)) loser = "Kiken"; else if (decision === "fusenpai") loser = "Fus."; - if (decidedByHantei) winner = "Ht"; // nothing above sets winner (fusensho is a badge here) + else if (decision === "fusensho") winner = "Fus."; + if (decidedByHantei) winner = joinSp(winner, "Ht"); return { winner, loser }; } +// teamMatchMarks: the match-level Kiken/Fus. mark for EACH side of a TEAM +// match a default-win decision closed -- the same sideMarks + placeMarks +// pattern MatchCard already applies to an individual match's score cell +// (aWin/bWin via sameCompetitor, then placeMarks), generalized for a caller +// that renders a side's NAME separately from its score cell (a list row, a +// TV headline) rather than inline in a flat score string. THE one place this +// composition lives (bc-tmfn): every consumer below calls this rather than +// re-deriving its own copy. +// +// `isTeamRow` is the caller's OWN team-match signal (a subResults array, a +// compKind check, whatever it already has) -- this function has no way to +// tell an individual match's kiken from a team one, so it never guesses. +// Without it, an ordinary INDIVIDUAL kiken/fusenpai match would get this +// mark TWICE: once here, once already inline in its own matchScoreStr +// (formatIpponsScore's sideMarks call), since teamIVPWScore is deliberately +// free of marks and an individual score string is not. +// +// bc-cse: OPTIONAL. Four callers (admin_schedule_score_editor.jsx, +// viewer_match.jsx, viewer_schedule.jsx, viewer_standings.jsx) computed the +// exact same `Array.isArray(m.subResults) && m.subResults.length > 0` before +// calling in, so that default now lives here instead and those four callers +// pass nothing. A caller with a BETTER signal (viewer_match.jsx's own +// compKind/teamSize check, display_scoreboard.jsx's competition-format +// isTeamMatch prop) still passes it explicitly to override the default -- +// e.g. a genuine team match with an empty subResults array (nothing fought +// yet) would otherwise read as non-team here. +// +// Returns {} for a non-team row, a not-yet-completed match, or a decision +// sideMarks has nothing to say about (returns "" for both sides, same as +// the individual case). +function teamMatchMarks(match, isTeamRow) { + const teamRow = isTeamRow === undefined + ? Array.isArray(match?.subResults) && match.subResults.length > 0 + : isTeamRow; + if (!teamRow || !match || match.status !== "completed") return { shiro: "", aka: "" }; + const marks = sideMarks(match.decision, !!match.decidedByHantei); + const aWin = sameCompetitor(match.winner, match.sideA); + const bWin = sameCompetitor(match.winner, match.sideB); + const [aMark, bMark] = placeMarks(marks, aWin, bWin); + return { shiro: bMark, aka: aMark }; +} + // winnerSideLR: which DISPLAY side won, under the SHIRO-left convention every // score string uses (sideB = Shiro = left, sideA = Aka = right). Returns // "left" | "right" | null (no winner recorded, drifted data, or a mixed @@ -408,9 +461,22 @@ function teamIVScore(m) { if (!Array.isArray(subs) || subs.length === 0) return null; const aName = typeof m.sideA === "object" ? m.sideA?.name : m.sideA; const bName = typeof m.sideB === "object" ? m.sideB?.name : m.sideB; + // bc-tmfn: a team match a match-level default-win decision closed credits + // every numbered bout with no result of its own to the OTHER side from + // decisionBy -- see team_default_credit.jsx for the whole rule. `kachinuki` + // best-effort reads m.teamMatchType, a viewer-list enrichment not every + // caller of this fallback carries (see that file's header); when absent + // this treats the match as non-kachinuki, which only matters at all when + // m.teamResult is ALSO absent (the server's own count wins whenever + // present, and it is present on every live payload once the concurrent + // Go-side change lands). + const matchCtx = { status: m.status, decision: m.decision, decisionBy: m.decisionBy, kachinuki: m.teamMatchType === "kachinuki" }; let ivA = 0, ivB = 0; for (const sub of subs) { if (!sub || sub.position <= DAIHYOSEN_POSITION) continue; // skip the daihyosen sentinel (-1) and any malformed negative position + const creditSide = creditedBoutSide(sub, matchCtx); + if (creditSide === "a") { ivA++; continue; } + if (creditSide === "b") { ivB++; continue; } const w = sub.winner; if (!w) continue; // hikiwake / undecided → no IV if (w === aName || w === sub.sideA) ivA++; @@ -439,7 +505,7 @@ function teamIVPWScore(m) { return iv == null ? null : `IV ${iv}`; } -const PlayerLine = React.memo(({ player, isWinner, side, showDojo, score, isTBD, isEngi, slotLabel, feederId }) => { +const PlayerLine = React.memo(({ player, isWinner, side, showDojo, score, isTBD, isEngi, slotLabel, feederId, barred }) => { if (!player || isTBD) { return (
@@ -475,7 +541,12 @@ const PlayerLine = React.memo(({ player, isWinner, side, showDojo, score, isTBD, anywhere, so cards stay uniformly short. */} {showDojo ? {player.dojo || } : null}
- {score != null ? {score} : null} + {/* score is null for a scheduled match (nothing struck yet), so a barred + side (ineligible_match.jsx) takes that same results-column slot: a + sibling of .bc-name-wrap in this flex row, never inside .bc-name's own + ellipsis. Mirrors where a completed match's Kiken/Fus./Ht mark rides + (cardMarks in MatchCard); the two can never both apply to one side. */} + {score != null ? {score} : barred ? : null}
); }); @@ -498,9 +569,10 @@ const MatchCard = React.memo(({ match, variant, showDojo, onClick, highlighted, // has (the card builds its per-side scores from ippon arrays and never calls // matchScoreStr, so removing this leaves such a card completely unlabelled). // A server-fed structural bye in a bracket carrying mp-7f2w metadata is not - // drawn as a MatchCard at all (a legacy bracket has no metadata, routes to - // BracketTreeLegacy, and does draw every rounds[] entry as a card): it renders - // as the bc-bye-slot placeholder in BracketTreeMeta below. + // drawn at all (a legacy bracket has no metadata, routes to + // BracketTreeLegacy, and does draw every rounds[] entry as a card): the + // competitor who skips the round appears only in the card of the match they + // first fight (BracketTreeMeta below, bc-tmfn). const isBye = match.score?.type === "bye"; const ipponsA = match.ipponsA || []; @@ -516,6 +588,10 @@ const MatchCard = React.memo(({ match, variant, showDojo, onClick, highlighted, // that side's score slot — the node's "results column". The meta strip // above carries only the middle marks (X / (E) / (DH)). const cardMarks = isDone ? sideMarks(match.decision, !!match.decidedByHantei) : { winner: "", loser: "" }; + // A barred side (ineligible_match.jsx) has no score yet -- barredSides + // requires `status === "scheduled"`, cardMarks above requires "completed" + // -- so this never competes with cardMarks for the same results-column slot. + const { a: barredA, b: barredB } = barredSides(match); const [aMark, bMark] = placeMarks(cardMarks, aWin, bWin); // realIppons strips the "Ht" mark from the letters before joining: the mark // is re-attached separately via cardMarks/aMark/bMark (sideMarks + placeMarks), @@ -559,9 +635,9 @@ const MatchCard = React.memo(({ match, variant, showDojo, onClick, highlighted,
{/* feeders is [A, B]: hand each side ITS feeder so an unresolved slot can be named after the match that will actually fill it (see makeSlotLabeller). */} - +
- + ); }); @@ -677,37 +753,23 @@ function buildDisplayModel(rounds) { })); const columns = []; for (let dr = maxDR; dr >= 1; dr--) columns.push(real.filter((m) => m.displayRound === dr)); - // Structural-bye slots: for non-leaf matches whose feeder[i] is "" (meaning - // one side had no upstream match and the player seeded directly), insert a - // visible placeholder card in the upstream column so the tree layout - // communicates the skip spatially: mirroring the Excel Tree sheet output. - // Leaf-round matches (displayRound === maxDR) always have "" feeders for real - // players; those don't need placeholders. + // Feeder graph: each real match's upstream MATCHES, in [sideA, sideB] + // order with the empty entries dropped. A "" feeder is a side with no + // upstream match: a competitor who skips one or more rounds (a structural + // bye) and first fights in this card. That competitor appears ONLY here, + // as the EKC reference sheets and the Excel tree page print them: no + // placeholder card in the column before and no connector into that side + // (operator ruling, bc-tmfn). The raw [sideA, sideB] `feeders` stays on + // each column entry (the spread above), because the connector needs to + // know WHICH side a lone feeder fills (connectorTargetY). const feedersById = {}; - real.forEach((m) => { - const hasUpstream = m.displayRound < maxDR; - const resolvedFeeders = []; - (m.feeders || []).forEach((feederId, idx) => { - if (feederId === "" && hasUpstream) { - const playerObj = idx === 0 ? m.sideA : m.sideB; - const playerName = typeof playerObj === "object" ? (playerObj?.name || "") : (playerObj || ""); - const slot = { id: `bye-${m.id}-${idx}`, isByeSlot: true, displayRound: m.displayRound + 1, playerName }; - feedersById[slot.id] = []; - const colIdx = maxDR - slot.displayRound; - if (colIdx >= 0 && colIdx < columns.length) columns[colIdx].push(slot); - resolvedFeeders.push(slot.id); - } else if (feederId !== "") { - resolvedFeeders.push(feederId); - } - }); - feedersById[m.id] = resolvedFeeders; - }); + real.forEach((m) => { feedersById[m.id] = (m.feeders || []).filter(Boolean); }); // Match numbers: the "M1", "M2" stamped on the cards. A referee reads the // printed Excel sheet and the operator's screen side by side, so "M12" here // and "Match 12" there must name the same bout. // // The SERVED number is the answer whenever the bracket carries one: the - // engine already computed it (engine.assignBracketMatchNumbers → + // server already computed it (state.Bracket.NumberMatches → // state.BracketMatch.MatchNumber, on the wire as matchNumber) and the whole // payload reaches us untouched (normalizeMatch spreads the match). Deriving // it a second time here only bought a second thing to drift from the sheet, @@ -759,23 +821,43 @@ function buildDisplayModel(rounds) { } // computeMetaTops lays out an (uneven) effective-round bracket. It walks the -// feeder graph from the final: matches with no feeders ("seeded" entrants: real -// players or bye recipients) are stacked top-to-bottom in depth-first encounter -// order, and every parent is centred so its OWN connector anchor sits at the mean -// of its feeders' anchors. Returns a map of matchId → absolute top (px). +// feeder graph from the final: matches with no feeders are stacked +// top-to-bottom in depth-first encounter order, and every parent is centred so +// its OWN connector anchor sits at the mean of its feeders' anchors. Returns a +// map of matchId → absolute top (px). +// +// "No feeders" covers every card whose two competitors both come straight in, +// wherever its column is: a first-round bout, and equally a later-round card +// whose two competitors both skipped the earlier rounds (in a 5-entrant draw, +// the semifinal between two direct entrants). Such a card is stacked like a +// first-round bout, in its own column, at the running cursor. A card fed by +// ONE match (the other side is a direct entrant) has a single anchor to +// average, so it sits level with its feeder, seam on seam; the connector then +// turns at the elbow to reach the fed row (connectorTargetY). // // `heights` is matchId → measured card height. `offsets` is matchId → anchor // distance from the card top (the y the SVG connectors join at: the sides-block -// midline for match cards, the geometric centre for bye-slot cards). Centring on -// the mean of feeder ANCHORS rather than geometric centres keeps the elbow on each -// card's seam even when a tall, header-offset match card feeds a child alongside a -// shorter bye-slot card: otherwise the asymmetric offset shifts the merge ~6px -// off the seam (delta != 0). `offsets` defaults to h/2 for any id it omits, so a -// caller that passes only heights gets the prior geometric-centre-of-mass layout. -function computeMetaTops(columns, feedersById, heights, offsets = {}) { +// midline, below the geometric centre by the meta header). Centring on the mean +// of feeder ANCHORS rather than geometric centres puts each parent's seam at +// the mean of its feeders' seams whatever their heights. `offsets` defaults to +// h/2 for any id it omits, so a caller that passes only heights gets the +// geometric-centre layout. +// computeMetaTops places every card: a card no match feeds is stacked down the +// page in depth-first order from the final, and a card two matches feed is +// centred on their anchors (each card's anchor is the seam between its two +// rows, `offsets`). A card ONE match feeds (oneSidedFeed) is placed so the +// centre of the row that match fills, `fedRowMids[id]` from the card's top, +// is level with the feeder's anchor, so the line between them runs straight. +// That row sits half a row above or below the card's seam, so the card is +// shifted by that much against its feeder, and the stacking cursor moves by +// the same amount so the shifted card keeps clear of the cards stacked before +// and after it. Without a fedRowMids entry the card is levelled at its seam. +function computeMetaTops(columns, feedersById, heights, offsets = {}, fedRowMids = {}) { const GAP = 16; const DEFAULT_H = 110; const offsetOf = (id) => offsets[id] ?? (heights[id] || DEFAULT_H) / 2; + const rawFeedersOf = {}; + columns.forEach((col) => col.forEach((m) => { rawFeedersOf[m.id] = m.feeders; })); const anchorOf = {}; const inProgress = new Set(); let cursor = 0; @@ -787,6 +869,21 @@ function computeMetaTops(columns, feedersById, heights, offsets = {}) { // bracket.json must not crash the renderer: break the cycle and return 0. if (inProgress.has(id)) return 0; inProgress.add(id); + const one = oneSidedFeed(rawFeedersOf[id]); + if (one && fedRowMids[id] != null) { + // How far the fed row's centre sits below the seam: positive for the + // Shiro row (the card rises against its feeder), negative for the Aka + // row (it drops). Rising, the feeder's subtree is stacked that much + // lower first, so the card itself lands where it would have been; dropping, + // what is stacked after it starts that much lower. + const shift = fedRowMids[id] - offsetOf(id); + if (shift > 0) cursor += shift; + const a = visit(one.fid) - shift; + if (shift < 0) cursor -= shift; + anchorOf[id] = a; + inProgress.delete(id); + return a; + } const fs = (feedersById[id] || []).filter(Boolean); const h = heights[id] || DEFAULT_H; if (fs.length === 0) { @@ -846,16 +943,52 @@ function connectorPath({ fRight, fMidY, mLeft, mMidY, elbowX }) { return `M ${fRight} ${fMidY} L ${elbowX} ${fMidY} L ${elbowX} ${mMidY} L ${mLeft} ${mMidY}`; } +// connectorTargetY: the y (tree-relative px) at which the connector from +// feeder `fid` ends on its parent card (operator ruling, bc-tmfn, option A). +// +// `feeders` is the parent's RAW [sideA, sideB] feeder pair from the engine, +// where "" marks a side with no upstream match (a competitor who skipped the +// earlier rounds and first fights in this card). +// - Fed by TWO matches: the connector joins at the card's anchor, the seam +// between its two rows (`cardAnchorY`), where the two feeders merge. +// - Fed by exactly ONE match: the connector ends on the row that match fills, +// at that row's vertical centre: index 0 is sideA, the Aka row +// (.bc-side--a); index 1 is sideB, the Shiro row (.bc-side--b). Joining at +// the seam there would read as feeding both competitors. +// +// `rowMidY(side)` returns the centre of the "a" or "b" row, or null when it +// cannot be measured (falls back to the anchor). It is called ONLY in the +// one-feeder case, so the effect measures a row element only when this rule +// needs it. Pure apart from that callback, so it is unit-testable without a +// layout engine, like elbowXFor/connectorPath above. +function connectorTargetY({ feeders, fid, cardAnchorY, rowMidY }) { + const one = oneSidedFeed(feeders); + if (!one || one.fid !== fid) return cardAnchorY; + const y = rowMidY(one.side); + return y == null ? cardAnchorY : y; +} + +// oneSidedFeed: for a card's RAW [sideA, sideB] feeder pair, the one match +// that feeds it when exactly one does, as { side: "a" (sideA, the Aka row) or +// "b" (sideB, the Shiro row), fid }, else null. The ONE statement of "a card +// fed by one match": connectorTargetY ends that match's line on the fed row, +// and computeMetaTops places the card so the fed row is level with the +// feeder, which is what makes that line straight. +function oneSidedFeed(feeders) { + const fed = []; + (feeders || []).forEach((f, i) => { if (f) fed.push(i); }); + if (fed.length !== 1 || fed[0] > 1) return null; + return { side: fed[0] === 0 ? "a" : "b", fid: feeders[fed[0]] }; +} + // columnRightEdge: the right edge (tree-relative px) of the first mounted // card in `col`, or null if col is empty/unmounted. Every card in a column // shares the same right edge: once positioned, each .bc-match-wrap gets // `left: 0, right: 0` from wrapStyle in BracketTreeMeta, and before that // first measure pass it already spans the same width because its parent -// .bc-round-matches is a flex column with the default stretch cross-axis — -// so any one member's rect gives the whole column's edge. In practice this -// reads a .bc-match: buildDisplayModel pushes each column's real matches -// before appending its bye-slot placeholders, so a real match's ref is -// always found first. +// .bc-round-matches is a flex column with the default stretch cross-axis, +// so any one member's rect gives the whole column's edge. Every column entry +// is a real match's .bc-match (buildDisplayModel draws no placeholder cards). function columnRightEdge(col, refMap, treeRect) { if (!col) return null; for (const m of col) { @@ -868,9 +1001,12 @@ function columnRightEdge(col, refMap, treeRect) { // BracketConnectorsMeta draws feeder→parent elbows for the effective-round // layout (mp-7f2w). Unlike the legacy BracketConnectors it pairs by the explicit // feeder graph, not binary (2i, 2i+1) positions, so uneven columns connect -// correctly. Bye-slot placeholder cards (isByeSlot) appear in the feeder graph -// and in refMap, so they DO receive connector lines from their parent match: -// the elbow terminates at the bye card, mirroring the Excel Tree sheet. +// correctly. Only real matches are in the feeder graph, so a side whose +// competitor skipped the earlier rounds receives no connector at all; where +// such a side leaves a card fed by ONE match, that connector ends on the row +// the match fills rather than at the card's seam (connectorTargetY), and the +// card is placed with that row level with the feeder (computeMetaTops), so +// the connector runs straight across. // // Every elbow routes through the gap immediately before the PARENT's column // (elbowXFor), measured from the DOM rather than the CSS `.bc-tree` gap @@ -900,7 +1036,16 @@ function BracketConnectorsMeta({ columns, feedersById, treeRef, refMap, version, if (!mEl) return; const mR = mEl.getBoundingClientRect(); const mLeft = mR.left - treeRect.left; - const mMidY = anchorY(mEl, mR, treeRect.top); + const cardAnchorY = anchorY(mEl, mR, treeRect.top); + // The fed row's centre, measured only when connectorTargetY asks + // (a card fed by ONE match). Both PlayerLine shapes carry + // bc-side--a / bc-side--b, the empty (TBD) row included. + const rowMidY = (side) => { + const row = mEl.querySelector(`.bc-side--${side}`); + if (!row) return null; + const r = row.getBoundingClientRect(); + return (r.top + r.bottom) / 2 - treeRect.top; + }; if (gapBefore == null) { const prevRight = columnRightEdge(columns[ci - 1], refMap, treeRect); if (prevRight != null) gapBefore = mLeft - prevRight; @@ -919,12 +1064,26 @@ function BracketConnectorsMeta({ columns, feedersById, treeRef, refMap, version, const fR = fEl.getBoundingClientRect(); const fRight = fR.right - treeRect.left; const fMidY = anchorY(fEl, fR, treeRect.top); + const mMidY = connectorTargetY({ feeders: m.feeders, fid, cardAnchorY, rowMidY }); out.push({ key: `${fid}->${m.id}`, d: connectorPath({ fRight, fMidY, mLeft, mMidY, elbowX }) }); }); }); }); setPaths(out); - setSize({ w: tree.scrollWidth, h: tree.scrollHeight }); + // Sized to the cards' own extent, not to tree.scrollWidth/scrollHeight: + // those include this SVG, so once drawn wide it would hold the tree at + // that width, and a tree whose columns narrow to fit + // (.bracket-canvas--fit) would keep scrolling after it had shrunk. + let w = 0; + let h = 0; + columns.forEach((col) => col.forEach((m) => { + const el = refMap.current[m.id]; + if (!el) return; + const r = el.getBoundingClientRect(); + w = Math.max(w, r.right - treeRect.left); + h = Math.max(h, r.bottom - treeRect.top); + })); + setSize({ w, h }); }; compute(); const ro = new ResizeObserver(compute); @@ -943,9 +1102,10 @@ function BracketConnectorsMeta({ columns, feedersById, treeRef, refMap, version, ); } -// BracketTreeMeta renders the effective-round columns (mp-7f2w). Real match cards -// plus structural-bye placeholder cards (isByeSlot=true) are included; phantoms -// (hidden) are dropped. All cards are absolutely positioned at the +// BracketTreeMeta renders the effective-round columns (mp-7f2w). Only real match +// cards are drawn: phantoms (hidden) are dropped, and a competitor who skips a +// round appears only in the card of the match they first fight, with no +// placeholder before it (bc-tmfn). All cards are absolutely positioned at the // feeder-graph-derived top so parents sit centred on their feeders // (see computeMetaTops). function BracketTreeMeta({ columns, feedersById, matchNumById, slotLabel, variant = 1, showDojo = true, onMatchClick, highlightedMatchId, autoScrollMatchId, scrollContainerRef, highlightPlayers, isEngi }) { @@ -972,17 +1132,28 @@ function BracketTreeMeta({ columns, feedersById, matchNumById, slotLabel, varian if (!tree || !columns || columns.length === 0) return; const heights = {}; const offsets = {}; + const fedRowMids = {}; for (const col of columns) { for (const m of col) { const el = refMap.current[m.id]; if (!el) return; const rect = el.getBoundingClientRect(); heights[m.id] = rect.height; + // A card fed by ONE match: the centre of the row that match fills, + // which computeMetaTops levels with the feeder. Both PlayerLine + // shapes carry bc-side--a / bc-side--b, the empty (TBD) row included. + const one = oneSidedFeed(m.feeders); + const fedRow = one && el.querySelector(`.bc-side--${one.side}`); + if (fedRow) { + const r = fedRow.getBoundingClientRect(); + fedRowMids[m.id] = (r.top + r.bottom) / 2 - rect.top; + } // Anchor offset from the card top: the y the SVG connectors join at. - // Mirrors anchorY(): sides-block midline for match cards, geometric - // centre for bye-slot cards (no .bc-side). Passing this to computeMetaTops - // centres parents on feeder ANCHORS, not geometric centres, so the elbow - // lands on the seam even for a match-card + bye-slot feeder pair. + // Mirrors anchorY(): the sides-block midline, falling back to the + // geometric centre for a card without two .bc-side rows. Passing this + // to computeMetaTops centres parents on feeder ANCHORS, not geometric + // centres, so a two-feeder parent's seam lands on the mean of its + // feeders' seams whatever their card heights. const sides = el.querySelectorAll(".bc-side"); if (sides.length >= 2) { const f = sides[0].getBoundingClientRect(); @@ -993,7 +1164,7 @@ function BracketTreeMeta({ columns, feedersById, matchNumById, slotLabel, varian } } } - const tops = computeMetaTops(columns, feedersById, heights, offsets); + const tops = computeMetaTops(columns, feedersById, heights, offsets, fedRowMids); // Every column is absolutely positioned, so the flow height of each // round-matches container is 0 and the tree would collapse. Derive the // overall content height from the lowest card bottom and pin it on the @@ -1054,41 +1225,20 @@ function BracketTreeMeta({ columns, feedersById, matchNumById, slotLabel, varian const wrapStyle = top != null ? { "--mi": mi, position: "absolute", top: `${top}px`, left: 0, right: 0 } : { "--mi": mi }; - const inner = m.isByeSlot ? ( -
{ if (el) refMap.current[m.id] = el; }} - > - {/* The BYE tag is unconditional: a named slot without it is - just a grey box with a name in it, which reads as an - unexplained extra card rather than "this entrant advanced - unopposed". Name + tag share one flex row (see - .bc-bye-slot in styles.css): the name takes the free space - and ellipsises, the tag never shrinks, so a long name - truncates instead of pushing the marker out of the - fixed-width column. Rendered once in both cases: the - nameless slot is the tag alone, exactly as before. */} - {m.playerName ? {m.playerName} : null} - BYE -
- ) : ( - { if (el) refMap.current[m.id] = el; }} - onClick={() => onMatchClick && onMatchClick(m, ci, mi, columns.length)} - highlightPlayers={highlightPlayers} - matchNum={matchNumById[m.id]} - isEngi={isEngi} - slotLabel={slotLabel} - /> - ); return (
- {inner} + { if (el) refMap.current[m.id] = el; }} + onClick={() => onMatchClick && onMatchClick(m, ci, mi, columns.length)} + highlightPlayers={highlightPlayers} + matchNum={matchNumById[m.id]} + isEngi={isEngi} + slotLabel={slotLabel} + />
); })} @@ -1303,24 +1453,34 @@ function matchStateCell(m) { // min-width (COL) + .bc-tree's gap (GAP). numCols comes from the same // buildDisplayModel the tree renders from, so phantom bye columns are counted // and the offset stays correct for any bracket size. The smaller card (CARD) is -// centred under the full-width final column. +// centred under the full-width final column. For the tree's fixed 230px +// columns (the public Bracket tab); the admin Bracket page, whose columns +// narrow to fit (.bracket-canvas--fit), places its bronze in a row of column +// slots instead (.bracket-bronze-row, admin_competition_bracket.jsx). function bronzeUnderFinalStyle(rounds) { // CARD (210) is the smallest width that still fits a typical winner name // without ellipsis truncation (measured live: "Haruto Watanabe" fits at 210, // truncates at 205), while staying visibly smaller than the 230px final it // sits under. COL/GAP mirror .bc-round min-width / .bc-tree gap. const COL = 230, GAP = 56, CARD = 210; + const colOffset = Math.max(0, bracketColumnCount(rounds) - 1) * (COL + GAP); + return { width: CARD, marginLeft: colOffset + (COL - CARD) / 2 }; +} + +// bracketColumnCount: how many columns the tree draws for these rounds, from +// the same buildDisplayModel the tree renders from (effective rounds when the +// engine supplied display metadata, the raw rounds otherwise). +function bracketColumnCount(rounds) { const model = buildDisplayModel(rounds); - const numCols = (model && model.hasMeta && Array.isArray(model.columns)) + return (model && model.hasMeta && Array.isArray(model.columns)) ? model.columns.length : (Array.isArray(rounds) ? rounds.length : 1); - const colOffset = Math.max(0, numCols - 1) * (COL + GAP); - return { width: CARD, marginLeft: colOffset + (COL - CARD) / 2 }; } window.BracketTree = BracketTree; window.MatchCard = MatchCard; window.bronzeUnderFinalStyle = bronzeUnderFinalStyle; +window.bracketColumnCount = bracketColumnCount; window.roundLabel = roundLabel; // Exposed so every surface that labels a bracket MATCH (viewer rows, admin // score editor, TV/display boards) uses the effective-round rule rather than @@ -1348,5 +1508,14 @@ window.enchoOn = enchoOn; window.matchMiddleMark = matchMiddleMark; window.winnerSideLR = winnerSideLR; window.sideLabel = sideLabel; - -export { formatIpponsScore, enchoLabel, boutMiddle, defaultWinMaru, matchMiddleMark, winnerSideLR, sideLabel, roundLabel, bracketRoundLabel, teamIVScore, teamIVPWScore, engiFlagScore, matchScoreStr, matchStateCell, buildDisplayModel, computeMetaTops, bronzeUnderFinalStyle, PlayerLine, slotDisplayName, makeSlotLabeller, bracketSlotLabeller, MatchCard, BracketTree, elbowXFor, connectorPath }; +// sideMarks/placeMarks: the same MatchCard pattern (which side gets which +// result mark), exposed for the window-global consumers that place a +// match-level Kiken/Fus. mark beside a withdrawn TEAM's name (bc-tmfn) -- +// VSchedItem/TWMatch/PoolNumberedMatchRow/the admin Scores row -- none of +// which ES-import bracket.jsx (see those files' own window.matchScoreStr / +// window.boutMiddle usage for the pre-existing pattern this follows). +window.sideMarks = sideMarks; +window.placeMarks = placeMarks; +window.teamMatchMarks = teamMatchMarks; + +export { formatIpponsScore, enchoLabel, boutMiddle, defaultWinMaru, matchMiddleMark, sideMarks, placeMarks, teamMatchMarks, winnerSideLR, sideLabel, roundLabel, bracketRoundLabel, teamIVScore, teamIVPWScore, engiFlagScore, matchScoreStr, matchStateCell, buildDisplayModel, computeMetaTops, bronzeUnderFinalStyle, bracketColumnCount, PlayerLine, slotDisplayName, makeSlotLabeller, bracketSlotLabeller, MatchCard, BracketTree, elbowXFor, connectorPath, connectorTargetY }; diff --git a/web-mobile/js/display_helpers.jsx b/web-mobile/js/display_helpers.jsx index 297e15dcc..05eadc475 100644 --- a/web-mobile/js/display_helpers.jsx +++ b/web-mobile/js/display_helpers.jsx @@ -4,6 +4,7 @@ import { withNumber, numberedParts } from './match_scoreboard.jsx'; import { poolNameOf, isSupplementaryBout, swissRoundLabel } from './pool_ids.jsx'; +import { isBarredMatch } from './ineligible_match.jsx'; // NOTE: this module is the shared *leaf* in the display graph: presentation // modules (scoreboard, lobby, streaming) import from here, not vice versa. @@ -125,6 +126,7 @@ function findUpcomingOnCourt(competitions, court, limit = 2) { if ((m.court || "") !== court) continue; if (m.status !== "scheduled") continue; if (!bracketSidesReady(m)) continue; + if (isBarredMatch(m)) continue; // cannot be fought as scheduled out.push({ ...m, _comp: c }); } const rounds = (c.bracket && c.bracket.rounds) || []; @@ -132,6 +134,7 @@ function findUpcomingOnCourt(competitions, court, limit = 2) { if ((m.court || "") !== court) return; if (m.status !== "scheduled") return; if (!bracketSidesReady(m)) return; // skip "Pool X-Nth" / "Winner of …" placeholders + if (isBarredMatch(m)) return; // cannot be fought as scheduled out.push({ ...m, _comp: c, _isBracket: true, _roundIndex: ri, _totalRounds: rounds.length }); })); } @@ -226,6 +229,7 @@ function findActiveCourts(tournament, competitions) { function queueLabel(m) { if (!m) return ""; if (m.status !== "scheduled") return ""; + if (isBarredMatch(m)) return ""; const qp = Number(m.queuePosition); if (Number.isFinite(qp) && qp > 0) { if (qp === 1) return "Next up"; @@ -243,6 +247,7 @@ function queueLabel(m) { function queueLabelCompact(m) { if (!m) return null; if (m.status !== "scheduled") return null; + if (isBarredMatch(m)) return null; const qp = Number(m.queuePosition); // Use Number.isFinite so Infinity/-Infinity are rejected alongside NaN: // matches queueLabel's guard. isNaN alone would let Infinity through and diff --git a/web-mobile/js/display_scoreboard.jsx b/web-mobile/js/display_scoreboard.jsx index 53a5f65f7..16517337e 100644 --- a/web-mobile/js/display_scoreboard.jsx +++ b/web-mobile/js/display_scoreboard.jsx @@ -7,8 +7,9 @@ import { findRunningOnCourt, findUpcomingOnCourt, countCourtMatches, sideLabelParts, phaseLabel, poolNameOf, isSupplementaryBout, phaseProgressOnCourt, bracketRoundSiblings, StreamingQR } from './display_helpers.jsx'; import { NumberedName } from './numbered_name.jsx'; import { teamMatchTypeFor, DAIHYOSEN_POSITION } from './pool_ids.jsx'; -import { TeamScoreboard, IndividualScore, useTeamLineups, teamIVPW, teamIVPWFrom } from './match_scoreboard.jsx'; -import { realIppons } from './result_slot.jsx'; +import { TeamScoreboard, IndividualScore, useTeamLineups, teamIVPWFrom, teamNameMark } from './match_scoreboard.jsx'; +import { isBarredMatch } from './ineligible_match.jsx'; +import { subBoutHasResult } from './team_default_credit.jsx'; const { useMemo: useMD } = React; @@ -108,7 +109,30 @@ function TvWhiteBoard({ tournament, court, linkState = 'connected', promoted, is // one computation, never two chances to disagree. const matchSideA = promoted.match.sideA?.name || (typeof promoted.match.sideA === "string" ? promoted.match.sideA : ""); const matchSideB = promoted.match.sideB?.name || (typeof promoted.match.sideB === "string" ? promoted.match.sideB : ""); - const { ivShiro, ivAka, pwShiro, pwAka } = teamIVPWFrom(promoted.match?.teamResult, subResults, matchSideA, matchSideB); + const isKachinuki = teamMatchTypeFor(promoted.competition) === "kachinuki"; + // bc-tmfn: the match-level default-win credit context, threaded into + // BOTH the headline's own teamIVPWFrom call and TeamScoreboard below (see + // team_default_credit.jsx for the whole rule). Every field is OPTIONAL on + // TeamScoreboard/teamIVPWFrom, so this is a no-op wherever the ruling is + // not in force. + const matchCtx = { status: promoted.match?.status, decision: promoted.match?.decision, decisionBy: promoted.match?.decisionBy, kachinuki: isKachinuki }; + const { ivShiro, ivAka, pwShiro, pwAka } = teamIVPWFrom(promoted.match?.teamResult, subResults, matchSideA, matchSideB, matchCtx); + // bc-tmfn: the match-level Kiken/Fus. mark for a team a default-win + // decision withdrew/barred, placed beside the headline name -- the SAME + // pattern MatchCard (bracket.jsx) uses (sameCompetitor + sideMarks + + // placeMarks). bc-cse: reached through the ONE shared composition of + // that pattern, window.teamMatchMarks (bracket.jsx), rather than + // re-derived inline here -- this board reaches bracket.jsx's primitives + // through window globals like every other viewer/display module (see + // result_slot.jsx's header). isTeamMatch is this board's OWN team-match + // signal (competition format), passed through as the override + // teamMatchMarks accepts. Computed ONCE here and threaded into + // TeamScoreboard too, so the headline and the (suppressed, on this + // variant) summary row can never disagree about which side carries the + // mark. + const { shiro: teamShiroMark, aka: teamAkaMark } = window.teamMatchMarks + ? window.teamMatchMarks(promoted.match, isTeamMatch) + : { shiro: "", aka: "" }; // NO middle mark here (operator ruling): the FIK row centre rendered by the // shared scoreboard below is the mark's ONE home, and this header chip // duplicating X/(E)/(DH) ~10cm above it was an error; the plain "vs" stays. @@ -175,7 +199,13 @@ function TvWhiteBoard({ tournament, court, linkState = 'connected', promoted, is here: this is a projector/OBS surface with no screen-reader audience, unlike the operator consoles that did take one. */}
-
{repShiro || }
+ {/* bc-cse: msb-name--labelled -- see viewer_match.jsx's + VSchedItem comment for why a mark riding beside a + clip-mode name needs its own flex row: without it a + long team name clips the Kiken/Fus. mark off the end. + No-op (harmless) when repShiro renders instead, since + that branch carries no mark at all. */} +
{repShiro || teamNameMark("shiro", teamShiroMark, )}
{repShiro &&
} {isTeamMatch && (
@@ -186,7 +216,11 @@ function TvWhiteBoard({ tournament, court, linkState = 'connected', promoted, is
{nameCentre}
-
{repAka || }
+ {/* bc-cse: msb-name--labelled + msb-name--aka (flex row, + justify-content flex-end so it still reads to the + right once .name-labelled overrides the parent's + textAlign, which no longer reaches a flex child). */} +
{repAka || teamNameMark("aka", teamAkaMark, )}
{repAka &&
} {isTeamMatch && (
@@ -220,7 +254,9 @@ function TvWhiteBoard({ tournament, court, linkState = 'connected', promoted, is matchSideA={matchSideA} matchSideB={matchSideB} squadA={squadA} squadB={squadB} numberA={promoted.match.sideA?.number || ""} numberB={promoted.match.sideB?.number || ""} - kachinuki={teamMatchTypeFor(promoted.competition) === "kachinuki"} /> + kachinuki={isKachinuki} + decision={promoted.match?.decision} decisionBy={promoted.match?.decisionBy} status={promoted.match?.status} + shiroMark={teamShiroMark} akaMark={teamAkaMark} />
) : (
@@ -368,6 +404,10 @@ function findNextPoolOnCourt(competition, currentPoolName, court) { const zekken = !!competition.withZekkenName; const bouts = competition.poolMatches .filter(m => poolNameOf(m.id) === nextName) + // A barred bout (ineligible_match.jsx) cannot be fought as scheduled, + // so it is not a real "up next" bout for this strip: same picker rule + // as findUpcomingOnCourt. + .filter(m => !isBarredMatch(m)) .sort(compareByRunOrder) .map(m => ({ id: m.id, @@ -518,7 +558,7 @@ function TvIndividualBoard({ tournament, court, linkState = 'connected', promote background: isNow ? "var(--accent-soft)" : isDone ? "#f9fafb" : "transparent", opacity: isNow ? 1 : isDone ? 0.88 : 0.78, }}> - +
); })} @@ -678,45 +718,36 @@ function TvDisplay({ court, tournament, competitions, withZekkenName, linkState // mp-13y: DH (Daihyosen) row gating: shown when: // 1. All regular bouts are complete (every sub has ippons, hantei, or draw). - // 2. IV (Individual Victories) are tied. - // 3. PW (Points Won) are also tied. - // 4. It is a knockout phase (not a pool match). + // 2. It is a knockout phase (not a pool match). + // 3. IV (Individual Victories) and PW (Points Won) are both tied, which + // TeamScoreboard checks itself (see the end of the memo below). // The DH sub-result (position === DAIHYOSEN_POSITION) may or may not exist // yet; when absent, TeamScoreboard renders a "Daihyosen pending" placeholder. const subResults = (promoted && promoted.match && promoted.match.subResults) || []; const isKnockoutPhase = !!(promoted && promoted.isBracket) || !!(promoted && promoted.match && promoted.match.phase === "bracket"); - // Extract stable string primitives from promoted.match.sideA/B so the - // useMemo below can dep on values rather than the promoted object literal - // (which is recreated on every render, defeating memoisation). - const promotedSideA = promoted?.match?.sideA?.name || (typeof promoted?.match?.sideA === "string" ? promoted.match.sideA : ""); - const promotedSideB = promoted?.match?.sideB?.name || (typeof promoted?.match?.sideB === "string" ? promoted.match.sideB : ""); const showDH = useMD(() => { if (!isTeamMatch || !isKnockoutPhase) return false; const regularSubs = subResults.filter(s => s.position > DAIHYOSEN_POSITION); if (regularSubs.length === 0) return false; - const allDone = regularSubs.every(s => { - const aIp = realIppons(s.ipponsA); - const bIp = realIppons(s.ipponsB); - // Mirror the shared scoreboard's "scored" test: a bout can also be - // decided with no ippon letters: fusensho/kiken (winner + decision), - // a hansoku award, or an explicit winner. Without these, a tied - // knockout closed by forfeit would never satisfy allDone and the - // Daihyosen row would be suppressed forever. - return aIp.length > 0 || bIp.length > 0 || s.decidedByHantei || - !!s.winner || (typeof s.decision === "string" && s.decision !== "") || - s.hansokuA > 0 || s.hansokuB > 0 || - (typeof window.isHikiwake === "function" && - (window.isHikiwake(s.score?.type) || window.isHikiwake(s.decision))); - }); - if (!allDone) return false; - // The match is tied (→ show DH) when IV and PW are level per side. - // teamIVPW already prefers an explicit `sub.winner` (which the server - // guarantees equals sideA/sideB), so a hantei-decided 0-0 bout is - // counted as an IV for its winner there: no extra hantei loop needed. - const { ivShiro, ivAka, pwShiro, pwAka } = teamIVPW(subResults, promotedSideA, promotedSideB); - return ivShiro === ivAka && pwShiro === pwAka; - }, [subResults, isTeamMatch, isKnockoutPhase, promotedSideA, promotedSideB]); + // bc-cse: subBoutHasResult (team_default_credit.jsx) is THE wire-level + // "has a result" test -- a winner, a decision, a struck point, the + // hantei mark, a hansoku award, or an overtime -- exactly what this + // used to hand-roll here. Without one of those, a tied knockout closed + // by forfeit would never satisfy allDone and the Daihyosen row would + // be suppressed forever. The isHikiwake(score.type) arm is the one + // legacy/client-only branch it does not cover (a bare quick-score + // synth shape), same as TeamScoreboard's isScored (match_scoreboard.jsx) + // this mirrors. + const allDone = regularSubs.every(s => subBoutHasResult(s) || + (typeof window.isHikiwake === "function" && window.isHikiwake(s.score?.type))); + // Whether the encounter is TIED is not asked here: TeamScoreboard + // gates the row on showDH && its own tie test, which reads the + // server's teamResult first (teamIVPWFrom), exactly as the viewer + // card does. A second tie test here from the bout rows alone could + // only disagree with that one and hide a row the card shows. + return allDone; + }, [subResults, isTeamMatch, isKnockoutPhase]); // White scoreboard for any promoted match. // Team → TvWhiteBoard (IV/PW summary + bout grid). Individual → grouped diff --git a/web-mobile/js/ineligible_match.jsx b/web-mobile/js/ineligible_match.jsx new file mode 100644 index 000000000..1eb5f907b --- /dev/null +++ b/web-mobile/js/ineligible_match.jsx @@ -0,0 +1,176 @@ +// ineligible_match.jsx: a scheduled match that cannot be fought because a +// competitor in it is barred. +// +// A competitor who withdrew (kiken, fusenpai, or kiken-injury until they are +// reinstated) is "prohibited from participating in following shiai" (FIK +// Art. 31), so each match they still have ends as a default win for the +// opponent. The server knows who is barred and stamps it on every SCHEDULED +// match as the read-only `ineligibleSides` ({a?: decision, b?: decision}, +// keyed by side, valued by the decision that barred that side; never stored). +// This module is the one reader of that stamp, so every surface that picks +// the next match, labels a row, or offers the default win asks the same +// question the same way. +// +// The default win is recorded as a match-level `fusensho` naming the barred +// side (decisionBy), through the existing /decision route. fusensho is the +// default win recorded on someone else's withdrawal: it writes no eligibility +// status, so the withdrawal that barred the competitor stays the one record +// of it, and reinstating or clearing that withdrawal still works. +// +// A leaf that imports only competitor_identity.jsx (itself a leaf with no +// imports), for the winner-attribution fallback withdrawnSideKey needs below: +// keep it to that, so every surface can take the rule without taking a real +// dependency graph with it. + +import { sameCompetitor } from './competitor_identity.jsx'; + +// Side A is Aka and side B is Shiro, on pool and bracket matches alike -- the +// ONE owner of this two-value mapping, in both directions (bc-cse). It used +// to be hand-rolled at four call sites: admin_scoring_shared.jsx's +// withdrawnKeyOf, team_default_credit.jsx's creditedSideKey, this constant, +// and barred_chip.jsx's barredNameMark (whose per-side "aka"/"shiro" render +// context is the same vocabulary decisionBy uses, so sideKeyForDecisionBy +// answers that question too). All four now go through sideKeyForDecisionBy +// or withdrawnSideKey below. +const DECISION_BY = { a: "aka", b: "shiro" }; + +// sideKeyForDecisionBy: the read direction of the DECISION_BY mapping above +// ("aka"/"shiro" -> "a"/"b"), "" for anything else. +export function sideKeyForDecisionBy(decisionBy) { + return decisionBy === DECISION_BY.a ? "a" : decisionBy === DECISION_BY.b ? "b" : ""; +} + +// withdrawnSideKey(m): the side ("a" = Aka/sideA, "b" = Shiro/sideB) a +// match's default-win ruling names as WITHDRAWN/BARRED -- decisionBy first, +// falling back for a LEGACY row recorded before decisionBy was captured to +// the match's own winner attribution (mirrors Go's state.DefaultWinCreditSide, +// which falls back to domain.AttributeWinnerSide the same way): the side +// that did NOT win is the one that was barred. +export function withdrawnSideKey(m) { + if (!m) return ""; + const byDecision = sideKeyForDecisionBy(m.decisionBy); + if (byDecision) return byDecision; + if (m.winner && m.sideA && sameCompetitor(m.winner, m.sideA)) return "b"; + if (m.winner && m.sideB && sameCompetitor(m.winner, m.sideB)) return "a"; + return ""; +} + +function nameOf(side) { + return side?.name || ""; +} + +// The barred sides of a match, by side key. Empty for anything but a +// scheduled match: a match already running or decided is not awaiting a +// default win, whatever its competitors' status is now. +export function barredSides(m) { + if (m.status !== "scheduled") return { a: "", b: "" }; + const s = m.ineligibleSides || {}; + return { a: s.a || "", b: s.b || "" }; +} + +// True when the match cannot be fought as scheduled. Every picker of the next +// match skips these; opening one directly is still allowed, because that is +// how the operator records its default win. +export function isBarredMatch(m) { + const { a, b } = barredSides(m); + return !!(a || b); +} + +// The default win this match awaits, or null when no side is barred or both +// are (neither can be awarded the match then). +export function awaitedDefaultWin(m) { + const { a, b } = barredSides(m); + if (!a === !b) return null; + const barredKey = a ? "a" : "b"; + const decision = a || b; + return { + barredKey, + decision, + barred: barredKey === "a" ? m.sideA : m.sideB, + opponent: barredKey === "a" ? m.sideB : m.sideA, + reinstateable: decision === "kiken-injury", + }; +} + +// The /decision body for a default win, keyed directly by which side is +// barred and who they are. Callers who already know the barred side without +// reading the `ineligibleSides` stamp (RemainingMatchesPanel walks the +// withdrawn competitor's OTHER scheduled matches directly) build the body +// here too, so a default win has ONE wire shape regardless of how the +// caller found the barred side. +export function defaultWinDecisionBodyForSide(barredKey, barredSide) { + return { + decision: "fusensho", + decisionBy: DECISION_BY[barredKey], + decisionReason: `auto: ${nameOf(barredSide)} withdrawn`, + }; +} + +// The /decision body that records the awaited default win, or null. +export function defaultWinDecisionBody(m) { + const w = awaitedDefaultWin(m); + if (!w) return null; + return defaultWinDecisionBodyForSide(w.barredKey, w.barred); +} + +// The operator's note for a barred match: who cannot fight, and what to do. +export function barredNote(m) { + const { a, b } = barredSides(m); + if (a && b) { + // bc-cse: a pool/league match can be recorded as drawn (the guide's + // rule: "If both competitors withdraw, neither receives a win or + // points"), so its note stays the plain fact; a knockout bracket + // needs a winner, so nothing here resolves it and the note says what + // the operator must fix instead. bothBarredDrawable is the ONE gate + // this text and BarredMatchNotice's action offer must agree on. + return bothBarredDrawable(m) + ? "Both withdrew earlier: neither can fight this match." + : "Neither can fight: correct the earlier withdrawal or the draw."; + } + const w = awaitedDefaultWin(m); + if (!w) return ""; + const name = nameOf(w.barred); + if (w.reinstateable) return `${name} withdrew injured: reinstate them or record the default win.`; + if (w.decision === "fusenpai") return `${name} did not appear earlier: record the default win.`; + return `${name} withdrew: record the default win.`; +} + +// The label of the operator's one-tap action. +export function defaultWinActionLabel(m) { + const w = awaitedDefaultWin(m); + return w ? `Record default win for ${nameOf(w.opponent)}` : ""; +} + +// bothBarredDrawable(m): true when a both-barred match's format allows +// recording it as drawn -- a pool/league match, never a knockout one (the +// bracket needs a winner, so a draw resolves nothing there). The server +// accepts that draw for exactly these ids (engine.IsPoolMatchID: a pool or +// league id starts "Pool "; Swiss and bracket ids do not), so the offer and +// the acceptance cannot disagree. +export function bothBarredDrawable(m) { + return m.id.startsWith("Pool "); +} + +// bothBarredDrawAction(m): the one-tap "record as drawn" action for a +// SCHEDULED pool/league match whose BOTH sides are barred, or null when +// this match does not qualify (not both barred, or a knockout bracket +// match). Bundles the gate, the button label, and the /decision body +// together -- like awaitedDefaultWin does for the single-sided case -- +// so BarredMatchNotice never re-derives any of the three. The server +// accepts this hikiwake ONLY for a scheduled pool/league match with both +// sides barred (FIK guide: "If both competitors withdraw, neither +// receives a win or points"). +export function bothBarredDrawAction(m) { + const { a, b } = barredSides(m); + if (!a || !b || !bothBarredDrawable(m)) return null; + return { + label: "Record as drawn (neither can fight)", + body: { + decision: "hikiwake", + decisionReason: `auto: ${nameOf(m.sideA)} and ${nameOf(m.sideB)} withdrawn`, + }, + }; +} + +// The spectator-facing chip beside a barred competitor's name. +export const BARRED_CHIP = "Withdrawn"; diff --git a/web-mobile/js/match_scoreboard.jsx b/web-mobile/js/match_scoreboard.jsx index f608b9a45..99a5a3099 100644 --- a/web-mobile/js/match_scoreboard.jsx +++ b/web-mobile/js/match_scoreboard.jsx @@ -22,6 +22,8 @@ import { DAIHYOSEN_POSITION } from './pool_ids.jsx'; import { resultSlot, sideSlotOrder, realIppons, hanteiTied, nameOf, attributeWinnerSide, subBoutAttribution } from './result_slot.jsx'; import { sideLookupKey } from './competitor_identity.jsx'; import { NumberedName, numberFollowsName } from './numbered_name.jsx'; +import { creditedBoutSide, subBoutHasResult } from './team_default_credit.jsx'; +import { barredNameMark } from './barred_chip.jsx'; // bc-pnum: inline style for the squad member label riding beside a bout // row's fighter name (BoutSubRow below), the public twin of @@ -31,10 +33,15 @@ import { NumberedName, numberFollowsName } from './numbered_name.jsx'; // own header warns against for admin_lineup.jsx specifically -- the same // reasoning applies to any admin panel. `em` sizing (rather than a fixed px) // scales with the name it rides beside across variant="card"/"tv". -const SQUAD_MEMBER_LABEL_STYLE = { fontSize: "0.7em", fontWeight: 600, opacity: 0.75, marginRight: "0.35em" }; -// Aka's label sits AFTER the name (the outer side), so its gap is on the -// other side of it (bc-dnst). -const SQUAD_MEMBER_LABEL_STYLE_AFTER = { ...SQUAD_MEMBER_LABEL_STYLE, marginRight: 0, marginLeft: "0.35em" }; +// +// bc-cse: no margin -- the parent .msb-name--labelled cell now supplies the +// gap via CSS (styles.css), so a margin here on top of it would double the +// space. Both entries in the pair still exist because the label's flex ORDER +// (Shiro's leading, Aka's trailing -- see the JSX below) is decided by +// element order, not by this style object; nothing else distinguishes them +// once neither carries a margin. +const SQUAD_MEMBER_LABEL_STYLE = { fontSize: "0.7em", fontWeight: 600, opacity: 0.75 }; +const SQUAD_MEMBER_LABEL_STYLE_AFTER = { ...SQUAD_MEMBER_LABEL_STYLE }; const { useState: useSB, useEffect: useEB } = React; @@ -161,7 +168,9 @@ export function useTeamLineups(match, competition, roundIndex) { let round = 0; if (typeof roundIndex === "number" && roundIndex >= 0) { round = roundIndex; - } else if (typeof match.round === "number") { + } else if (typeof match.round === "number" && match.round >= 0) { + // A pool match stores Round -1 ("no round"); like resolveRoundIndex, + // it reads as round 0 rather than asking the server for round -1. round = match.round; } else if (typeof match.round === "string") { const mr = /^Round\s+(\d+)$/.exec(match.round); @@ -489,12 +498,20 @@ export function BoutSubRow({ sub, index, lineupA, lineupB, teamSize, isDH, state // Aggregate IV (individual victories) + PW (points won) per side from the // regular (non-DH) bouts. sideB = shiro/left, sideA = aka/right. -export function teamIVPW(subResults, matchSideA, matchSideB) { +export function teamIVPW(subResults, matchSideA, matchSideB, matchCtx) { let ivShiro = 0, ivAka = 0, pwShiro = 0, pwAka = 0; // Count only real numbered bouts: skip the daihyosen sentinel AND any // malformed negative position, mirroring the Go-side defensive skip // (state.TeamResultFrom: Position <= DaihyosenSubPosition). for (const s of (subResults || []).filter(x => x.position > DAIHYOSEN_POSITION)) { + // bc-tmfn: a numbered bout with no result of its own, under an active + // match-level default-win ruling, is credited IV+1/PW+2 to the OTHER + // side from decisionBy — see team_default_credit.jsx for the whole + // rule. matchCtx is OPTIONAL (omitted callers, e.g. streaming_overlay.jsx, + // keep their pre-existing behaviour byte-for-byte: teamDefaultWinCreditActive(undefined) is false). + const creditSide = creditedBoutSide(s, matchCtx); + if (creditSide === "a") { ivAka++; pwAka += 2; continue; } + if (creditSide === "b") { ivShiro++; pwShiro += 2; continue; } const a = ipponLetters(s.ipponsA).filter(Boolean).length; const b = ipponLetters(s.ipponsB).filter(Boolean).length; pwShiro += b; pwAka += a; @@ -586,7 +603,7 @@ export function withNumber(side, withZekkenName, color) { // The rule lives here so a surface that wants it passes a flag instead of // reaching into these internals with its own selector — the shape that produced // the `.msb-sep { display: none }` bug this component just had to fix. -export function IndividualScore({ match, variant, showNames, withZekkenName, shiroName, akaName, showDojo }) { +export function IndividualScore({ match, variant, showNames, withZekkenName, shiroName, akaName, showDojo, showBarredChip }) { // nameOf, not a local unwrap: same object-or-bare-string rule the slot leaf's // hanteiWinnerKey applies, so the two attribution paths cannot read a side // name differently. @@ -652,12 +669,28 @@ export function IndividualScore({ match, variant, showNames, withZekkenName, shi // inherited `normal` and truncates the name instead — so the stacked cell // takes the unclipped form, where `.numbered-name { display: contents }` // leaves chip and name inline and the cell's own wrapping applies. - const nameCell = (parts, side, dojo) => ( - <> - - {dojo && {dojo}} - - ); + // bc-cse: showBarredChip wraps the name in barredNameMark (barred_chip.jsx), + // the SAME "Withdrawn" chip every phone schedule row (VSchedItem, + // viewer_schedule.jsx's TWMatch, viewer_standings.jsx) already shows for a + // scheduled match a competitor is barred from -- the TV individual pool + // board (TvIndividualBoard) is the one caller that opts in, since a barred + // bout used to list there as a plain scheduled row with no chip at all. + // Off by default: the lobby board and the viewer's own match card render + // through this same showNames path and are unaffected. msb-name--labelled + // (fix bc-cse #4) makes the cell a flex row so the chip is a flex:none + // sibling and only the NumberedName clip wrapper shrinks/ellipsises -- + // without it a long name clipped the chip off the end (or, on Aka, pushed + // its leading chip past the visible width), the same class of bug the + // member-label rows were fixed for. + const nameCell = (parts, side, dojo) => { + const nameEl = ; + return ( + <> + {showBarredChip ? barredNameMark(match, side, nameEl) : nameEl} + {dojo && {dojo}} + + ); + }; // Emphasise the decided winner's NAME. sub.winner is already id-first with a // name fallback and is blanked for an indistinguishable same-name pair, so // neither side lights up when the data cannot attribute the win. This is the @@ -667,12 +700,13 @@ export function IndividualScore({ match, variant, showNames, withZekkenName, shi // for an ippon-LESS result, i.e. hantei or a default win). const winShiroName = !!sub.winner && sub.winner === sub.sideB; const winAkaName = !!sub.winner && sub.winner === sub.sideA; + const labelledCls = showBarredChip ? " msb-name--labelled" : ""; return (
- {showNames ? nameCell(shiroParts, "shiro", shiroDojo) : ""} + {showNames ? nameCell(shiroParts, "shiro", shiroDojo) : ""} {centreMarks(sub)} - {showNames ? nameCell(akaParts, "aka", akaDojo) : ""} + {showNames ? nameCell(akaParts, "aka", akaDojo) : ""}
); @@ -693,10 +727,10 @@ export function IndividualScore({ match, variant, showNames, withZekkenName, shi // IV/PW readout (display_scoreboard.jsx) and TeamScoreboard's own §277 // summary row derive from the exact same function and can never disagree // about which source won. -export function teamIVPWFrom(teamResult, subResults, matchSideA, matchSideB) { +export function teamIVPWFrom(teamResult, subResults, matchSideA, matchSideB, matchCtx) { return teamResult && typeof teamResult === "object" ? { ivShiro: teamResult.shiroIV || 0, ivAka: teamResult.akaIV || 0, pwShiro: teamResult.shiroPW || 0, pwAka: teamResult.akaPW || 0 } - : teamIVPW(subResults, matchSideA, matchSideB); + : teamIVPW(subResults, matchSideA, matchSideB, matchCtx); } // TeamScoreboard: §277 team table: an IV/PW summary row (labeled, per side) + @@ -709,11 +743,35 @@ export function teamIVPWFrom(teamResult, subResults, matchSideA, matchSideB) { // squadA/squadB/numberA/numberB (bc-pnum): threaded straight through to every // BoutSubRow, which is where the label is actually composed and rendered; // see that component's header for the props' shape and defaults. -export function TeamScoreboard({ subResults, teamResult, lineupA, lineupB, teamSize, showDH, variant, shiroName, akaName, matchSideA, matchSideB, isRunning, kachinuki, squadA, squadB, numberA, numberB }) { +// teamNameMark: a match-level result mark (Kiken/Fus.) riding beside a TEAM +// name+number block, on the INNER side (toward the centre "vs"/score, the +// side OPPOSITE the competitor number chip) -- the same placement +// admin_scoring_shared.jsx's WithdrawalMarkedName already gives the team +// editor's header names, replicated here (not imported: that module is an +// ADMIN editor with its own network/state machine, and this is a PUBLIC +// shared component reaching only for the small leaf pieces -- NumberedName, +// numberFollowsName -- it already imports). `nameEl` is the already-built +// NumberedName element to wrap; `mark` is a plain STRING the caller computes +// (bracket.jsx sideMarks + placeMarks); this function only places it, it does +// not decide what the mark says (see team_default_credit.jsx's header for +// why TeamScoreboard itself never learns that rule). +export function teamNameMark(side, mark, nameEl) { + if (!mark) return nameEl; + const markEl = {mark}; + return numberFollowsName(side) ? <>{markEl}{" "}{nameEl} : <>{nameEl}{" "}{markEl}; +} + +export function TeamScoreboard({ subResults, teamResult, lineupA, lineupB, teamSize, showDH, variant, shiroName, akaName, matchSideA, matchSideB, isRunning, kachinuki, squadA, squadB, numberA, numberB, decision, decisionBy, status, shiroMark, akaMark }) { // Real numbered bouts only: exclude the daihyosen sentinel and any malformed // negative position (mirrors the Go-side defensive skip). const regular = (subResults || []).filter(s => s.position > DAIHYOSEN_POSITION); - const { ivShiro, ivAka, pwShiro, pwAka } = teamIVPWFrom(teamResult, subResults, matchSideA, matchSideB); + // bc-tmfn: the match-level default-win credit context. decision/decisionBy/ + // status are new OPTIONAL props (every existing caller/test that omits them + // keeps behaving exactly as before: teamDefaultWinCreditActive(undefined) is + // false, so creditedBoutSide never fires and effectiveSub below is a no-op + // pass-through). See team_default_credit.jsx for the whole rule. + const matchCtx = { status, decision, decisionBy, kachinuki }; + const { ivShiro, ivAka, pwShiro, pwAka } = teamIVPWFrom(teamResult, subResults, matchSideA, matchSideB, matchCtx); // FIK: a Daihyosen (representative bout) only happens when the team match is // TIED after the regular bouts: equal individual victories AND equal points. // Guard the render on the tie so a stale/invalid position:-1 sub never shows a @@ -722,31 +780,46 @@ export function TeamScoreboard({ subResults, teamResult, lineupA, lineupB, teamS const renderDH = !!showDH && tied; const dhSub = renderDH ? (subResults || []).find(s => s.position === DAIHYOSEN_POSITION) : null; const tv = variant === "tv"; + // effectiveSub: the bout centreMarks/isScored actually render for numbered + // position i+1 -- the REAL recorded row when one exists (or already has a + // result), or else a SYNTHESISED default-win row when the active ruling + // credits this position (bc-tmfn). The synthesised row is shaped exactly + // like a real per-bout fusensho row naming the TEAMS (the pre-existing + // quick-score synth shape subWinnerSides already resolves via its + // matchSideA/matchSideB fallback tier), so centreMarks/resultCells render + // its ○○ default-win maru through the SAME machinery a real one already + // gets -- no second rendering path to keep in sync. + const effectiveSub = (i) => { + const real = regular[i]; + const creditSide = creditedBoutSide(real, matchCtx); + if (!creditSide) return real || {}; + return { + position: i + 1, decision: "fusensho", + winner: creditSide === "a" ? matchSideA : matchSideB, + ipponsA: [], ipponsB: [], + }; + }; // The current bout = first unscored regular bout (navy "now" highlight via // var(--accent-soft): the running signal), but only while the match is // RUNNING (see rowState below). Already-scored bouts are "done"; unscored // bouts are "queued". On a non-running board (completed or up-next) nothing // is "now": a completed match that left padded/unplayed positions unscored // (e.g. a quick-score synthesising fewer subResults than teamSize) keeps - // those rows "queued", not "done". - const isScored = (s) => { - const a = ipponLetters(s.ipponsA).filter(Boolean).length; - const b = ipponLetters(s.ipponsB).filter(Boolean).length; - // A bout counts as scored once it has any recorded outcome: ippon letters, - // a hansoku, a hantei, an explicit winner or decision (quick-score and - // forfeit-style outcomes set winner/decision without ippon letters), or a - // hikiwake draw. - return a > 0 || b > 0 || s.hansokuA || s.hansokuB || s.decidedByHantei || - !!s.winner || (typeof s.decision === "string" && s.decision !== "") || - (typeof window.isHikiwake === "function" && (window.isHikiwake(s.score?.type) || window.isHikiwake(s.decision))); - }; + // those rows "queued", not "done". A CREDITED row also reads "done": it is + // built from teamDefaultWinCreditActive, which requires status === + // "completed", so it can only ever coincide with isRunning being false. + const isScored = (s) => subBoutHasResult(s) || + // Legacy/client-only branch subBoutHasResult does not cover: a bare + // score.type === "hikiwake" with no decision string set (a quick-score + // synth shape). Preserved so this delegation loses no existing coverage. + (typeof window.isHikiwake === "function" && window.isHikiwake(s.score?.type)); // Kachinuki: row count = recorded bouts only (no teamSize padding). // Show at least 1 row so the bootstrap senpo-vs-senpo bout is always visible. // Fixed-order: render one row per lineup position (teamSize), padding past // recorded subResults so a running encounter shows all bouts: completed, // the current one, and still-to-come positions. const rowCount = kachinuki ? Math.max(regular.length, 1) : Math.max(regular.length, teamSize || 0); - const scoredAt = (i) => i < regular.length && isScored(regular[i]); + const scoredAt = (i) => isScored(effectiveSub(i)); // Per-row state: a scored bout is "done"; the first unscored bout is "now" // ONLY when the match is RUNNING (so a 0–0 running board highlights bout 1); // every other unscored bout is "queued". Gating "now" on isRunning means a @@ -777,9 +850,15 @@ export function TeamScoreboard({ subResults, teamResult, lineupA, lineupB, teamS {/* The summary cell ellipsises, so it takes the chip as its own flex child rather than a number baked into the string: at 402px the cell is 85px wide and "Seishinkan Ember T7" (126px) lost its T7 outright, - while Shiro's leading number survived (measured, bc-rvfx). */} + while Shiro's leading number survived (measured, bc-rvfx). + shiroMark/akaMark (bc-tmfn) are the match-level Kiken/Fus. mark + for a team a default-win decision withdrew/barred -- STRINGS the + caller computes (bracket.jsx sideMarks + placeMarks, the SAME + rule MatchCard uses), never derived in here: see + team_default_credit.jsx's header for why this component does not + learn the mark rule itself. */} - + {teamNameMark("shiro", shiroMark, )} @@ -793,7 +872,7 @@ export function TeamScoreboard({ subResults, teamResult, lineupA, lineupB, teamS - + {teamNameMark("aka", akaMark, )}
)} @@ -802,9 +881,13 @@ export function TeamScoreboard({ subResults, teamResult, lineupA, lineupB, teamS subResults so a running encounter shows the still-to-come bouts too: not just the scored ones (a partially-scored match used to render only its scored rows). A padding row has no sub: BoutSubRow shows the pinned - lineup name when present, else the bout number (mp-13y #4/#6). */} + lineup name when present, else the bout number (mp-13y #4/#6). A + CREDITED row (bc-tmfn) substitutes the synthesised default-win sub + from effectiveSub, so it renders exactly like a real per-bout + fusensho row: done state, ○○ on the credited side, plain "vs" + centre. */} {Array.from({ length: rowCount }, (_, i) => ( - ))} diff --git a/web-mobile/js/patch.jsx b/web-mobile/js/patch.jsx index dbda533ff..99f91405c 100644 --- a/web-mobile/js/patch.jsx +++ b/web-mobile/js/patch.jsx @@ -39,6 +39,15 @@ // than fall through to a wrong-shape merge. import { mergeMatchPatch as _mergeMatchPatch } from './data.jsx'; import { normalizeMatch, buildPlayerMap } from './api_serializers.jsx'; +import { isBarredMatch } from './ineligible_match.jsx'; + +// bc-cse: _mergeMatchPatchKeepIneligible, which used to live here, is gone. +// It defended `ineligibleSides` against an explicit `null` in the patch, but +// the field is a Go pointer with `omitempty` (internal/state/models.go), so +// an absent stamp is OMITTED from the wire, never sent as `null` or `{}` -- +// that shape cannot occur. An omitted key already survives the ordinary +// `{...existing, ...patch}` spread `_mergeMatchPatch` performs unchanged, so +// the wrapper was defending against a payload the server can never send. // statusSortOrder mirrors annotateBracketQueuePositions in // internal/mobileapp/handlers_match.go and the per-court sort in @@ -115,7 +124,10 @@ function recomputeQueuePositions(matches) { for (const bucket of byCourt.values()) { let counter = 0; for (const e of bucket) { - if (e.m.status === "scheduled") { + // A barred match (ineligible_match.jsx) cannot be fought as + // scheduled: it holds position 0 and is skipped by the counter, + // so later matches move up to take the slot it would have held. + if (e.m.status === "scheduled" && !isBarredMatch(e.m)) { counter++; newPositions[e.idx] = counter; } @@ -201,7 +213,8 @@ function recomputeBracketQueuePositions(bracket) { let counter = 0; for (const e of bucket) { let pos = 0; - if (e.m.status === "scheduled") { + // Same barred-skip rule as recomputeQueuePositions above. + if (e.m.status === "scheduled" && !isBarredMatch(e.m)) { counter++; pos = counter; } @@ -406,6 +419,13 @@ function applyPatch(prev, event) { if (prevStatus === "scheduled" && nextStatus === "scheduled") { if ((prevMatch.court || "") !== (nextMatch.court || "")) return true; if ((prevMatch.scheduledAt || "") !== (nextMatch.scheduledAt || "")) return true; + // A patch that DOES carry ineligibleSides (the contract only says + // one "may" arrive without it) can flip whether this still- + // scheduled match is barred. recomputeQueuePositions gives a + // barred match position 0 and skips it in the per-court count, so + // that flip changes queue membership exactly like leaving/entering + // `scheduled` does: siblings on either side must re-rank. + if (isBarredMatch(prevMatch) !== isBarredMatch(nextMatch)) return true; } return false; }; diff --git a/web-mobile/js/pool_ids.jsx b/web-mobile/js/pool_ids.jsx index d6a2adc82..23c804dd3 100644 --- a/web-mobile/js/pool_ids.jsx +++ b/web-mobile/js/pool_ids.jsx @@ -1,9 +1,11 @@ -// pool_ids.jsx: canonical parser for pool-match ids. LEAF module with NO -// imports, so both display_helpers.jsx and admin_pools.jsx can import it -// without pulling a transitive import chain into either: admin_pools.jsx -// otherwise relies on window globals rather than ESM imports, so a leaf with -// no dependencies keeps its module graph trivial. Single source of truth for -// the pool-id parse rule used across the display and admin surfaces. +// pool_ids.jsx: canonical parser for pool-match ids. A leaf that imports only +// write_result.jsx (itself a leaf with no imports, for scoreRowMatchLabel's +// matchLabel below), so both display_helpers.jsx and admin_pools.jsx can +// import it without pulling a real transitive import chain into either: +// admin_pools.jsx otherwise relies on window globals rather than ESM +// imports, so a leaf this close to dependency-free keeps its module graph +// trivial. Single source of truth for the pool-id parse rule used across the +// display and admin surfaces. // // Backend id formats: "PoolName-N", "PoolName-DH-N" (daihyosen), "PoolName-TB-N" // (tiebreaker). The non-greedy capture leaves hyphenated pool names intact @@ -15,10 +17,17 @@ // truthy poolNameOf() result alone (see findNextPoolOnCourt's "mixed" gate). // This regex constant is intentionally module-private: callers use the // exported poolNameOf() wrapper below, never the raw pattern. +import { matchLabel } from './write_result.jsx'; + // DAIHYOSEN_POSITION is the sentinel `position` value marking a sub-bout as // the daihyosen (representative bout) rather than a numbered roster bout -// (real bouts use a non-negative position: fixed-format 0-based, kachinuki -// 1-based). Negative so it never collides with a real bout index. +// (real bouts are numbered from 1 in BOTH formats: the team editor sends +// idx+1, quick-score counts up from 1, and kachinuki appends +// LastBout.Position+1). Negative so it never collides with a real bout index. +// An earlier revision of this comment claimed fixed-format was 0-based; it +// is not, and no writer has ever emitted a 0 (the Excel export places a bout +// at subStartExcelRow+(Position-1), so a real 0 would land one row above the +// sub-match grid and the first bout would go missing from the sheet). // Mirrors state.DaihyosenSubPosition on the Go side. Use this instead of -1. export const DAIHYOSEN_POSITION = -1; @@ -62,6 +71,52 @@ export function poolMatchNumberOf(id) { return n === undefined ? 0 : Number(n) + 1; } +// scoreRowMatchLabel: how the scores list names a match to the operator +// (bc-cse: relocated from admin_schedule_score_editor.jsx, its original +// home and still its re-exporter, so this leaf can also serve callers like +// admin_scoring_shared.jsx that cannot import that file without a cycle -- +// admin_schedule_score_editor.jsx -> admin_schedule_lineup.jsx -> +// admin_scoring_shared.jsx already exists). +// +// EVERY row carries an identity, because this list is where an operator lands +// after a dialog names a match ("Match 15 was reopened"), and a row identified +// only by its time and its two competitors cannot be found that way. +// +// The two phases number independently, so the label says which numbering it +// is quoting. A knockout match owns a number across the whole tree, the same +// one the printed tree and the correction dialogs use, so it reads bare: +// "Match 15". A pool bout is numbered inside its own pool and the numbering +// restarts per pool (operator ruling 2026-09-19), so its pool is named with +// it: "Pool A · Match 2". Without that prefix the two would collide, since +// every pool has a Match 1 and so does the bracket. +// +// The pool's own name comes from window.poolLabel (viewer_utils.jsx), the one +// owner of the pool-vs-league-vs-Swiss heading, so a Swiss round reads +// "Round 3 · Match 2" rather than the synthetic "Swiss-R3" id. +// +// A knockout row is named by matchLabel (write_result.jsx), the same owner the +// correction dialogs use, so the row an operator is sent to looking for +// "Match 15" -- or for "the 3rd-place match", the one match named rather than +// numbered -- carries the words they were given. A dialog adds the round the +// server names it with ("Match 15 (Semifinals)"); the row keeps the number, +// which is what the dialog's label leads with. +// +// Returns "" when the match carries no number at all: a bracket match drawn +// before numbering existed, and a pool supplementary bout (daihyosen or +// tiebreaker), which is an appended rep bout rather than one of the pool's +// numbered round-robin bouts. +export function scoreRowMatchLabel(m) { + if (m.phase === "bracket") { + const label = matchLabel({ number: m.matchNumber, id: m.id }); + // matchLabel falls back to the raw id, which names nothing on screen. + return label === m.id ? "" : label; + } + const n = poolMatchNumberOf(m.id || ""); + if (!n) return ""; + const pool = (window.poolLabel ? window.poolLabel(m) : m.poolName) || ""; + return pool ? `${pool} · Match ${n}` : `Match ${n}`; +} + // This regex constant is intentionally module-private: callers use the // exported isSupplementaryBout() wrapper below, never the raw pattern. const SUPPLEMENTARY_BOUT_RE = /-(?:DH|TB)-\d+$/; diff --git a/web-mobile/js/team_default_credit.jsx b/web-mobile/js/team_default_credit.jsx new file mode 100644 index 000000000..b134a61a4 --- /dev/null +++ b/web-mobile/js/team_default_credit.jsx @@ -0,0 +1,171 @@ +// team_default_credit.jsx: THE ONE JS OWNER of the "every team bout needs a +// result" default-win credit rule (bc-tmfn operator ruling). +// +// THE RULE: a team match (fixed-format, never kachinuki) that is COMPLETED +// with a match-level decision in the default-win class -- any kiken variant, +// fusenpai, or fusensho -- credits every NUMBERED bout (position >= 1; the +// daihyosen row is position -1, see DAIHYOSEN_POSITION in pool_ids.jsx) that +// carries no result of its own as a default win for the winning team: IV +1 +// and PW +2 for the winner, nothing subtracted from the other side (an IL/PL +// tally, if a caller needs one, is a server-side standings concern -- +// engine.accrueTeamSubResults and friends -- not something this DISPLAY-only +// leaf computes). decisionBy names the side that withdrew or was barred +// ("aka" = sideA, "shiro" = sideB, the same convention +// engine.RecordDecisionTx uses to decide Winner/WinnerSide), so the OTHER +// side is credited. decisionBy is preserved on every PUBLIC read path +// (internal/mobileapp/public_projection.go: "decisionBy, an enum, is what +// drives viewer rendering and is deliberately preserved"), unlike the free- +// text DecisionReason/CorrectionReason audit fields, so every consumer below +// may rely on it being present wherever a match reaches the wire at all. +// +// Kachinuki is EXCLUDED on purpose: it has no fixed teamSize of positions to +// pad out to (bouts are appended one at a time, "winner stays on", and the +// encounter ends on an explicit operator action -- End match -- never on a +// match-level default-win decision standing in for unplayed slots). Callers +// signal this via a `kachinuki` boolean in the match-context object every +// exported function below takes; most already carry a `teamMatchType` field +// (viewer_utils.jsx's compMatches / compMatchesForCompetition, pool_ids.jsx's +// teamMatchTypeFor) a caller resolves before calling in. That field is a +// VIEWER-LIST enrichment, not guaranteed on every match shape a generic +// shared function like bracket.jsx's teamIVScore/teamIVPWScore may be handed +// (e.g. a raw normalizeMatch object outside those enriched lists), so a +// caller that cannot resolve it should pass `kachinuki: false` -- crediting a +// kachinuki match's client-side IV/PW FALLBACK undercounts nothing real, +// because the server's own `teamResult` (state.TeamResultFrom, changed +// concurrently in internal/state/team_result.go) is authoritative whenever +// present and this leaf's arithmetic is consulted only in ITS absence. +// +// SCOPE: this leaf answers "which side does a default-win ruling credit" and +// "what does that mean for one bout's outcome / for an IV-PW aggregate". It +// does NOT decide what MARK (Kiken/Fus.) a surface shows: that is bracket.jsx +// `sideMarks`' job (mirrored from internal/export/suffix.go SideMarks), kept +// there because CLAUDE.md documents sideMarks/SideMarks as one mirrored pair +// with one owner, and duplicating its "Kiken"/"Fus." label logic here would +// split that pair into three. A caller placing a mark beside a withdrawn +// team's name computes it the SAME way MatchCard already does (sideMarks + +// placeMarks, keyed off sameCompetitor(match.winner, match.sideA/sideB)), +// not through this leaf. +// +// Imports realIppons/hanteiDecided/attributeWinnerSide from result_slot.jsx, +// withdrawnSideKey from ineligible_match.jsx, and idOf/nameOf from +// competitor_identity.jsx -- all three leaves with no non-leaf imports of +// their own -- so this file stays leaf-or-nothing per the dependency-hygiene +// rule stated in result_slot.jsx's own header. + +import { realIppons, hanteiDecided, attributeWinnerSide } from './result_slot.jsx'; +import { withdrawnSideKey } from './ineligible_match.jsx'; +import { idOf, nameOf } from './competitor_identity.jsx'; + +// isTeamDefaultWinDecision: the default-win decision class -- every kiken +// variant, fusenpai, or fusensho -- whose awarded points record as maru. +// THE one JS owner of this class (bc-cse): bracket.jsx's isDefaultWinBC and +// admin_scoring_shared.jsx's withdrawalInForce both used to hand-roll their +// own copy and now delegate here instead. Mirrors domain.IsDefaultWinDecisionStr +// (Go), kept as its own small copy against THAT language rather than an +// import: a one-line class predicate is cheaper to duplicate across languages +// than to wire a cross-language dependency for. +export function isTeamDefaultWinDecision(decision) { + return decision === "kiken" || decision === "kiken-voluntary" || decision === "kiken-injury" || + decision === "fusenpai" || decision === "fusensho"; +} + +// creditedSideKey: the side ("a" = Aka/sideA, "b" = Shiro/sideB) a match- +// level default-win ruling credits -- the COMPLEMENT of ineligible_match.jsx's +// withdrawnSideKey (bc-cse), the one owner of the aka/shiro <-> a/b mapping: +// decisionBy "shiro" -> sideA/Aka wins, decisionBy "aka" -> sideB/Shiro wins +// (mirroring engine.RecordDecisionTx's decisionBy -> Winner mapping: "decisionBy +// names the WITHDRAWING side"). With no decisionBy (a legacy row) it falls +// back to the recorded winner's side through attributeWinnerSide +// (result_slot.jsx), as Go's state.DefaultWinCreditSide falls back to +// domain.AttributeWinnerSide, so this estimate and the server's teamResult +// credit the same side. withdrawnSideKey's own name fallback is not a +// substitute: it gives up where one side carries an id and the other does +// not, which attributeWinnerSide still settles by name. +// +// Takes the match (`decisionBy`, and `winner`/`sideA`/`sideB` as `{id, name}` +// objects or bare names); a context without the winner fields gets no +// fallback. Returns "" when no side can be named: nothing to credit. +const SIDE_COMPLEMENT = { a: "b", b: "a" }; +export function creditedSideKey(m) { + const ctx = m || {}; + const byDecision = SIDE_COMPLEMENT[withdrawnSideKey({ decisionBy: ctx.decisionBy })] || ""; + if (byDecision) return byDecision; + return attributeWinnerSide({ + winnerId: idOf(ctx.winner), + sideAId: idOf(ctx.sideA), + sideBId: idOf(ctx.sideB), + winner: nameOf(ctx.winner), + sideA: nameOf(ctx.sideA), + sideB: nameOf(ctx.sideB), + }) || ""; +} + +// teamDefaultWinCreditActive(matchCtx): is match-level default-win crediting +// IN FORCE right now? matchCtx = { status, decision, decisionBy, kachinuki }. +// A COMPLETED, non-kachinuki match whose decision is in the default-win +// class and whose decisionBy names a side. Callers already know their match +// is a TEAM match before asking this (this leaf has no way to tell an +// individual match's kiken from a team one -- see the file header), so every +// call site below gates on team-shaped data first (a subResults array, or a +// component that only ever mounts for a team match). +export function teamDefaultWinCreditActive(matchCtx) { + const { status, decision, kachinuki } = matchCtx || {}; + return status === "completed" && !kachinuki && + isTeamDefaultWinDecision(decision) && !!creditedSideKey(matchCtx); +} + +// subBoutHasResult: the wire-level twin of Go's SubMatchResult.HasResult +// (internal/state/models.go) -- a winner, a decision (a tie is "hikiwake", a +// default win "fusensho"), a struck point or the hantei mark on either side, +// a foul, or an overtime. A row with none of those was never fought. Reuses +// realIppons (drops the "•" placeholder AND the "Ht" mark, i.e. counts only a +// real struck point, matching Go's CountScoringIppons) and hanteiDecided +// (does either side carry the "Ht" mark) from result_slot.jsx rather than +// re-spelling either filter, per that file's "one definition" rule for what +// counts as a recorded ippon. +// +// This is the WIRE shape (ipponsA/ipponsB/winner/decision/hansokuA/hansokuB/ +// encho), the same shape TeamScoreboard's isScored (match_scoreboard.jsx) +// reads -- NOT the team editor's local row-EDITING state (aPts/bPts/fusensho/ +// draw), which is a different shape entirely and already has its own has-a- +// result check, admin_scoring_team.jsx's subBoutHasBeenPlayed. Do not feed +// one shape to the other's checker. +export function subBoutHasResult(sub) { + if (!sub) return false; + return !!sub.winner || !!sub.decision || + realIppons(sub.ipponsA).length > 0 || realIppons(sub.ipponsB).length > 0 || + hanteiDecided(sub) || + (sub.hansokuA || 0) > 0 || (sub.hansokuB || 0) > 0 || + !!(sub.encho && (sub.encho.periodCount || 0) > 0); +} + +// creditedBoutSide(sub, matchCtx): for ONE numbered bout row (position >= 1; +// pass a stub {position} — or nothing at all — for a row the wire has not +// sent, e.g. a padding position beyond what subResults currently carries), +// which side the active ruling credits it to ("a"/"b"), or "" when this bout +// is NOT credited: the ruling is not in force, or the bout already carries +// its own result. One-stop call for a per-row renderer (TeamScoreboard's +// effective-bout synthesis) or a per-bout aggregate loop (bracket.jsx's +// teamIVScore fallback). +export function creditedBoutSide(sub, matchCtx) { + if (!teamDefaultWinCreditActive(matchCtx)) return ""; + if (subBoutHasResult(sub)) return ""; + return creditedSideKey(matchCtx); +} + +// creditedTotals(missingCount, side): the IV/PW a default-win ruling adds on +// top of the bouts actually fought -- IV +1 and PW +2 per credited bout, +// awarded entirely to `side` ("a"/"b"); the other side gets nothing. +// `missingCount` is the CALLER's own count of numbered bouts with no result: +// every caller's bout shape differs (the wire SubMatchResult array vs the +// team editor's local row-editing state), so this takes a plain count rather +// than an array, and each caller derives it with the has-a-result check +// appropriate to ITS shape (subBoutHasResult above for wire data, +// admin_scoring_team.jsx's own subBoutHasBeenPlayed + unfinishedTeamBouts for +// the live modal). Returns all-zero for a falsy side or a zero count, so a +// caller need not gate the call itself. +export function creditedTotals(missingCount, side) { + if (!side || !missingCount) return { ivA: 0, ivB: 0, pwA: 0, pwB: 0 }; + const iv = missingCount, pw = missingCount * 2; + return side === "a" ? { ivA: iv, ivB: 0, pwA: pw, pwB: 0 } : { ivA: 0, ivB: iv, pwA: 0, pwB: pw }; +} diff --git a/web-mobile/js/viewer_alerts.jsx b/web-mobile/js/viewer_alerts.jsx index ce1dadb4b..cbe1839f1 100644 --- a/web-mobile/js/viewer_alerts.jsx +++ b/web-mobile/js/viewer_alerts.jsx @@ -15,6 +15,7 @@ // viewer_notifications.jsx loads after viewer_alerts.jsx, so the import resolves. import { LS_NOTIFICATIONS_ENABLED } from './notification_keys.jsx'; +import { isBarredMatch } from './ineligible_match.jsx'; const { useState, useRef: useRefV, useEffect } = React; @@ -170,6 +171,11 @@ function matchSideName(side, fallbackName) { // Exported for unit testing and mp-5px (service worker path must reuse this). export function isFollowedMatchOnDeck(m) { if (!m) return false; + // A barred match (ineligible_match.jsx) cannot be fought as scheduled, so + // it is never on deck whatever its queuePosition reads: defense-in-depth + // alongside the queue-position recompute (patch.jsx), which is what keeps + // queuePosition itself at 0 for one. + if (isBarredMatch(m)) return false; if (m.status === "running") return true; if (m.status === "scheduled" && Number(m.queuePosition) === 1) return true; return false; diff --git a/web-mobile/js/viewer_competition.jsx b/web-mobile/js/viewer_competition.jsx index e3ded643e..f69ab87f6 100644 --- a/web-mobile/js/viewer_competition.jsx +++ b/web-mobile/js/viewer_competition.jsx @@ -10,6 +10,7 @@ import { usePrimaryWatch } from './viewer_schedule.jsx'; import { poolNameOf, isPoolDaihyosenBout, teamMatchTypeFor } from './pool_ids.jsx'; import { NumberedName } from './numbered_name.jsx'; import { resultRecencyDesc } from './result_recency.jsx'; +import { isBarredMatch } from './ineligible_match.jsx'; const { useState, useMemo, useRef: useRefV } = React; const StatusBadge = window.StatusBadge; @@ -115,7 +116,10 @@ export function ViewerCompetition({ tournament, competition, pools, poolMatches, return matchInvolvesWatchedSet(m, highlightPlayers); }; const running = allMatches.filter((m) => m.status === "running" && hasBothSides(m) && matchInvolvesWatched(m)); - const upcoming = allMatches.filter((m) => m.status === "scheduled" && hasBothSides(m) && matchInvolvesWatched(m)) + // A barred match (ineligible_match.jsx) cannot be fought as scheduled, so + // it is skipped here: currentMatch and the "Up next" section both derive + // from this filtered list, so neither ever picks one as the next match. + const upcoming = allMatches.filter((m) => m.status === "scheduled" && hasBothSides(m) && !isBarredMatch(m) && matchInvolvesWatched(m)) .sort((a, b) => (a.scheduledAt || "99:99").localeCompare(b.scheduledAt || "99:99")) .slice(0, hasActiveFilter ? 20 : 3); // Reverse-chronological by scheduled time. allMatches arrives in @@ -127,11 +131,9 @@ export function ViewerCompetition({ tournament, competition, pools, poolMatches, // matches, which have one real competitor and one absent side, so they // arrived here as finished results reading "TBD vs " with a Final // badge and no indication that nobody was ever scheduled. A bye is bracket - // structure, not a result. It stays discoverable in the Bracket tab, where - // the entrant renders as an unopposed slot tagged BYE feeding the next - // round (that is bc-bye-slot__tag in BracketTreeMeta; not the MatchCard's - // bc-bye-tag, which is gated on score.type === "bye" and unreachable from a - // server payload). Use + // structure, not a result. The Bracket tab shows it the way the printed + // sheets do: the entrant appears only in the card of the match they first + // fight, with no card of its own (BracketTreeMeta, bc-tmfn). Use // hasBothSides, never `m.sideA && m.sideB`: normalizeMatch substitutes a // truthy {id:"",name:""} for a missing side. // diff --git a/web-mobile/js/viewer_home.jsx b/web-mobile/js/viewer_home.jsx index aafa22c6d..7f8c6d820 100644 --- a/web-mobile/js/viewer_home.jsx +++ b/web-mobile/js/viewer_home.jsx @@ -8,6 +8,7 @@ import { notificationSupported } from './viewer_notifications.jsx'; import { VSchedItem, MatchViewerModal } from './viewer_match.jsx'; import { buildWatchlistUpcoming, usePrimaryWatch, WATCHED_UPCOMING_LIST_MAX } from './viewer_schedule.jsx'; import { mirrorWatchlistParam } from './watchlist_link.jsx'; +import { isBarredMatch } from './ineligible_match.jsx'; const { useState, useMemo, useRef: useRefV, useEffect } = React; const StatusBadge = window.StatusBadge; @@ -246,7 +247,9 @@ export function ViewerHome({ tournament, onSelectCompetition, onAdminClick, onOp // the upcoming list / cards / detail view all reject placeholder // sides. Mirrors the upNext filter below. const running = allMatches.filter((m) => m.status === "running" && hasBothSides(m) && runningCompIds.has(m.compId) && (courtFilter === "all" || m.court === courtFilter)); - let upNext = allMatches.filter((m) => m.status === "scheduled" && hasBothSides(m) && runningCompIds.has(m.compId) && (courtFilter === "all" || m.court === courtFilter)); + // A barred match (ineligible_match.jsx) cannot be fought as scheduled, so + // it is excluded from Up next: mirrors ViewerCompetition's upcomingMatches. + let upNext = allMatches.filter((m) => m.status === "scheduled" && hasBothSides(m) && !isBarredMatch(m) && runningCompIds.has(m.compId) && (courtFilter === "all" || m.court === courtFilter)); if (courtFilter === "all") upNext = upNext.slice(0, 3); // mp-xhaa: resolve the watchlist to a flat player set (dojo entries expand to diff --git a/web-mobile/js/viewer_match.jsx b/web-mobile/js/viewer_match.jsx index 18583e0c0..4ed750206 100644 --- a/web-mobile/js/viewer_match.jsx +++ b/web-mobile/js/viewer_match.jsx @@ -14,11 +14,12 @@ import { writeDidNotLand } from './write_result.jsx'; import { SideLabel } from './side_cell.jsx'; -import { useTeamLineups, TeamScoreboard, IndividualScore, numberedParts } from './match_scoreboard.jsx'; +import { useTeamLineups, TeamScoreboard, IndividualScore, numberedParts, teamNameMark } from './match_scoreboard.jsx'; import { NumberedName } from './numbered_name.jsx'; import { TermV, poolLabel } from './viewer_utils.jsx'; import { DAIHYOSEN_POSITION } from './pool_ids.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; +import { barredNameMark } from './barred_chip.jsx'; const { useState, useRef: useRefV, useCallback } = React; @@ -119,6 +120,17 @@ export function MatchDetailCard({ match, onClose, escapeToClose = true, slotLabe // TeamScoreboard additionally gates it on the match actually being tied. const showDH = isTeam && (match.subResults || []).some(s => s.position === DAIHYOSEN_POSITION); + // bc-tmfn: the match-level Kiken/Fus. mark for a TEAM match a default-win + // decision closed, beside the withdrawn team's name in the §277 summary + // row (TeamScoreboard). teamMatchMarks (bracket.jsx) is the ONE owner of + // this composition (sameCompetitor + sideMarks + placeMarks, the same + // pattern MatchCard already applies); reached via window like every other + // bracket.jsx primitive this file uses (see result_slot.jsx's header for + // the dependency reasoning). TeamScoreboard itself never learns this rule: + // it only renders the strings handed to it (team_default_credit.jsx's + // header states why). + const { shiro: teamShiroMark, aka: teamAkaMark } = window.teamMatchMarks ? window.teamMatchMarks(match, isTeam) : { shiro: "", aka: "" }; + return (
@@ -168,7 +180,9 @@ export function MatchDetailCard({ match, onClose, escapeToClose = true, slotLabe matchSideB={match.sideB?.name || (typeof match.sideB === "string" ? match.sideB : "")} squadA={squadA} squadB={squadB} numberA={match.sideA?.number || ""} numberB={match.sideB?.number || ""} - kachinuki={match.teamMatchType === "kachinuki"} /> + kachinuki={match.teamMatchType === "kachinuki"} + decision={match.decision} decisionBy={match.decisionBy} status={match.status} + shiroMark={teamShiroMark} akaMark={teamAkaMark} /> : }
@@ -193,6 +207,14 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig const scoreStr = (m.status === "completed" || isRunning) ? (window.matchScoreStr(m) || null) : null; + // bc-tmfn: a TEAM row's score cell (window.matchScoreStr → teamIVPWScore) + // is deliberately free of marks, so the match-level Kiken/Fus. a default + // win closed a team match with rides beside the withdrawn team's NAME + // instead — the ONE shared computation (window.teamMatchMarks, + // bracket.jsx), which derives its own team-row signal from m.subResults + // (bc-cse) so an individual match's own mark, already inline in its + // scoreStr above, is never doubled here. + const { shiro: teamShiroMark, aka: teamAkaMark } = window.teamMatchMarks ? window.teamMatchMarks(m) : { shiro: "", aka: "" }; // FR-025: queue position is 1-indexed per court for scheduled matches; // running/completed are 0 (set server-side, omitempty in JSON → undefined // on older payloads). Treat null/undefined/0 as "don't render" so the UI @@ -256,8 +278,15 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig an inline-flex box whose TEXT child carries the ellipsis and `min-width: 0`, so the name shrinks inside the flex row and the content never overflows `.n` for its `overflow: hidden` to clip. - Shiro is unaffected either way, its number being first. */} - + Shiro is unaffected either way, its number being first. + + bc-cse: msb-name--labelled turns `.n` itself into a flex row for + the SAME reason, one level out -- the Kiken/Fus./Withdrawn mark + teamNameMark/barredNameMark add is a SIBLING of the NumberedName + above, so without it a long name clipped that mark away (or, on + Aka, pushed its leading mark past the visible width) exactly as + the bare number once did. */} + {barredNameMark(m, "shiro", teamNameMark("shiro", teamShiroMark, ))} {tweaks.showDojo && m.sideB?.dojo ? {m.sideB.dojo} : null}
{/* No score string (pending, or completed with no recorded cells) → @@ -283,7 +312,7 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig )}
- + {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))} {tweaks.showDojo && m.sideA?.dojo ? {m.sideA.dojo} : null}
diff --git a/web-mobile/js/viewer_schedule.jsx b/web-mobile/js/viewer_schedule.jsx index af5de94f5..0aa5fcfd7 100644 --- a/web-mobile/js/viewer_schedule.jsx +++ b/web-mobile/js/viewer_schedule.jsx @@ -3,12 +3,13 @@ import { poolLabel, tournamentMatches, compareDmy } from './viewer_utils.jsx'; import { matchParticipantIds, matchParticipantNames, useWatchlist, resolveEntryPlayerIds, resolveWatchedPlayers, findPrimaryEntry, buildRoster, buildWatchedSets, matchInvolvesWatchedSet } from './viewer_watchlist_core.jsx'; -import { withNumber } from './match_scoreboard.jsx'; +import { withNumber, teamNameMark } from './match_scoreboard.jsx'; import { SideCell } from './side_cell.jsx'; import { MatchViewerModal, localQueueLabelCompact } from './viewer_match.jsx'; import { sameCompetitor, competitorKey } from './competitor_identity.jsx'; import { competitorMatchesQuery, matchMentions } from './competitor_search.jsx'; import { resultRecencyDesc } from './result_recency.jsx'; +import { barredNameMark } from './barred_chip.jsx'; const { useState, useMemo, useRef: useRefV } = React; const EmptyState = window.EmptyState; @@ -325,6 +326,14 @@ export function TWMatch({ m, highlight, onClick }) { // closed-set ruling in CLAUDE.md. const isRunning = m.status === "running"; const scoreStr = (m.status === "completed" || isRunning) ? window.matchScoreStr(m) : null; + // bc-tmfn: a TEAM row's score cell (window.matchScoreStr → teamIVPWScore) + // is deliberately free of marks, so the match-level Kiken/Fus. a default + // win closed a team match with rides beside the withdrawn team's NAME + // instead -- the ONE shared computation (window.teamMatchMarks, + // bracket.jsx), which derives its own team-row signal from m.subResults + // (bc-cse) so an individual match's own mark, already inline in its + // scoreStr above, is never doubled here. + const { shiro: teamShiroMark, aka: teamAkaMark } = window.teamMatchMarks ? window.teamMatchMarks(m) : { shiro: "", aka: "" }; // FR-025: per-court queue position: see VSchedItem for the contract. // Short pill form here because the tw-match row is denser than the // upcoming-list row in the per-competition viewer. Wording is owned @@ -351,11 +360,18 @@ export function TWMatch({ m, highlight, onClick }) { {/* Side by CELL TINT, matching the admin twin in admin_schedule_page.jsx (bc-sccl); the S/A squares are gone and sr-only labels carry the side in text. */} - - {withNumber(m.sideB)} + {/* bc-cse: teamNameMark puts the mark in its own ELEMENT -- + baking the mark into the plain string left it inside the SAME + text run .tw-match__name ellipsises, so a long name clipped it + away exactly like the other three hosts fixed alongside this one + (see viewer_match.jsx's VSchedItem comment). The mark is now a + real sibling element, flex:none via msb-name--labelled, and the + name string is wrapped in msb-name__text so only IT ellipsises. */} + + {barredNameMark(m, "shiro", teamNameMark("shiro", teamShiroMark, {withNumber(m.sideB)}))} - - {withNumber(m.sideA)} + + {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, {withNumber(m.sideA)}))}
{m.compName}
diff --git a/web-mobile/js/viewer_standings.jsx b/web-mobile/js/viewer_standings.jsx index 1ac065b85..e7df0a14d 100644 --- a/web-mobile/js/viewer_standings.jsx +++ b/web-mobile/js/viewer_standings.jsx @@ -6,7 +6,9 @@ // specifiers resolve in the browser. viewer.js is the SOLE viewer entry script in // index.html and imports this module: do NOT give it its own