diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 5a2938d96..db5d93b19 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -18,7 +18,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
- go-version: "1.27.1"
+ go-version: "1.27.2"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml
index d245d41e5..8f9755bef 100644
--- a/.github/workflows/validate.yaml
+++ b/.github/workflows/validate.yaml
@@ -22,7 +22,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
- go-version: "1.27.1"
+ go-version: "1.27.2"
- name: Init project
run: |
@@ -62,7 +62,7 @@ jobs:
# (the Makefile's pin, used by `make go/lint` locally) did not.
# Keep this in lockstep with the `go install` version in
# Makefile's go/lint target so a local green run predicts CI.
- version: v2.13.1
+ version: v2.14.0
# ____ _
# | _ \ ___ ___| | _____ _ __
@@ -111,7 +111,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
- go-version: "1.27.1"
+ go-version: "1.27.2"
cache: true
- name: Init project
@@ -193,7 +193,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
- go-version: "1.27.1"
+ go-version: "1.27.2"
cache: true
- name: Install LibreOffice headless
diff --git a/CLAUDE.md b/CLAUDE.md
index d9a481365..3eb098791 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -169,14 +169,15 @@ tournament-data/
- **Tie-marking Rule**: A match (individual or sub-match) is a tie when the operator enters **'X'** (or 'x') in the "vs" column or when both sides finish with the same total score (equal character-count after stripping spaces/zeros/dashes). For auto-detection, at least one score cell in that row must be non-empty. A team match is automatically a draw (T=1) when both IV and PW totals are equal.
- **Match Colors**: White (Shiro) is always the left column and Red (Aka) is always the right column (this is fixed and not configurable). In the Excel export, `helper.WhiteLeft` (`internal/helper/excel.go`) is the one Go owner of that side-to-column order; no mirror setting exists, and every side-ordered pair (entrant formulas and names, scores, fouls, result marks, header labels and styles, standings side records, a team summary's IV/PW) routes through it rather than restating the order. In pool matches the first-listed player (SideA) is Red; in elimination matches the upper-bracket player (`node.Left`) is Red. **A TINTED surface does not carry a SHIRO/AKA badge as well** (operator ruling 2026-09-20, bc-sccl, which SUPERSEDES bc-dnst's "named once in text, by the header badge"): where a side has a background tint, the tint plus Shiro's 45° hatch carry it, and the badge is redundant. Every side badge in the app went with that ruling: the score editors' header pill (`.sb-side__badge`), the engi card's (`.engi-side__badge`), the scores list and court console's (`.se-color-badge`) and the compact schedule's 14×14 `A`/`S` squares (`.tw-match__badge`). Bout rows and foul counters were already tint-only, so do not re-add per-bout SHIRO/AKA chips or "SHIRO Fouls" prefixes either. A tinted surface still names the side in TEXT for a screen reader — that is the one thing that must never be dropped with a badge (DESIGN.md §4: colour is never the only signal). The list of surfaces that carry no such text is short and closed: the TV board and the bracket card (both below, each by its own ruling), plus the watchlist hero, which names it VISIBLY instead. **`SideCell` (side_cell.jsx) owns this: a surface passes `side` and gets the fill class AND the `sr-only` label together, so the tint cannot arrive without its text; `SideLabel` is the label alone, for a surface that paints its own tint; `sideFillClass` is the class alone, for the watchlist hero only, which names the side visibly and so must not carry the span as well.** Do not hand-type either the class or the span -- that is how `"Shiro: "` and `"Shiro:"` came to coexist. **Use an `sr-only` span**, via that owner. An `aria-label` only works on an element whose role permits naming, and the cells this rule governs are bare `
`s, i.e. role `generic`, where ARIA PROHIBITS it: the court console carried `aria-label={`Shiro: …`}` on four such cells and the side was announced by nothing. An `aria-label` on an interactive wrapper is worse than useless here, because role=button has presentational children, so it REPLACES the side word, the competitor numbers and the dojos inside it — that is how the watchlist hero lost all of them. ONE exception, and it is about SIZE, not colour: the watchlist hero (`.wl-hero__side-lbl`) keeps a visible `Aka`/`Shiro` word (operator decision 2026-09-20). It is a large personal card read at arm's length, not a dense row, so the word costs it nothing -- which is the whole reason the ruling removed the badge from dense rows. Do not copy it onto a row. The exception this REPLACES was `.bc-color-badge`, justified by that card being dark navy; bc-wlhc made the card light and deleted the class, so the old wording named a surface that no longer existed. Controls that PICK a side (the "SHIRO wins"/"AKA wins" buttons, the decision side picker, the team summary band) keep naming it, as does the OBS lower third's heading. The TV board does NOT: its SHIRO/AKA headings went with the same ruling (position plus each name's colour carry it there, and a projector has no screen-reader audience), pinned by `side_is_carried_by_tint.test.jsx`'s "the TV board names no side". Aria-labels and titles may always say the side. The competitor number sits on the OUTER side of the name on every surface, Shiro's before it and Aka's after (operator ruling 2026-09-14, bc-dnst); `NumberedName` (numbered_name.jsx) owns it for JSX and `withNumber` (match_scoreboard.jsx) for strings, so a new surface calls one of those two and never restates the before/after choice. **Which of the two depends on one thing: does the cell CLIP?** A cell with `text-overflow: ellipsis` truncates the END of its run, which on Aka is the number, so the string form silently loses it there while Shiro's leading number survives. A clipping cell therefore passes `clip` to `NumberedName` (or, on the display surfaces, takes `sideLabelParts` from display_helpers.jsx), which makes the chip its own flex child so only the name ellipsises; a wrapping or unconstrained cell keeps the string. Both forms read the same `numberedParts`, so they cannot disagree about a side's name and number. The OBS lower third is MIXED on exactly this basis, not string-only (bc-rvfx): its team lines clip and chip, its individual lines do not clip and keep the string. The TV board WAS mixed on the same basis and is now chip-only (operator decision 2026-09-19, bc-lbty): its `NextPair` rows (the NEXT line and the UP NEXT pool bout list) still do not clip, but they render the non-clip `NumberedName` anyway, because a chipped row directly above a bare-string row read as an oversight rather than a choice. Clipping still decides `clip` vs plain; it no longer decides chip vs string, so do NOT infer from that exception that every non-clipping cell must chip -- the overlay's individual lines are the standing counter-example. **A chip takes the COLOUR of the name it rides beside** (operator decision 2026-09-19, bc-lbty): `.num-prefix` is `color: inherit` with its tint mixed from `currentColor` toward `transparent`, so Shiro's chip is dark and Aka's is red, and the chip never introduces colour the adjacent name does not already carry. The mix must stay transparency-based, never toward a solid light colour: the OBS lower third paints on `rgba(0,0,0,0.85)` with white and pale-pink text, where a white-mixed chip would be invisible. This supersedes the earlier mp-938b neutral-chip note, which the `.num-prefix` comment in styles.css no longer carries and which survives in git history only. Where the two sides STACK vertically instead of sitting left/right (the bracket card, the admin and public schedule rows) there is no outer side: the number sits before the name on both so the numbers align in a column, and the bracket card names no side in text, its leading colour bar and tint carry it (operator ruling 2026-09-14, bc-dnst).
- **Excel Layout**: Uses an **8-column per court** layout. Columns A and G (and their court-shifted counterparts) are 30 units wide; others are 5 units wide. A blank row separates pools vertically.
-- **Team block bout rows** (`state.Competition.TeamBoutRows`, with `domain.KachinukiMaxBouts`): the ONE owner of how many numbered bout rows a team match's block has in any workbook: 0 for an individual competition, TeamSize for a team match, 2 x TeamSize - 1 for kachinuki (the most bouts a winner-stays-on encounter can take). `RenderCompetitionWorkbook` passes it to the renderers (the Pool Matches and Elimination Matches blocks, the 3rd-place block, their IV/PW formula ranges and page heights) and `BuildResultsWorkbook` to the overlays, which skip a bout Position past it rather than write into the next block. **The app's "Download blank template" is NOT `ExportCompetitionXlsx`**: `buildXlsxBody` (admin_schedule_export.jsx) posts the roster to the stateless `POST /create` (cmd), which re-draws it (mp-x0u9), sending `teamMatchType=kachinuki` for a kachinuki competition, `teamMatches` as the team size and `format=league` for a league (one pool and no knockout, which `/create` cannot tell from a pools competition that formed a single pool; any other `format` is a 400); cmd builds a `state.Competition` from them to ask the same owners (`TeamBoutRows`, `IsKnockoutEnabled`). An encounter with more bouts (reserves fielded) keeps them all on the Kachinuki Detail sheet, which lists a match's recorded bouts, or that many empty numbered rows for one with none, in every workbook (`helper.BlankKachinukiSections` for `/create`'s own draw, which leaves that sheet unprotected), so it can be filled in by hand (operator decision 2026-09-27, bc-kdsc). Before the draw, a knockout-only competition's detail sheet lists the skeleton the Elimination Matches sheet printed (the same rounds, through `helper.BlankKachinukiSections`), so the two sheets have one source. `helper.breakBeforeEliminationBlock` is the one page rule for every block on the Elimination Matches sheet, the 3rd-place block included, so a block that fits on one page never prints across two. A block taller than a page (a team of 17 or more, which only the CLI or API allows; the app caps teams at 9) still does. Never restate 2n-1 for a workbook, in Go or JS; the one other use is the score editor's own cap on manually added kachinuki bouts (`kachinukiMaxBouts`, admin_scoring_team.jsx), which is taken from `MAX_TEAM_SIZE`, not from a team's size. `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` reproduces four committed example workbooks byte for byte through `/create`, so a rendering change must regenerate them (`make examples`).
-- **Workbook owners.** `engine.AttachPoolMatches` is the ONE Pool Matches source for both engine exports (the results workbook, and the stored-draw export `ExportCompetitionXlsx` behind `GET /api/competitions/:id/export` and the Export PDFs prints): never rely on a cached `pool.Matches`, since pools.csv records membership only and a restart empties it (the stored-draw export printed no match block after a restart until bc-kdsc). `engine.PrintedBracket` is how those exports name a stored bracket match: its block title (`helper.EliminationMatchTitle`, or `helper.ThirdPlaceLabel` for the bronze) and its sides, a side not decided yet as `helper.MatchRefLabel`'s "M n", never an internal id such as "Winner of r2-m1". The results overlay and the Kachinuki Detail sheet both name through it, so a detail section and the "M n" that points at it read alike. `/create` has no stored bracket; `helper.BlankKachinukiSections` names its draw's matches through the same owners (`helper.EliminationMatchTitle`, `helper.MatchRefLabel`, `helper.ThirdPlaceLabel`, `helper.PoolMatchLabel`).
+- **Team block bout rows** (`state.Competition.TeamBoutRows`, with `domain.KachinukiMaxBouts`): the ONE owner of how many numbered bout rows a team match's block has in any workbook: 0 for an individual competition, TeamSize for a team match, 2 x TeamSize - 1 for kachinuki (the most bouts a winner-stays-on encounter can take). `RenderCompetitionWorkbook` passes it to the renderers (the Pool Matches and Elimination Matches blocks, the 3rd-place block, their IV/PW formula ranges and page heights) and `BuildResultsWorkbook` to the overlays, which skip a bout Position past it rather than write into the next block. **The app's "Download blank template" is NOT `ExportCompetitionXlsx`**: `buildXlsxBody` (admin_schedule_export.jsx) posts the roster to the stateless `POST /create` (cmd), which re-draws it (mp-x0u9), sending `teamMatchType=kachinuki` for a kachinuki competition, `teamMatches` as the team size and `format=league` for a league (one pool and no knockout, which `/create` cannot tell from a pools competition that formed a single pool; any other `format` is a 400); cmd builds a `state.Competition` from them to ask the same owners (`TeamBoutRows`, `IsKnockoutEnabled`). The main sheets' team block skips the representative bout (position -1), so its height and IV/PW ranges stay `TeamBoutRows`' alone; that bout is on the Representative Bouts sheet (see Workbook owners). An encounter with more bouts (reserves fielded) keeps them all on the Kachinuki Detail sheet, which lists a match's recorded bouts, or that many empty numbered rows for one with none, in every workbook (`helper.BlankKachinukiSections` for `/create`'s own draw, which leaves that sheet unprotected), so it can be filled in by hand (operator decision 2026-09-27, bc-kdsc). Before the draw, a knockout-only competition's detail sheet lists the skeleton the Elimination Matches sheet printed (the same rounds, through `helper.BlankKachinukiSections`), so the two sheets have one source. `helper.breakBeforeEliminationBlock` is the one page rule for every block on the Elimination Matches sheet, the 3rd-place block included, so a block that fits on one page never prints across two. A block taller than a page (a team of 17 or more, which only the CLI or API allows; the app caps teams at 9) still does. Never restate 2n-1 for a workbook, in Go or JS; the one other use is the score editor's own cap on manually added kachinuki bouts (`kachinukiMaxBouts`, admin_scoring_team.jsx), which is taken from `MAX_TEAM_SIZE`, not from a team's size. `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` reproduces four committed example workbooks byte for byte through `/create`, so a rendering change must regenerate them (`make examples`).
+- **Workbook owners.** `engine.AttachPoolMatches` is the ONE Pool Matches source for both engine exports (the results workbook, and the stored-draw export `ExportCompetitionXlsx` behind `GET /api/competitions/:id/export` and the Export PDFs prints): never rely on a cached `pool.Matches`, since pools.csv records membership only and a restart empties it (the stored-draw export printed no match block after a restart until bc-kdsc). `engine.PrintedBracket` is how those exports name a stored bracket match: its block title (`helper.EliminationMatchTitle`, or `helper.ThirdPlaceLabel` for the bronze) and its sides, a side not decided yet as `helper.MatchRefLabel`'s "M n", never an internal id such as "Winner of r2-m1". The results overlay, the Kachinuki Detail sheet and the Representative Bouts sheet all name through it, so a detail section and the "M n" that points at it read alike. `/create` has no stored bracket; `helper.BlankKachinukiSections` names its draw's matches through the same owners (`helper.EliminationMatchTitle`, `helper.MatchRefLabel`, `helper.ThirdPlaceLabel`, `helper.PoolMatchLabel`).
+- **Representative Bouts sheet** (`helper.WriteRepresentativeBoutsSheet`, `helper.SheetRepresentativeBouts`): a fixed-order team competition's workbook carries it exactly when a stored match holds a representative bout (a -1 row); kachinuki never has one. `engine.RepresentativeBoutMatches`/`collectRepresentativeBouts` (representative_export.go) walk the draw in the Kachinuki Detail sheet's order (`detailDraw.sections`) and take each bout's score, marks and centre from that sheet's own `boutFigures`, so the two sheets cannot disagree on a bout; the row is labelled `helper.RepresentativeBoutRowLabel` ("DH"). Both engine exports carry it (the stored-draw export and the results workbook); `/create`, the four committed examples and the Export PDFs prints do not (the PDF groups select sheets by name and list neither detail sheet). The row keeps the TEAM names by rule, so a representative is printed only from a member id that resolves in that side's team (`representativeOf`), never from the stored name; otherwise the bout prints its label alone.
- **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. The team sheet keeps the line's space while it is hidden (`.holds-space`, the one owner of `visibility: hidden` for this, operator ruling 2026-10-06), because it sits above every bout row: appearing at a bout's first point (in kachinuki at every bout, since a recorded bout's marks no longer count) it moved every row 28 px down, and a quick second tap on M opened the fighter list. The individual board's line sits below what a scoring tap aims at, so it still comes and goes. 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, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the fusensho for Carol." (kiken-injury: "...Reinstate Alice if they can fight again, or record the fusensho for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. A withdrawal decision against a competitor ANOTHER match already barred is decided by ONE rule, `engine.alreadyBarredRefusal` (bc-kfup), asked by both T105 halves (`checkConcurrentIneligibility` before the write, the K2 check-and-set in `recordIneligibilityFromDecision`): a kiken is refused with `already_ineligible` (a competitor withdraws once), while a fusenpai is their default loss on a remaining match and records NO new `CompetitorStatus`, so the existing one keeps naming the match where they withdrew. Do not re-add a fusenpai refusal there: StartMatchTx refuses to start that match, so the fusenpai is the operator's exit. Clearing the match that DID record the bar moves it onto such a chained fusenpai rather than lifting it (`standingWithdrawalOf`, fusenpai only: a kiken still on record beside a later bar was lifted by a reinstatement before that bar existed, since a second kiken is refused), and a reopen that clears it lands `scheduled` because `reopenTargetStatusTx` judges the bars the restore will leave (`restoredStatus`, shared with the restore). Accepted as it is (operator decision 2026-09-26): a fusenpai recorded before a reinstatement still carries the bar on, so a competitor who withdrew injured, had a no-show recorded on another match, was reinstated, and then has a later withdrawal cleared as a mistake is barred again by that earlier no-show; the editor names the no-show match, where clearing the fusenpai lifts it. Telling the two apart needs the time of each reinstatement, which is not worth storing for this. Do not report it as a new defect. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateEligibility`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateEligibility` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record fusensho for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. The same request-time pass stamps a COMPLETED match a kiken, fusenpai or fusensho decided with `withdrawnStatus` (`state.WithdrawnStatusAnnotation`: the withdrawn side's status now, eligible / the match that bars it / reinstateable), which `RecordedWithdrawal` words its clear from instead of fetching statuses. The viewer payloads load statuses whenever a match needs either stamp (`anyMatchToAnnotate`), and the `/decision`, `/score` and bulk-score writes stamp the result they push (`stampWithdrawnStatus`), so a pushed row and its refetch read the same. **Recording a withdrawal changes only that match** (operator ruling 2026-09-26, "do not cascade"): the post-kiken Remaining matches panel that offered to close the competitor's other matches was removed, and each of them is closed from its own notice when it comes up. Do not re-add a cascade from one match's editor. Recording one still starts the court's next match in the same competition (mp-nwds item 7, the operator's request), on the court console and the Scores tab alike; both skip the withdrawn competitor's matches through `sideBarredByDecision`/`involvesCompetitor` (`ineligible_match.jsx`), because the list shows them barred only after it refreshes.
+- **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the fusensho for Carol." (kiken-injury: "...Reinstate Alice if they can fight again, or record the fusensho for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. A withdrawal decision against a competitor ANOTHER match already barred is decided by ONE rule, `engine.alreadyBarredRefusal` (bc-kfup), asked by both T105 halves (`checkConcurrentIneligibility` before the write, the K2 check-and-set in `recordIneligibilityFromDecision`): a kiken is refused with `already_ineligible` (a competitor withdraws once), while a fusenpai is their default loss on a remaining match and records NO new `CompetitorStatus`, so the existing one keeps naming the match where they withdrew. Do not re-add a fusenpai refusal there: StartMatchTx refuses to start that match, so the fusenpai is the operator's exit. Clearing the match that DID record the bar moves it onto such a chained fusenpai rather than lifting it (`standingWithdrawalOf`, fusenpai only: a kiken still on record beside a later bar was lifted by a reinstatement before that bar existed, since a second kiken is refused), and a reopen that clears it lands `scheduled` because `reopenTargetStatusTx` judges the bars the restore will leave (`restoredStatus`, shared with the restore). Accepted as it is (operator decision 2026-09-26): a fusenpai recorded before a reinstatement still carries the bar on, so a competitor who withdrew injured, had a no-show recorded on another match, was reinstated, and then has a later withdrawal cleared as a mistake is barred again by that earlier no-show; the editor names the no-show match, where clearing the fusenpai lifts it. Telling the two apart needs the time of each reinstatement, which is not worth storing for this. Do not report it as a new defect. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateEligibility`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateEligibility` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record fusensho for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. The same request-time pass stamps a COMPLETED match a kiken, fusenpai or fusensho decided with `withdrawnStatus` (`state.WithdrawnStatusAnnotation`: the withdrawn side's status now, eligible / the match that bars it / reinstateable), which `RecordedWithdrawal` words its clear from instead of fetching statuses. The viewer payloads load statuses whenever a match needs either stamp (`anyMatchToAnnotate`), and the `/decision`, `/score` and bulk-score writes stamp the result they push (`stampWithdrawnStatus`), so a pushed row and its refetch read the same. **Recording a withdrawal changes only that match** (operator ruling 2026-09-26, "do not cascade"): the post-kiken Remaining matches panel that offered to close the competitor's other matches was removed, and each of them is closed from its own notice when it comes up. Do not re-add a cascade from one match's editor. Recording one still starts the court's next match in the same competition (mp-nwds item 7, the operator's request), on the court console and the Scores tab alike, both through `start_match.jsx` (one start at a time; a refused start is shown on the refused match, on the console's Up next or queue row and on the Scores tab's row, with one toast: an error `editMatchScore` already toasted is marked (`markToasted`, write_result.jsx) and the host does not toast it again. The notice is bound to the refused match, not to Up next, by one rule both hosts ask (`startRefusalStands`, start_match.jsx): it stays while that match is `scheduled` on the court, and across an empty list, which says nothing about the match and goes when it leaves `scheduled`, when a non-empty court list no longer holds it, when the operator starts a match, or when an eligibility change in its competition is broadcast; Up next moving on does not drop it. A refusal because another start was still out is kept, in the past tense (`startWasBlockedByStartMessage`), on the refused match once that start has gone out: `landed` on the start's record, set by the console's `startMatch` for a start that landed or was queued offline, never for a pick whose deferral of the running bout failed, nor for a start the server refused: a `clock_skew` answer, or a superseded one that holds the `result` group (`heldGroups` non-empty: a different result is stored and nothing of the start was written). A superseded start with NO `heldGroups` is an echo-hold (a start names only `result`, and the server lists a held group only when it differs from the stored one, `reportHeld`/`HeldEcho`): another device's start of the same match landed first and the match is running, so `classifyStartOutcome` answers it `{ok: true}` like a landed start: no sentence, no toast, a pick pins the panel, and a refusal waiting on it is worded in the past tense. The two refusals answer `{ok: false, msg}`, so both hosts take them as refused starts: the sentence (`START_CLOCK_SKEW_MESSAGE`, or `START_SUPERSEDED_MESSAGE`, composed by `notSavedText` from `SUPERSEDED_REASON` and `SUPERSEDED_ADVICE` under the "Not started" lead, which never invites a retry, start_match.jsx) is stored on the refused match and toasted once, and a pick does not pin the panel on that match; after a pick the court is idle, the running bout already sent back to the queue. The refused match is never started on its own; if the blocking start fails instead, its own refusal stays. On the Scores tab `refuseStart` replaces the single waiting refusal and clears `waitingOn`, so its `finally` has nothing to rewrite, and a superseded start that holds its result group, after a decision, lands on that match with its notice. Accepted: the `result` group's projection also holds `CorrectionReason`/`ResultSource`, so a start over a match running under a different source or reason answers `heldGroups: ["result"]` and shows the refused sentence on a running match; the notice drops once live data shows it running (`startRefusalStands`), the toast stays. The console reads `startPatch`, `winnerSideLR` and `teamMatchMarks` by ES import (`start_match.jsx`, `side_marks.jsx`), and admin_shiaijo.jsx still must not import bracket.jsx); both skip the withdrawn competitor's matches through `sideBarredByDecision`/`involvesCompetitor` (`ineligible_match.jsx`), because the list shows them barred only after it refreshes.
- **Send back to queue keeps the score** (operator ruling 2026-09-26, bc-sbq). `RevertMatchToQueue`/`requeueBracketMatch` clear only the verdict and the audit notes; points, penalties, overtime, bouts, flags and rep-bout fighters stay, the operator removes a wrong mark themselves, and the button is always offered (picking another bout sends a scored one back the same way). Starting the match again keeps them: the SPA's `startPatch` sends the request-only `startOnly` flag and `engine.keepQueuedScore` copies the stored score onto that write, because the wire cannot say "no score sent" (the start's empty arrays are the bytes of an operator clearing every mark, which must still clear, so the editors' own Start stays unflagged; `startOnly` with any status but `running` is a 400, since the stored score is swapped in after validation). The start's representative-bout winner is derived AFTER that swap (`deriveDaihyosenWinner`, scoring_tx.go), so it comes from the bouts the start keeps, never from rows it discards. While queued, every display shows the match as NOT STARTED through `matchShowsScore` (match_shows_score.jsx, a leaf every display ES-imports), which `IndividualScore`/`TeamScoreboard` apply to themselves; the score editors are the one exception. A queued knockout match's kept points do not make it "fought" (`bracketMatchStartedOrDecided` reads status and winner only), so correcting or reopening the match that feeds it re-seats it with its points and asks nothing when it starts. A FINISHED later match that a confirmed correction reopens keeps its points, bouts and flags too, only its verdict goes (`reopenDisplacedBracketMatch` through `reopenBracketMatchKeepingTheFight`, both the knockout and the pool-requalification doors). Engi flags are saved as they are entered: a running engi write is not judged on the flag total (`engiRunningWrite`, scoring_tx.go) and the bracket write carries flags, so a send-back or switch keeps them.
-- **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 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. The server keeps them too: `engine.AddDaihyosen` stores the match's team names on the row when the bout is added, and `seatBracketSide` (bracket_seat.go, the ONE owner of seating a knockout side) keeps the row's `sideA`/`sideB` equal to the match's on every seat, and carries the row's `Winner` to the new name when the side keeps its team and the Winner names the side by its old name, the row's own or the match's (a rename: `nameRepBoutRow` with `another` false and `matchOld` the match's name before the seat, since a row's side name can be blank or stale; the carry also runs when the row already reads the new name; `ReplaceParticipantInDraw` seats a side matched by id through it, `renameBracketSide`, and renames an id-less side's row name without clearing a pick), judging "another team" by id when both sides carry one and by name otherwise; a side given another team loses its representative pick there, dated after the pick it clears. `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`. Whether a tied pair fights on in encho is the operator's call, on ANY bout and in ANY phase: the app records what happens and never decides how a match is run (operator ruling 2026-09-26). A taisho-only rule (bc-kten: the editor withheld Encho from any pair but the two taisho, and the server refused a new encho on such a pair) was built on 2026-09-25 and reversed the next day; do NOT re-add a pairing gate or a server refusal for encho. Whether a tied pairing must produce a result (e.g. the taisho must be defeated) is likewise 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`). A kachinuki encounter never carries a match-level encho (operator ruling 2026-09-24, bc-kheb): one bout fought on in encho does not put the encounter in overtime, so (E) lives on that bout's row only. `state.Competition.ClearKachinukiEncounterEncho(matchID, ...)` is the one owner, and it leaves a pool representative bout or tie-break bout (`Pool X-DH-N`, `Pool X-TB-N`, the `state.IsPoolDaihyosenMatchID`/`IsTiebreakerMatchID` grammar) alone at all three sites below: that match IS one individual bout, whose overtime lives at match level: `applyKachinukiMerge` strips the field on every score write (logged, never refused, since an older client's queued write inherits it), the engine's decision write (`recordDecisionTx`) drops it before the decision's circles are counted, and the load repair `upgradeKachinukiEncounterEnchoLocked` clears what older releases stored, gated on the config.md marker `kachinuki_encounter_encho_cleared`, which `POST /competitions` sets on a competition created as kachinuki (it has nothing to repair) and which a match file the repair cannot read leaves unset, so a later start retries. The team editor sends none (`encounterEnchoCount`) and offers no match-level overtime stepper on a kachinuki encounter. 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 kiken, kiken-injury, fusenpai or fusensho decided; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a kiken, fusenpai or fusensho whose `decisionBy` side is STILL barred by a withdrawal recorded on a DIFFERENT match: `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 (clearing the match that recorded a bar moves that bar onto a chained fusenpai rather than lifting it); once the bar is gone, the competitor reinstated or the match that recorded it cleared, the identical reopen goes 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 ", worded once by `alreadyPlacedNote`, the position named by `lineupPositionLabel` on every surface; a change to a position takes a refusal down) 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. 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. Positions are ordered in ONE way, `domain.ComparePositions` (the FIK names in fighting order, then numbered positions by number, then any other by name), which the roster walk (`OrderedMembers`), the export and the member-id load repair share; a text sort put Chuken before Senpo and "10" before "2".
- **A team carries the lineup of its previous team match** (operator ruling 2026-10-05: "by default, a team carries the previous team match lineup. You can have a different team lineup in every team match"). `engine.LineupInForce` (`internal/engine/lineup_in_force.go`) is the ONE owner: the match's own lineup (read whole, even one that holds no position), else the latest lineup the team had before it in match order (an earlier match's, or its starting lineup, which comes before every match); a match the draw does not hold gets its own lineup or the starting lineup. A lineup saved for a later round is never read (see the migration bullet below). A lineup is matched by the team's participant id only, never by its name; an id-less side has none. Discarding a draw removes the lineups saved for its matches. Match order is the pool-match number, then the knockout by round and position, the 3rd-place match last; `state.TeamMatches` is the one list of a draw's team matches (a bye, a pool daihyosen or tie-break bout, and a league's or Swiss competition's vestigial bracket are none), for the rule and the migration alike. The kachinuki roster, the Kachinuki Detail export and every client surface (through `GET .../lineup-in-force/:matchId` and `resolveMatchLineup`) ask it; no surface derives a round to read a lineup. The Lineups page edits each team's starting lineup (stored as its round-0 entry) and any match's own lineup; **Use the previous match's lineup** removes a match's own lineup so it inherits again. **Copy from previous match** was removed because carrying over is the default; do not re-add it. A match the Lineups page has chosen that the team is no longer in (a correction seated another team) stays chosen, locked, Use the previous match's lineup included, with a notice naming it (`notInMatch`), rather than the page silently editing the starting lineup. The at-court lineup panel follows the match it was opened for: its host keeps the match's key and reads the row every render, so a side given another team gets that team's editor and a match that leaves the data closes the panel.
@@ -200,7 +201,7 @@ The rules below were settled by operator rulings and are pinned by tests. Each h
#### 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, and also the winner of a match-level fusensho; `sideMarks` in bracket.jsx and `domain.SideMarks` (placed on the sheet by `SideMarksLR`, internal/export/suffix.go) agree on this, so a fusensho match reads identically on every surface and in the exported workbook).
+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 match-level fusensho; `sideMarks` (side_marks.jsx, re-exported by bracket.jsx) and `domain.SideMarks` (placed on the sheet by `SideMarksLR`, 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.
@@ -240,7 +241,7 @@ The slot rule has ONE owner, `resultSlot` in web-mobile/js/result_slot.jsx (a sm
Score strings TRAIL loose marks as an unattributable-winner degradation, but the scoreboard has no centre-`Ht` equivalent: an unattributable winner gets no mark rather than one in the shared centre cell. ACCEPTED no-mark classes (all drifted/legacy data; the centre is forbidden by the operator ruling and no side is attributable): (i) same-name-no-id pairs; (ii) rename-drifted winner names (note the old centre fallback DID fire for these whenever the middle was empty, which a no-encho hantei makes normal, so this IS a regression there, accepted); (iii) winner-less legacy `decidedByHantei` rows (previously centre `Ht`); (iv) untied legacy `decidedByHantei` rows written before the tied-scoreline validation existed (letters still convey the winner; the judges'-decision marker is dropped).
-Two related deliberate divergences. Score strings (`sideMarks`) and the Excel export (`SideMarksLR`, over `domain.SideMarks`) still mark `Ht` UNCONDITIONALLY: the mirrored pair stays consistent with itself and gains no tie gate. And a winner naming BOTH sides resolves defensively to AKA everywhere in JS (`subWinnerSides`, aka-first) to match Go's `isWinForSide`/`SideMarksLR` order, so rows, summaries, standings and the export agree on the same arbitrary side. That aka-first rule is scoped to the MATCH level, where a team name is unique by rule and a winner naming both sides is therefore drifted or hand-edited data. It does NOT hold for a SUB-BOUT, where two opposing fighters may legally share a display name and the same order would be a coin flip on ordinary valid data: such a bout is attributed by the row's MEMBER IDS and counts for NEITHER side when no id can decide. A team bout row that names no fighter settles at the match level: `domain.SubBoutAttributionForTeamRow` puts the team names in AFTER that same-name blank, so even a legacy same-named team pair is attributed side A first, as the app and the standings do. **`domain.SubBoutAttribution` owns that rule** and its doc comment is the statement of it; every consumer builds its attribution there, and the JS mirror is `subBoutAttribution` (result_slot.jsx). Do not re-add a name tier that runs when `SideA == SideB`, and do not restate the mechanism here: an earlier copy of it named a function by its unexported name and went stale one commit after that function was exported. Stored sub names stay frozen after a rename (nothing rewrites `SubMatchResult.SideA`/`SideB` on disk or on the wire): every DISPLAY instead resolves the CURRENT name by member id at render time, through `resolveBoutSideDisplayName` (web-mobile/js/lineup_resolver.jsx) and its Go twin `resolveKachinukiDisplayName` (internal/engine/kachinuki_export.go) for the Excel export (bc-dnst), so a rename shows on bouts already fought, on every surface, without touching the stored record. Mirrored helpers: `middleMark`/`sideMarks`/`enchoLabel` (bracket.jsx) ↔ `domain.MiddleMark`/`domain.SideMarks`/`domain.EnchoLabel` (internal/domain/result_marks.go, with `domain.SideMarksAB`, and `domain.IpponsScore` beside IsScoringIppon in ippon.go), moved there (bc-kdsc) so the Kachinuki Detail sheet (internal/helper/excel_kachinuki.go, fed by internal/engine/kachinuki_export.go, which cannot import internal/export without a cycle) reads the SAME closed-set rule rather than restating it. internal/export keeps only what adapts them: `MiddleMark` (from `*state.EnchoMetadata`) and `SideMarksLR` (places the marks White-left). The overtime marker is pinned in both languages via `internal/export/testdata/encho_labels.json` (Go side: `domain.EnchoLabel`).
+Two related deliberate divergences. Score strings (`sideMarks`) and the Excel export (`SideMarksLR`, over `domain.SideMarks`) still mark `Ht` UNCONDITIONALLY: the mirrored pair stays consistent with itself and gains no tie gate. And a winner naming BOTH sides resolves defensively to AKA everywhere in JS (`subWinnerSides`, aka-first) to match Go's `isWinForSide`/`SideMarksLR` order, so rows, summaries, standings and the export agree on the same arbitrary side. That aka-first rule is scoped to the MATCH level, where a team name is unique by rule and a winner naming both sides is therefore drifted or hand-edited data. It does NOT hold for a SUB-BOUT, where two opposing fighters may legally share a display name and the same order would be a coin flip on ordinary valid data: such a bout is attributed by the row's MEMBER IDS and counts for NEITHER side when no id can decide. A team bout row that names no fighter settles at the match level: `domain.SubBoutAttributionForTeamRow` puts the team names in AFTER that same-name blank, so even a legacy same-named team pair is attributed side A first, as the app and the standings do. **`domain.SubBoutAttribution` owns that rule** and its doc comment is the statement of it; every consumer builds its attribution there, and the JS mirror is `subBoutAttribution` (result_slot.jsx). Do not re-add a name tier that runs when `SideA == SideB`, and do not restate the mechanism here: an earlier copy of it named a function by its unexported name and went stale one commit after that function was exported. Stored sub names stay frozen after a rename (nothing rewrites `SubMatchResult.SideA`/`SideB` on disk or on the wire): every DISPLAY instead resolves the CURRENT name by member id at render time, through `resolveBoutSideDisplayName` (web-mobile/js/lineup_resolver.jsx) and its Go twin `resolveKachinukiDisplayName` (internal/engine/kachinuki_export.go) for the Excel export (bc-dnst), so a rename shows on bouts already fought, on every surface, without touching the stored record. Mirrored helpers: `middleMark`/`enchoLabel` (bracket.jsx) and `sideMarks` (side_marks.jsx, the leaf that also holds `placeMarks`, `winnerSideLR` and `teamMatchMarks`; bracket.jsx re-exports them) ↔ `domain.MiddleMark`/`domain.SideMarks`/`domain.EnchoLabel` (internal/domain/result_marks.go, with `domain.SideMarksAB`, and `domain.IpponsScore` beside IsScoringIppon in ippon.go), moved there (bc-kdsc) so the Kachinuki Detail sheet (internal/helper/excel_kachinuki.go, fed by internal/engine/kachinuki_export.go, which cannot import internal/export without a cycle) reads the SAME closed-set rule rather than restating it. internal/export keeps only what adapts them: `MiddleMark` (from `*state.EnchoMetadata`) and `SideMarksLR` (places the marks White-left). The overtime marker is pinned in both languages via `internal/export/testdata/encho_labels.json` (Go side: `domain.EnchoLabel`).
#### Writing `SubResults`
@@ -284,18 +285,18 @@ The engine-side contradiction guard is `stripInvalidHantei` (replaced `preserveM
Operator ruling 2026-10-03: "Nothing should be dropped. All events must be ordered." A match write is merged GROUP BY GROUP onto the stored match, ordered by the stamp of when the change was made (`modifiedAt`, server-relative, never arrival order), and what does not apply is kept in the match's history. Each rule below has one owner; change the owner, never a call site.
-- **The groups** (`internal/state/match_groups.go`, the ONE owner of which fields each holds): `points` (IpponsA/B, HansokuA/B), `result` (Status, Winner/WinnerID, Decision/DecisionBy/DecisionReason, and the CorrectionReason/ResultSource that describe it), `encho`, `flags` (engi), `rep` (pool rep players), and `bout:` per SubResults row (1..n, -1). Sides and ids, court, schedule, round, ReopenPending, IsOverridden and the bracket structure are NOT groups: their own writers keep them. `CopyGroup` moves a group, `GroupDiffers` compares one, `GroupValue` projects one for the history; nothing hand-copies a field list.
+- **The groups** (`internal/state/match_groups.go`, the ONE owner of which fields each holds): `points` (IpponsA/B, HansokuA/B), `result` (Status, Winner/WinnerID, Decision/DecisionBy/DecisionReason, and the CorrectionReason/ResultSource that describe it), `encho`, `flags` (engi), `rep` (pool rep players), `repPickA` and `repPickB` (each side's representative picked for a team match's representative bout, SideAMemberID and SideBMemberID of the -1 row: each dated apart from the other side's and from `bout:-1`, so two captains picking at the same moment both land, a point on that bout never erases a pick and a pick never erases a point; the row's WinnerMemberID stays with the bout and the merge re-derives it when the two disagree, `ReconcileWinnerMemberID`), and `bout:` per SubResults row (1..n, -1). Sides and ids, court, schedule, round, ReopenPending, IsOverridden and the bracket structure are NOT groups: their own writers keep them. `CopyGroup` moves a group, `GroupDiffers` compares one, `GroupValue` projects one for the history; nothing hand-copies a field list.
- **The stamps**: `GroupStamps map[string]int64` on `MatchResult` and `BracketMatch` (pool-matches.csv: the `GroupStamps` column, index 29, JSON, appended after `ModifiedAt`; bracket.json: `groupStamps`). A match with NO map reads every group as stamped at its `ModifiedAt` (legacy files behave exactly as the whole-match guard did); once a map exists, a group with no entry was never written (0). `ModifiedAt` stays the newest group stamp (recency and `keepNewerMatches` read it). A bout whose row is gone keeps its stamp: a TOMBSTONE, so an older write still carrying the row cannot resurrect it. `stampGroups` (`MatchResult.StampGroups`/`BracketMatch.StampGroups`) is the ONE stamper: it materializes a legacy match's implicit stamps before stamping one group, or every other group would start reading the new `ModifiedAt`, and it never LOWERS a group's stamp (a caller that already holds a later one for a group -- an engi finish let through as a HeldEcho under an older stamp -- must not drag it backwards, or a change genuinely made between the two would wrongly read as applying after a group it never actually followed; mirrors `ModifiedAt`'s own never-lower rule); a stamp the MERGE decided, which may be lower (S2's displacement moves a newer invalid recount out and gives the group the finish's own stamp), is written as decided through `ApplyMergedGroupStamps`, never through `stampGroups`. Every writer that changes a group outside the merge stamps it through it: the reopen (`reopenPoolMatch`/`reopenBracketMatch`, server now), the requeue (`RevertMatchToQueue`/`requeueBracketMatch`: the verdict only, the kept score keeps its stamps), override-winner (`result`, and its own comparison is on that group), the engi finish (`result`+`flags`, at the client's stamp once the merge has ordered it, `engiFinishHeld`; server now only for an unstamped writer), the kachinuki advance (the appended `bout:n` + `result`, at the server's time and never older than the match, as `writeStamp` stamps every server-built write; the Record-bout answer echoes the bout log AND the stamps the advance left, `engine.KachinukiAdvance`, and the team editor adopts the stamp with the log, so its next tap on the new bout is floored by it, bc-hlck), `RemoveTrailingKachinukiBout` (the removed bout's tombstone), and a downstream reopen (`reopenDisplacedBracketMatch`, `bout:-1` with it). Byes auto-completed at draw time carry no stamp, which is right.
-- **The wire**: `MatchResult.Changed` (`json:"-"`, request-only, beside `ClearsWithdrawal`) is read from `/score`'s and each bulk entry's `changed`; a name that is not a group is a 400. ABSENT means every group the payload carries (`defaultChangedGroups`; on the pool branch a nil SubResults still names the stored rows, the whole-struct write's old meaning), EXCEPT that a client's running or scheduled write over a COMPLETED match never carries `result` (`effectiveChangedGroups`). Every server-built write names its groups: decision `result`+`points`+`encho` (`decisionChangedGroups`), both-barred hikiwake `result`, daihyosen add `bout:-1`+`result` and remove `bout:-1`+`result`+`encho` (+`points` when clearing the verdict took a hantei mark out, `daihyosenChangedGroups`), quick-score every group and every stored row (`WholeMatchChangedGroups`), a start (`startOnly`) `result` alone. The rows the server itself changes after the writer sent them (kachinuki merge, the padding a match-level decision adds) are added by `noteServerBoutChanges`; in a kachinuki write, which merges its bout log by position, a stored row the writer left out counts too, so the finish's strip of a trailing unscored pairing is named and not copied back. It and `daihyosenChangedGroups` (mobileapp, the representative-bout add and remove, built from the stored match under the same lock) are the two places the server diffs, each its own values under the write's lock; a CLIENT payload is never diffed against the store, or an echoed stale group with a newer stamp would read as an edit. A server-built door reaching the merge with `Changed` nil is logged as a BUG (`serverBuiltWithoutGroups`). `MatchResult.WriteDoor` names the door for the history.
+- **The wire**: `MatchResult.Changed` (`json:"-"`, request-only, beside `ClearsWithdrawal`) is read from `/score`'s and each bulk entry's `changed`; a name that is not a group is a 400. ABSENT means every group the payload carries (`defaultChangedGroups`; on the pool branch a nil SubResults still names the stored rows, the whole-struct write's old meaning), EXCEPT that a client's running or scheduled write over a COMPLETED match never carries `result` (`effectiveChangedGroups`). Every server-built write names its groups: decision `result`+`points`+`encho` (`decisionChangedGroups`), both-barred hikiwake `result`, daihyosen add `bout:-1`+`result`+`repPickA`+`repPickB` and remove `bout:-1`+`result`+`encho`+`repPickA`+`repPickB` (both date both sides' picks, so a pick made on the previous representative bout is held, never landed on the new one; +`points` when clearing the verdict took a hantei mark out, `daihyosenChangedGroups`), quick-score every group and every stored row (`WholeMatchChangedGroups`), a start (`startOnly`) `result` alone. The rows the server itself changes after the writer sent them (kachinuki merge, the padding a match-level decision adds) are added by `noteServerBoutChanges`; in a kachinuki write, which merges its bout log by position, a stored row the writer left out counts too, so the finish's strip of a trailing unscored pairing is named and not copied back. It and `daihyosenChangedGroups` (mobileapp, the representative-bout add and remove, built from the stored match under the same lock) are the two places the server diffs, each its own values under the write's lock; a CLIENT payload is never diffed against the store, or an echoed stale group with a newer stamp would read as an edit. A server-built door reaching the merge with `Changed` nil is logged as a BUG (`serverBuiltWithoutGroups`). A bracket door that gives a side another team without reopening that match records a `reseat` history line naming the cleared pick group (`recordRepPickClears`, bracket_seat_audit.go: a snapshot of every match's picks before the door's write, diffed after, in the same transaction; `ResolveQualifiedPools`, the resolver run after a pool write or a `DELETE .../overrides`, is such a door, in its own `WithTransaction`); a reopened match's `downstream-reopen` line names the pick instead, and a refused write's restore records nothing. `MatchResult.WriteDoor` names the door for the history.
- **The merge owner**: `mergeMatchWrite` (engine/match_merge.go), called by BOTH branches before anything else judges the write (`applyPoolWrite`; `applyBracketResultIn` for rounds and bronze, ahead of `preserveWithdrawalRuling` and the downstream guard), each after folding the stored pairing into the write (`reconcileSides`; on the bracket branch `sidesBeforeMerge`), so a winner worked out again reads the stored sides, never a payload's omitted ones. An UNSTAMPED write the server orders itself (a completing write, or any write a server door builds: quick-score, an engine caller, a decision with no stamp) takes the server's time there, never older than the stored `ModifiedAt` (`writeStamp`), so it leaves a fence a later stale replay is ordered against; only a client's unstamped running or scheduled write keeps the unstamped bypass. A changed group applies when `domain.ApplyByTimestamp(stamp, storedGroupStamp)` (0 on either side applies) and takes the write's stamp; otherwise the stored value is copied in and the group is HELD (its incoming value goes to `MergeReport.HeldValues`). A group the write does not change always takes the stored value. An applied group whose value equals the stored one is `Unchanged` (an echo); for a write that NAMES NO groups at all (`Changed` nil: bulk-score, an older client, which cannot tell an echo from a change) it keeps the stamp it already had rather than taking the write's, and `ModifiedAt` moves only when some applied group actually changed, so a whole-match echo can never fence out a real, older change made on another device in the gap. A write that NAMES a group takes the write's stamp there even when the group echoes: naming it is a real action taken at that time (an operator re-sends the value their screen showed, or a replayed decision), not a re-send of everything the write happened to carry, and `ModifiedAt` moves with it. A HELD group whose value equals the stored one is no loss (`HeldEcho`, bc-mrgc phase 3: a decision's "no overtime" over a match with none): it is neither listed in `heldGroups` nor kept in `HeldValues`, and its history outcome is `unchanged`. The write is SUPERSEDED only when something is held (or echo-held) and every applied group is an echo (`MergeReport.Superseded`): then nothing is written to the match file, exactly as before. The report rides on `MatchResult.Merge`; `ResultChanged` false (the verdict did not move: held, or applied as an echo of the stored one, e.g. the same withdrawal sent again under a later stamp) skips the eligibility record, since re-recording a stored withdrawal would rewrite its status and undo a reinstatement made since. Corrections get no exemption: a stale correction is held like any other change.
- **R2** (operator: "points scored means the withdrawal was a mistake"): a kiken, fusenpai or fusensho is ATOMIC with what it changes and is held whole when any of its groups is held or ANY stored points or bout stamp is newer than it (`withdrawalOutranked`); `recordDecisionTx` asks the same merge on a copy first (`decisionHeldByMerge`), so a stale decision is superseded before its refusals judge it. A scoring change (points, a bout, the engi flags, with a different value) in a RUNNING write stamped after a stored withdrawal clears it (`ClearsWithdrawal` set, the cleared result kept in history as `clearedWithdrawal`); `restoreIfWithdrawalRemoved` restores eligibility. That clear needs an actual SCORING change against a withdrawal of the match itself -- an overtime toggle alone says nothing about whether the withdrawal was a mistake -- and it must give the match its winner: a clear that would leave a knockout tied is abandoned and the decision stands, held as below, never answered `needs_winner`. Overtime enters this rule only when its value actually changes (an echoed (E) is a plain no-op), and a hold records a reason only when it held something that is not an echo. A kiken, fusenpai or fusensho (a fusensho is awarded for a bar recorded on ANOTHER match: the other side cannot fight) already decided who won, and neither a point, a bout, nor an (E) tap the board still sends says otherwise, so the decision stands and the whole scoring side of the write -- points and overtime alike -- is held together instead (`holdDefaultWinScoring`; the history reason names the decision, `defaultWinStandsReason`, e.g. "a fusensho closed this match"), answered with its own code, `heldReason: "default_win_stands"`, and the decision, `heldDecision` (`MergeReport.DefaultWinStands`/`StandingDecision`; the message from `messageForHeldReason`, worded through `domain.DecisionWord`; the client's `writeDefaultWinStands`/`writeHeldDecision` and `decisionWord` in write_result.jsx; `HeldDecision()` is non-empty exactly when `HeldReason()` is `default_win_stands`, so every answer carries the two together), so the operator is told which decision closed the match and to correct it from the match's score editor (the copy names no button: a kachinuki editor offers no Remove), rather than told a newer change exists or to correct the result with a winner. A COMPLETED correction keeps a stored withdrawal exactly as before (`KeepsWithdrawalRuling`, the `clearWithdrawal` flag).
- **R3** (operator: "stay finished, add the point"): a running write's scoring change stamped AFTER a finished match's `result` stamp applies to the finished match, which stays finished; `deriveWinnerAfterMerge` works the winner out again through the existing owners (engi flags, `deriveKachinukiWinner`, `TeamResult` IV then PW then `deriveDaihyosenWinner`, `CountScoringIppons` with the Ht mark breaking a tie). An engi match is never a draw: a winner only on a valid flag total (`engiValidTotal`), pool and knockout alike (bc-mrgc phase 3; a pool recount to an even total used to become a hikiwake). One stamped before it, or unstamped, is held whole. A points change whose write does not apply `result` keeps a stored hantei mark (`carryHantei`, through `domain.AppendHantei`): the judges' decision is the result's, so a late foul or a board that never showed the mark cannot erase it; `stripInvalidHantei` still drops it if the new scoreline is not tied. The score handler treats such a write as a correction: no court gate, no StartMatchTx (`writesOverFinished`), and the old `staleAfterComplete` `{stale:true}` drop is gone. A server-built write that names `result` on purpose (the daihyosen add to a finished knockout match) is not R3's shape.
- **R4** (operator ruling 2026-10-04, replacing the implementer's back-to-running edge: "It needs a winner if the match is finished and is being corrected"): a scoring change that would leave a FINISHED knockout match tied, or an engi match (pool or knockout) with no valid flag count, is NOT applied. `deriveWinnerAfterMerge` reports false and changes nothing, and `mergeHold` (the one place a group is taken back out of a write) holds the scoring groups: the match keeps its recorded finish, winner and advanced winner, never goes back to running, and the change is in the history with `HoldReasonKnockoutNeedsWinner`/`HoldReasonEngiNeedsValidCount`. `MergeReport.NeedsWinner` reaches the answer as `heldReason: "needs_winner"` (`state.HeldReasonNeedsWinner`) beside `heldGroups`, with `NeedsWinnerMessage` on a fully held write, so the operator is told to correct the result with a winner. A pool or league tie becomes a completed draw (`hikiwake`). Nothing on the bracket write retracts a winner for it any more.
-- **S2, the other arrival order**: a finish whose `result` applies over scoring stored NEWER than it (a point made after the finish on a board that never saw it, arriving first) gets the verdict that scoring gives, through the same `deriveWinnerAfterMerge`, so both arrival orders end in the same state (status, winner, scoreline, stamps); the result is stamped with that newer scoring's stamp when the verdict moved (`verdictMoved`), as R3 stamps it. The finish's own hantei mark is carried onto the newer scoreline first (`carryHantei`, the S3 rule the other way round). A withdrawal is excluded (R2's `withdrawalOutranked` owns it). When the newer scoring would leave the match without the winner it needs (a knockout tie, an engi count that is not valid), stamp order says that scoring came after the finish and could not apply (R4), so the finish is APPLIED on its own scoreline and each newer scoring group is moved out of the match into the history as held (`mergeHold.displaceNewerScoring`, `MergeReport.Displaced`, one entry per stamp with door `displaced` and the R4 reason, written by `recordDisplacedHistory`, also from the engi branch); `ModifiedAt` falls back to the newest stamp left. Both arrival orders then reach the same state (status, winner, scoreline, stamps, next round) and both histories hold the tying change; the answer is applied and carries `displacedGroups` with `heldReason: "needs_winner"`. Only a finish whose payload does not carry the scoring it stood on (it names `result` alone and sends no scoreline) cannot be replayed: it is held (`needs_winner`) and the match stays as stored. Winners worked out again take a side's id from the stored match when the payload omits it (`mergeCtx.storedAID`/`storedBID`), since ids are backfilled only after the merge.
+- **S2, the other arrival order**: a finish whose `result` applies over scoring stored NEWER than it (a point made after the finish on a board that never saw it, arriving first) gets the verdict that scoring gives, through the same `deriveWinnerAfterMerge`, so both arrival orders end in the same state (status, winner, scoreline, stamps); the result is stamped with that newer scoring's stamp when the verdict moved (`verdictMoved`), as R3 stamps it. The finish's own hantei mark is carried onto the newer scoreline first (`carryHantei`, the S3 rule the other way round). A withdrawal is excluded (R2's `withdrawalOutranked` owns it). When the newer scoring would leave the match without the winner it needs (a knockout tie, an engi count that is not valid), stamp order says that scoring came after the finish and could not apply (R4), so the finish is APPLIED on its own scoreline and each newer scoring group is moved out of the match into the history as held (`mergeHold.displaceNewerScoring`, `MergeReport.Displaced`, one entry per stamp with door `displaced` and the R4 reason, written by `recordDisplacedHistory`, also from the engi branch); `ModifiedAt` falls back to the newest stamp left. Both arrival orders then reach the same state (status, winner, scoreline, stamps, next round) and both histories hold the tying change; the answer is applied and carries `displacedGroups` with `heldReason: "needs_winner"`. The same displacement covers a representative pick stamped after a representative-bout removal: when the removal applies, the pick is moved to the history (reason `HoldReasonRepBoutRemoved`, "the representative bout was removed"), and that answer carries `displacedGroups` with NO `heldReason`; the client asks `writeDisplacedForWinner` (write_result.jsx) before saying a displacement left a match without a winner. Only a finish whose payload does not carry the scoring it stood on (it names `result` alone and sends no scoreline) cannot be replayed: it is held (`needs_winner`) and the match stays as stored. Winners worked out again take a side's id from the stored match when the payload omits it (`mergeCtx.storedAID`/`storedBID`), since ids are backfilled only after the merge.
- **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to).
-- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReasonUnderTx`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out.
+- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out.
-- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`.
+- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop, and by `claim.agree(group)` when an editor's adopt TAKES the server's value for that group, or when the team editor's withdrawal-removal Undo puts the whole board back to the server's (every bout group and both pick groups); an adopt that takes only PART of a group agrees it only when the editor's whole group then equals the server's (the individual editor's hantei adopt skips `points` while a point of its own is unsaved; a representative-side adopt agrees `rep` only when both sides then hold the server's picks); both baselines are kept per group), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`.
- **Phase 3, the remaining drop sites, each with one owner:**
- **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone).
@@ -330,9 +331,9 @@ Two distinctions that look like details and are not:
**Self-run participants run the representative bout; a hantei stays the organiser's** (operator decisions 2026-09-28, bc-dhas). An anonymous self-run `PUT /score` may carry the representative-bout row (position -1, sub decision `daihyosen`) and score it like any bout. `POST`/`DELETE .../daihyosen` are public, but an anonymous caller adds or removes only on a RUNNING match (`selfRunDaihyosenRefusal`, handlers_daihyosen.go: 409 `result_finalized` with the `/score` sentence once it has finished, 409 `match_not_running` before it starts). The organiser keeps no status rule there, since an add to a finished fixed-order knockout match may be their correction (no `/reopen` exists for one). The add keeps a recorded kiken, fusenpai or fusensho; the organiser's remove of an unscored representative bout does not: it clears the match's decision and puts it back to running (`clearMatchVerdict`), while the competitor's bar stays recorded. Accepted as it is (operator decision 2026-10-02); do not report it as a new defect. An encounter holds ONE representative bout: a second add is 409 `daihyosen_exists` for every caller, and the add refuses engi, kachinuki and individual competitions and pool matches. `state.DaihyosenSubIndex` is the one finder of that row (the first -1 row).
-ONE judge, `holdSelfReportedWriteUnderTx` (handlers_match.go), decides everything a participant's score write is refused or rewritten for that depends on the stored match. The decision allowlist and the Rev/RevSession reset run before the lock, in `enforceSelfRunPolicy`. The judge runs under the write's lock and reads the stored match once (`matchSnapshot`, whose `Pairing` holds the stored sides and ids), and each refusal is a `selfRunRefusal` carrying its own status, code and sentence.
+ONE judge, `holdSelfReportedWriteUnderTx` (handlers_match.go), decides everything a participant's score write is refused or rewritten for that depends on the stored match. The decision allowlist and the Rev/RevSession reset run before the lock, in `enforceSelfRunPolicy`. The judge runs under the write's lock over the stored match the `/score` handler reads ONCE per write for every guard (`matchSnapshot`, whose `Pairing` holds the stored sides and ids; `applyCorrectionReason` reads the same one, and the member judge takes it as the pre-write baseline), and each refusal is a `selfRunRefusal` carrying its own status, code and sentence. Who a write seats is judged on what it LANDS, after the engine write, in the same transaction (`landedMembersRefusal`): the handler reads the match back through `stx`, which sees its own staged bytes, and compares it with the snapshot read before the write. A side member id the stored row now holds and did not hold before must be on that side's team (400 `team_member_not_in_team`: `errBoutFighterNotInTeam` on a participant's numbered row, `errRepMemberNotInTeam` on a representative pick, anyone's), and a winner member id that changed on a participant's numbered row must be one of the row's two fighters (400 `winner_member_not_in_bout`, `errWinnerMemberNotInBout`); the organiser's numbered rows are not judged. A refusal is RETURNED from the transaction closure as a `*landedWriteRefusal`, which ABORTS the transaction (no match write, no history line, no eligibility record), and is unwrapped into `engErr` after `WithTransaction` returns. It is judged only for a write the engine accepted (a superseded or rolled-back write still commits its history and restore) and only when the write, as the earlier judges leave it, INTRODUCES a member id on a judged row against the match as it stood before the write (`introducesJudgedMember`, a pure predicate over the payload rows and the pre-write snapshot; it and the judge, `membersOutsideTeams`, read ONE selection of rows, `judgedRows`: every representative-bout row against the stored one and, for a participant's write, each numbered row against the stored row at its position, `state.SubResultAt`, so a row or field added to one is added to the other): an echo of the stored ids, which the team editor stamps on every row that has a lineup, reads nothing back, because the engine never lands a member id the judge would refuse that the payload did not introduce (pinned from the engine's side by `TestEngineLandsNoMemberIDThePayloadDidNotIntroduce`, over kachinuki bouts on both store branches and a fixed-order team match's representative bout on the bracket, with stamped writes (one older than the group it rewrites, so that group is held and copied back) and both teams' starting lineups and team members saved, whose minted ids no row carries: every member id the write itself lands that the payload did not carry is a STORED one at the same position (`preserveKachinukiMemberIDs`; the merge's copies of the groups it does not apply, held or unnamed, through `CopyGroup`/`SetRepPick`; `keepQueuedScore` on a start, which that test does not send) or a winner id derived from the row's own landed side ids (`ResolveMemberWinnerID`/`ReconcileWinnerMemberID`), and an id taken from those lineups or members inside the write would be a foreign one it refuses; `MaybeAdvanceKachinuki`'s lineup ids are added after the transaction, in the handler, which the test does not reach). It judges the landed match, not the payload, because the engine rewrites a write before its merge (`applyKachinukiMerge` merges stored rows by position and fills member ids; `noteServerBoutChanges` adds the `bout:N` groups it rewrote): a pre-write probe of the payload (`engine.AppliedGroups`, removed) let a participant land another team's member on a kachinuki bout the write left out of `changed`. A group the merge does not land is the stored one in the landed match and is never judged: a stale NAMED group is in `heldGroups` and the history, an unnamed one in neither. Every member-id check (the landed rows, and the lineup PUT's `memberIDsOutsideTeam`) goes through one `memberJudge`, which reads team-members.yaml at most once and only when an id is introduced, and builds each team's member-id set once (`memberJudge.ids`); a file it cannot read refuses the write with 409 `team_members_unreadable` (`errTeamMembersUnreadable`), never a 500 the offline queue would retry forever, and since a refusal is an abort, nothing is written.
-A participant's score write never creates a representative bout. A -1 row on a match that has none is one the write INHERITED, from a sheet a moment behind a remove made elsewhere or from a queued write replayed after it. The judge DROPS that row, logs the drop, and writes the rest through the timestamp guard, which decides a stale one. A write that FINISHES the match on such a row is refused instead, 409 `no_daihyosen` (`errRepBoutRemoved`), and nothing is written, since a finish may take its winner from that row. Nor does it ever remove one (only `DELETE .../daihyosen` does): a write listing the bouts without the -1 row the match has comes from a sheet a moment behind an ADD, so the judge KEEPS the stored row, scored or not, and a finish is refused, 409 `daihyosen_added` (`errRepBoutAdded`). Two rows are 400 `duplicate_daihyosen`. The organiser's write is not judged here. Once the organiser records the judges' decision on it, a participant's write may send the row back (`sameHanteiVerdict`: same winner, same side names, mark in the same array) or leave its ippons out. The silence test is `engine.KeepsStoredDaihyosenVerdict`, the one `preserveSubHantei` restores by; a row with two or more outstanding fouls is never silent, because the fold would give it an array. Either way the judge REPLACES that row with a deep copy of the stored one, so the write lands with the decided bout exactly as recorded, whatever else the row said. Any of these is 409 `hantei_organiser_only`:
+A participant's score write never creates a representative bout. A -1 row on a match that has none is one the write INHERITED, from a sheet a moment behind a remove made elsewhere or from a queued write replayed after it. The judge DROPS that row, logs the drop, and writes the rest through the timestamp guard, which decides a stale one. A write that FINISHES the match on such a row is refused instead, 409 `no_daihyosen` (`errRepBoutRemoved`), and nothing is written, since a finish may take its winner from that row. Nor does it ever remove one (only `DELETE .../daihyosen` does): a write listing the bouts without the -1 row the match has comes from a sheet a moment behind an ADD, so the judge KEEPS the stored row, scored or not, and a finish is refused, 409 `daihyosen_added` (`errRepBoutAdded`). Two rows are 400 `duplicate_daihyosen`. The organiser's write is not judged here, except its representative picks, which `landedMembersRefusal` judges on what the write lands, exactly as a participant's (same 400 `team_member_not_in_team` and sentence); a pick the merge holds (stamped older than the stored pick, or carried by a write held whole) is kept in the history and answered in `heldGroups`, never refused (a pick names a SIDE, whose team a correction can change under an open editor), and the match's status is never asked. Once the organiser records the judges' decision on it, a participant's write may send the row back (`sameHanteiVerdict`: same winner, same side names, mark in the same array) or leave its ippons out. The silence test is `engine.KeepsStoredDaihyosenVerdict`, the one `preserveSubHantei` restores by; a row with two or more outstanding fouls is never silent, because the fold would give it an array. Either way the judge REPLACES that row with a deep copy of the stored one, so the write lands with the decided bout exactly as recorded, whatever else the row said. Any of these is 409 `hantei_organiser_only`:
- recording, moving or clearing the decision;
- dropping the row;
- naming a match `winner`/`winnerId` other than the decided side, read id-first against the STORED sides through `domain.AttributeWinnerSide`.
@@ -345,7 +346,7 @@ The public editor matches:
- once a hantei is recorded, the representative bout is read-only (`repBoutDecidedForParticipant`, overtime stepper included) with the refusal's sentence, and every save sends the verdict back;
- the individual editor hides its whole decision place.
-`POST .../bulk-score` and `PUT .../quick-score` write without these checks, so they are main-gated in self-run.
+`POST .../bulk-score` and `PUT .../quick-score` write without the participant's checks, so they are main-gated in self-run; bulk-score does judge each entry's representative picks on what the entry lands, in the entry's own transaction (a refused entry stores nothing and reports `reason: team_member_not_in_team`), and quick-score builds its rows server-side.
**A representative-bout add or remove holds the team editor's autosave** (bc-dhas). The add and remove go through `_daihyosenRequest` (api_client.jsx) on `_fetchJson`, which keeps one AbortController armed, on a timer rather than the wall clock, until the body is read. So a request the server never answers, or answers with a body that never completes, fails with the not-added/not-removed sentence instead of holding the write forever. The add and the remove are one sequence, `runRepBoutChange` (admin_scoring_team.jsx). It takes `hold()` before its pre-save. The pre-save is bounded by the same `FETCH_TIMEOUT_MS` (write_result.jsx), past which the change is reported as not answered, nothing is sent and the hold is released. Add and Remove both stay disabled while that save is still out (`submitting`), so no second change starts beside a pre-save given up on. The admin hosts' refetch after a write is bounded too (`refreshCompsBestEffort` asks `fetchCompetitions({ bounded: true })`), and one given up on at that deadline says nothing: `_fetchJson` rejects past its deadline with the one `timedOut` error however the abort surfaced, and `refreshFailureToast` (admin.jsx) returns no toast for it, because the hosts refresh after every autosave and on a slow link the toast would follow every few taps while the write reaches the page by push anyway. Any other refresh failure keeps the reload hint. `recordScore`, `recordDecision`, `overrideBracketWinner` and the queue's replay (`_flushQueue`, single-flight, so one stalled replay stopped every later flush) read their answers through `_fetchJson` too, so a body that stalls past the deadline is a network failure: the write comes back `{queued: true}` (the editor stays open, `writeKeepsEditorOpen`) and is replayed with the stamp it was sent with, safe if the server did store it because the write guard is last-write-wins and an equal stamp re-applies. The add saves the sheet first unless the match has finished, so a queued match starts through the score path's gates; the remove saves first only a running sheet with an edit owed. A debounce that fires while held defers its write, and the mounted editor calls `release()` from an effect on `daihyosenBusy`'s true-to-false edge, after the render that adopted the result. The adopt keeps the answer in the override, `{ match, at }`: a stamped answer the prop does not hold yet (`holdsAnswer`) becomes the match the sheet shows, with the answer's bout log, status and team-match overtime (the prop's winner and decision stay, since the client reshapes those), and it goes once the prop's bout log changes or its stamp reaches `at`; a change of the prop's STATUS re-judges it too (an effect on `match.status`, which releases it when the prop holds the same bout log and is not older than the answer), so a finish or reopen made elsewhere is not hidden behind it. `hold()` returns `settle(transform)`, called on every exit (`withServerDaihyosenRow` over the answer's row once landed, or over the last prop's when that prop already holds the answer; bare in `finally`): an edit owed when the editor UNMOUNTS during the request waits for it and is written with the outcome applied (the added row as returned, a removed one left out, unchanged on failure or refusal). A page going away (`pagehide`) meanwhile writes it at once, durably, as it stands, since nothing can wait for it; a tab merely hidden (`visibilitychange`) waits for the request like the unmount does and then writes durably with the outcome applied, since a write at once would put back a removed representative bout or wipe an added one. Accepted residual: a hidden page the system discards before the request answers, with no `pagehide`, loses that one tap. The pre-save (`saveRunningSheet`) repeats while a tap made during it is owed, reading the sheet through the autosave refs: the request is stamped when it is sent, so an edit made before it and written after it would lose the guard silently.
@@ -367,6 +368,8 @@ A picker that FILTERS A LIST by what the reader typed asks ONE rule for the comp
The workbook is built entirely from code in `internal/excel/template.go` (`NewFileFromScratch`). Each sheet (data, Time Estimator, Pool Draw, Pool Matches, Elimination Matches, Names to Print, Tree) is created and styled programmatically. Layout constants (rows-per-page, spacing, max bracket size) and sheet name constants (`SheetData`, `SheetPoolDraw`, etc.) live in `internal/helper/constants.go`. Use these constants everywhere rather than string literals.
+**excelize is pinned to a master commit, not a release** (`v2.11.1-0.20261010030226-066c84b8f7de`, operator decision 2026-10-10, PR #463): v2.11.0 carries fifteen advisories that fail CI's Anchore image scan (`validate`, severity medium and up), each fixed only on excelize's master, and no release after v2.11.0 existed. Move to the first tagged release that contains those fixes. `govulncheck` does not see them (it counts reachable code); to reproduce the scan, run grype on the built binary (`docker run --rm -v :/scan:ro anchore/grype:latest dir:/scan --fail-on medium`; the image is `FROM scratch`, so the binary is all it scans). An excelize version change rewrites the bytes of the committed example workbooks (this one changed only font property order and numbers written to 15 significant digits), so `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` fails: regenerate them with `make examples` and compare the workbooks' XML (cells, formulas, styles, page breaks), not their bytes, before committing.
+
### Resource Embedding
`main.go` embeds `web/*` via `//go:embed`. The global var `helper.WebFs` exists for backward compatibility with code paths that still reference it directly. Must rebuild after changing embedded files.
@@ -444,7 +447,7 @@ Name[, Zekken/DisplayName], Dojo[, DanGrade][, source]
## Common Pitfalls
-- **A team's people are its "team members", never its "squad"** in UI strings, in `docs/`, and on disk (operator ruling 2026-09-16, bc-dnst). The store file is `team-members.yaml`, renamed from `squads.yaml` (root key `squads` -> `members`). **A storage change carries a migration path on load covering the last TWO releases** (operator rule 2026-09-17): `upgradeTeamMembersFilenameLocked` (legacy_upgrade.go) adopts v2.0.0's file on first load and removes it once the new one is written, so a tournament recorded by that release keeps its members. v1.1.0 and earlier stored none, so that is the whole history. It converges rather than dual-reading, so only one shape stays live. The route is `GET /api/competitions/:id/team-members` and its payload key is `teamMembers`; both were renamed with the file, with no compatibility shim, because client and server ship in one binary. Three team writes are deliberately PUBLIC in self-run (operator decision 2026-09-27, bc-dhas, "Open lineups to participants"): the ones the public score sheet makes when a competitor names a bout's fighter, `PUT .../teams/:tid/match-lineups/:matchId`, `POST .../teams/:tid/members` and `PUT .../teams/:tid/members/:memberId`. They are NOT in `isSelfRunMainGatedConfigRoute`; their handlers hold a caller with an EMPTY password (`selfRunAnonymous`, middleware.go, shared with `/score`'s `enforceSelfRunPolicy` and the representative bout's add and remove) to the score path's rule. A password sent but wrong is an organiser with a stale one: it gets 401 with AuthMiddleware's body (`respondInvalidPassword`), never a participant's sentence. That is why the member POST asks `selfRunAnonymous` too. The rule has these parts: no lineup change once the match has finished (409 `result_finalized`, from the `resultFinalized` helper the score path and the representative bout's add and remove use, with the lineup's own sentence; an unknown match is 404) or for a team the match does not hold by id (404 `team_not_in_match`); no renaming a member who already has a name (409 `member_already_named`, a `selfRunRefusal`); no adding or naming a member once the competition has finished (409 `competition_finished`); and no add past `selfRunMemberLimit` (2 x team size + `SquadReserveSlots`, 409 `team_member_limit`), because a member can never be removed; the count and the add are ONE step under the store lock (`AddTeamMemberUpTo`, reached through the `deps.go` interface), since a count taken before the add let concurrent anonymous adds pass it together. The finished-competition refusal is judged there too, and in `NameUnnamedTeamMember`, under the lock the competition's completion takes (`state.ErrCompetitionFinished`), never from the handler's earlier read, which a finish could land after. The match-lineup PUT also refuses an anonymous caller's member id the named team does not hold among the positions the save writes (the changed ones when it names them, `LineupRequest.memberIDsRead`) (400 `team_member_not_in_team`, `memberIDsOutsideTeam`, read through `StoreTx.LoadSquads`), so a participant cannot seat another team's member, or an invented id, in a lineup; the organiser is not judged by it. Every member write answers with the member as stored, stamped (`TeamMember.ModifiedAt`, `Store.stampMember`: the server's time in ms, or one more than the member's previous stamp, so one member's stamps only grow): POST 201, PUT and DELETE 200 (they were 204), and all three announce the change (`lineup-updated` naming the team), POST included. A member name longer than `MaxLenPlayerName` is 400 for every caller. Each of these handlers resolves the caller before reading the body. The organiser, sending the password, keeps mp-q722's always-editable rule. Every other lineup or member route (`GET team-members`, `DELETE members/:memberId`, the round lineups PUT/DELETE, `DELETE match-lineups`) MUST stay in that list or it becomes publicly reachable in self-run; pinned by `self_run_team_writes_test.go`. When you add a route here, decide which side it is on and pin it there. The public page sends an EMPTY `X-Tournament-Password`, never omits it. A public surface reads a team's members from the viewer payload, never from `GET team-members`: the self-run score editor gets them through `MatchViewerModal`'s `teamMembers` prop, and `TeamScoreEditorModal` calls `fetchSquads` only when no host passed them. "Squad" survives as a code name only, in identifiers such as `squadMemberLabel`, `squadSlotLabel` and `squadRosterEntries`, and in the client-side `squads` property that `normalizeViewerCompItem` maps the wire key onto (note `normalizeCompetitionDetail` SPREADS the payload instead, so that shape carries `teamMembers`: two shapes, two names, one concept). The Lineups page heading reads "Team members"; write the same in any new operator copy or docs page.
+- **A team's people are its "team members", never its "squad"** in UI strings, in `docs/`, and on disk (operator ruling 2026-09-16, bc-dnst). The store file is `team-members.yaml`, renamed from `squads.yaml` (root key `squads` -> `members`). **A storage change carries a migration path on load covering the last TWO releases** (operator rule 2026-09-17): `upgradeTeamMembersFilenameLocked` (legacy_upgrade.go) adopts v2.0.0's file on first load and removes it once the new one is written, so a tournament recorded by that release keeps its members. v1.1.0 and earlier stored none, so that is the whole history. It converges rather than dual-reading, so only one shape stays live. The route is `GET /api/competitions/:id/team-members` and its payload key is `teamMembers`; both were renamed with the file, with no compatibility shim, because client and server ship in one binary. Three team writes are deliberately PUBLIC in self-run (operator decision 2026-09-27, bc-dhas, "Open lineups to participants"): the ones the public score sheet makes when a competitor names a bout's fighter, `PUT .../teams/:tid/match-lineups/:matchId`, `POST .../teams/:tid/members` and `PUT .../teams/:tid/members/:memberId`. They are NOT in `isSelfRunMainGatedConfigRoute`; their handlers hold a caller with an EMPTY password (`selfRunAnonymous`, middleware.go, shared with `/score`'s `enforceSelfRunPolicy` and the representative bout's add and remove) to the score path's rule. A password sent but wrong is an organiser with a stale one: it gets 401 with AuthMiddleware's body (`respondInvalidPassword`), never a participant's sentence. That is why the member POST asks `selfRunAnonymous` too. The rule has these parts: no lineup change once the match has finished (409 `result_finalized`, from the `resultFinalized` helper the score path and the representative bout's add and remove use, with the lineup's own sentence; an unknown match is 404) or for a team the match does not hold by id (404 `team_not_in_match`); no renaming a member who already has a name (409 `member_already_named`, a `selfRunRefusal`); no adding or naming a member once the competition has finished (409 `competition_finished`); and no add past `selfRunMemberLimit` (2 x team size + `SquadReserveSlots`, 409 `team_member_limit`), because a member can never be removed; the count and the add are ONE step under the store lock (`AddTeamMemberUpTo`, reached through the `deps.go` interface), since a count taken before the add let concurrent anonymous adds pass it together. The finished-competition refusal is judged there too, and in `NameUnnamedTeamMember`, under the lock the competition's completion takes (`state.ErrCompetitionFinished`), never from the handler's earlier read, which a finish could land after. The match-lineup PUT also refuses an anonymous caller's member id the named team does not hold among the positions the save writes (the changed ones when it names them, `LineupRequest.memberIDsRead`) (400 `team_member_not_in_team`, `memberIDsOutsideTeam`, through the score judge's `memberJudge`: a save naming no member id reads no file, and a team-members.yaml that cannot be read is 409 `team_members_unreadable`), so a participant cannot seat another team's member, or an invented id, in a lineup; the organiser is not judged by it. Every member write answers with the member as stored, stamped (`TeamMember.ModifiedAt`, `Store.stampMember`: the server's time in ms, or one more than the member's previous stamp, so one member's stamps only grow): POST 201, PUT and DELETE 200 (they were 204), and all three announce the change (`lineup-updated` naming the team), POST included. A member name longer than `MaxLenPlayerName` is 400 for every caller. Each of these handlers resolves the caller before reading the body. The organiser, sending the password, keeps mp-q722's always-editable rule. Every other lineup or member route (`GET team-members`, `DELETE members/:memberId`, the round lineups PUT/DELETE, `DELETE match-lineups`) MUST stay in that list or it becomes publicly reachable in self-run; pinned by `self_run_team_writes_test.go`. When you add a route here, decide which side it is on and pin it there. The public page sends an EMPTY `X-Tournament-Password`, never omits it. A public surface reads a team's members from the viewer payload, never from `GET team-members`: the self-run score editor gets them through `MatchViewerModal`'s `teamMembers` prop, and `TeamScoreEditorModal` calls `fetchSquads` only when no host passed them. "Squad" survives as a code name only, in identifiers such as `squadMemberLabel`, `squadSlotLabel` and `squadRosterEntries`, and in the client-side `squads` property that `normalizeViewerCompItem` maps the wire key onto (note `normalizeCompetitionDetail` SPREADS the payload instead, so that shape carries `teamMembers`: two shapes, two names, one concept). The Lineups page heading reads "Team members"; write the same in any new operator copy or docs page.
- **Never write "default win"** in anything a person reads: a UI string, a docs page, an API message, a commit or PR (operator ruling 2026-10-04: "such thing does not exist"). Every finished match has a result: a scoreline, or a registered decision that names the winner (kiken, fusenpai, fusensho, hantei). Name the decision (`domain.DecisionWord` in Go, `decisionWord` in write_result.jsx); say "decision" only where several kinds are meant together. Internal identifiers keep the old name (`IsDefaultWinDecisionStr`, `DefaultWinIppons`, the wire code `default_win_stands`), since no person reads them. The other forms are the same concept and banned with it: "default loss", "default winner", and a side that wins, won (never the contraction "won't"), loses or is winning "by default". Enforced by three gates sharing three patterns, pinned by one shared case table (`internal/mobileapp/testdata/default_win_cases.json`, read by the Go and JS gate tests): "default" then win(s) or loss(es) with at least one separator (any space, a hyphen, `{}`, quotes or `+`, and a word boundary after, so a camelCase identifier or `variant="default" winner` passes); "default", whitespace, winner(s); and wins, won, winning or loses followed within 40 characters by "by", whitespace, "default" (crossing line breaks, stopping at a full stop or a table-cell `|`). The words "winners", "lost" and "losing" are deliberately left out of the last pattern: they refused ordinary settings and connection prose ("Winners per pool is 2 by default"). The gates: `TestNoDefaultWinInStringLiterals` (every Go string literal under internal/ and cmd/, `+`-joined literals checked as one) with `TestNoDefaultWinInPersonReadText` (every git-tracked person-read text file outside docs/: `specs/openapi.yaml`, tracked `specs/**/*.md`, and the root `*.md` except CLAUDE.md and AGENTS.md), a FORBIDDEN rule in `web-mobile/check-write-result.mjs` (the comment-stripped WHOLE file; also index.html and styles.css) and `docs/check_prose.py` (paragraph-wide, on tag- and emphasis-stripped text; a fenced block or comment ends a paragraph).
- **Kendo uses NO MATS. Never write "mat" or "mats"** in code, comments, tests, UI strings, docs or commit messages. The fighting area is a **shiaijo** (in prose, a **court**); "shiai-jo" hyphenated on the public `docs/` site. "Mat" is judo/wrestling vocabulary and is simply wrong for this sport. Reaching for it to avoid repeating "court" is how it gets in. (The only legitimate `mat` in the tree is a QR bitmap variable in `web-mobile/js/qr.jsx`, plus the rule's own name inside `docs/check_prose.py`.)
- Excel coordinates matter: changing match generation requires updating cell references and formula links across sheets
@@ -458,6 +461,7 @@ Name[, Zekken/DisplayName], Dojo[, DanGrade][, source]
- Chained match navigation in the admin score editor (Prev/Next buttons, Finish + Start Next, ←/→ keys) must stay on the current match's shiaijo, and the AUTOMATIC advance (Finish + Start Next, the start after a withdrawal or no-show) also stays in the current match's competition (operator ruling 2026-09-26: one operator per court, scoring from the court view; an operator view never switches competition or court by itself). The tournament-wide Scores page lists every competition, so its `nextActiveFrom` filters on the open match's `compId`; the court console's `nextActiveAfter` always did. Prev/Next, which the operator taps, still move along the whole court. Operators run matches per-court, so hopping courts mid-flow breaks the workflow. See `AdminScoreEditor` in `web-mobile/js/admin_schedule_score_editor.jsx` (`admin_schedule.jsx` only re-exports it): filter to `(m.court || "") === (openMatch.court || "")` so empty/undefined courts share one "unassigned" bucket.
- Docs under `docs/` are PUBLIC-facing: no beads (`mp-xxxx`)/`bd`, no internal-tooling or `CLAUDE.md` references, and NO em-dashes. The PR template gates `make docs/build`; run it before pushing docs changes.
- `mkdocs build --strict` fails on broken cross-file links (WARNING) but only logs missing same-page anchors as INFO (build still passes), so verify anchors by hand. Anchors use pymdownx slugs: lowercase, punctuation dropped, `--`/parens collapsed to single hyphens (e.g. `### Locked mode (--lock-password)` becomes `#locked-mode-lock-password`). Note TWO files named `mobile-app.md` exist (`docs/user-guide/mobile-app.md` guide vs `docs/user-guide/commands/mobile-app.md` reference); a relative link from `commands/` resolves to the sibling, so confirm the target file before trusting a flagged anchor.
+- **A court call is the console's, and withdrawing one that is already gone is not a failure** (bc-cdbl, operator decisions 2026-10-08 Q1 (a) and Q2 3A). The console that called a match keeps the announcement id per match and withdraws it once that match leaves scheduled; the server never tags an announcement with a match. `AnnouncementStore.Add` replaces an active announcement with the identical message (new id, fresh duration), and the id it replaced still withdraws the one that replaced it (`replaced`; an alias whose announcement has gone is dropped at the next add or removal, `dropOrphanAliasesLocked`), so a console holding the older id can take the call down. When two consoles called the same match, the second withdrawal finds it gone: `API.deleteAnnouncement` reads a 404 as the outcome the caller asked for (the banner is gone), on the console and the organiser's Dismiss alike. The browser still logs the 404 response itself; that line is not the app's.
- **A lineup read never answers 404 for "nothing saved"** (bc-k404, operator decision 2026-09-27: "there either is one or there isn't and it's empty"). All three public lineup GETs (`lineups/:round`, `match-lineups/:matchId`, `lineup-in-force/:matchId`) in `internal/mobileapp/handlers_lineup.go` answer 200 with `saved: false` and an empty lineup instead; `api_client.jsx`'s `lineupOrNull`, used by `fetchTeamLineup` and `fetchLineupInForce`, is the ONE place that turns `saved: false` into `null`, and a 404 there means the competition itself does not exist. Do not re-add a not-found answer for an unsaved lineup.
## PR Workflow
diff --git a/Dockerfile b/Dockerfile
index 4499dfb42..26d9c3079 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Build stage
-ARG GO_VERSION=1.27.1
+ARG GO_VERSION=1.27.2
# Build stage
FROM golang:${GO_VERSION} AS builder
diff --git a/Dockerfile.mobile b/Dockerfile.mobile
index 370e8d48b..f09d7826f 100644
--- a/Dockerfile.mobile
+++ b/Dockerfile.mobile
@@ -1,5 +1,5 @@
# Build stage
-ARG GO_VERSION=1.27.1
+ARG GO_VERSION=1.27.2
# Build stage
FROM golang:${GO_VERSION} AS builder
diff --git a/Dockerfile.mobile-pdf b/Dockerfile.mobile-pdf
index 33abe49e3..84fb72ba1 100644
--- a/Dockerfile.mobile-pdf
+++ b/Dockerfile.mobile-pdf
@@ -11,7 +11,7 @@
# libreoffice-nogui). The Go binary's LocateSoffice() probes $PATH first, so no
# additional env vars are required.
-ARG GO_VERSION=1.27.1
+ARG GO_VERSION=1.27.2
# ---------------------------------------------------------------------------
# Build stage, identical to Dockerfile.mobile
diff --git a/GEMINI.md b/GEMINI.md
index 3d799d52c..6f3202d48 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -84,7 +84,7 @@ On tree and knockout brackets, the player/team on the top of the bracket is alwa
## Building and Running
### Prerequisites
-- Go 1.27.1+
+- Go 1.27.2+
- Make
### Key Commands
diff --git a/Makefile b/Makefile
index a09c2832a..f026f24fc 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@ BIN_NAME := bracket-creator
GH_REPOSITORY ?= gitrgoliveira/bracket-creator
IMAGE_NAME := ghcr.io/$(GH_REPOSITORY)
BIN_PATH := ./bin
-GO_VERSION := 1.27.1
+GO_VERSION := 1.27.2
GO_SOURCES := $(shell find . -name "*.go" -type f)
EMBEDDED_ASSETS := $(shell find ./web ./web-mobile -type f 2>/dev/null)
@@ -51,8 +51,14 @@ local/deps: hooks/install js/deps ## Install project dependencies
# golangci-lint is lower than the targeted Go version"). `go install pkg@ver`
# runs outside this module, so it would otherwise use whatever `go` is on
# PATH rather than the toolchain go.mod asks for.
- GOTOOLCHAIN=go$(GO_VERSION) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1
- GOTOOLCHAIN=go$(GO_VERSION) go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0
+ # The version also matters on its own: each analyser reads the standard
+ # library's export data through its bundled golang.org/x/tools, and go1.27.2
+ # writes a newer export-data version than x/tools v0.49 can read ("export
+ # data version 5 is greater than maximum supported version 4"). golangci-lint
+ # v2.14.0 bundles x/tools v0.50. No gosec release does yet, so gosec is pinned
+ # to the master commit that does (x/tools v0.51); move it to the next tag.
+ GOTOOLCHAIN=go$(GO_VERSION) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0
+ GOTOOLCHAIN=go$(GO_VERSION) go install github.com/securego/gosec/v2/cmd/gosec@v2.29.1-0.20261009120814-7b1b5cebe007
GOTOOLCHAIN=go$(GO_VERSION) go install golang.org/x/vuln/cmd/govulncheck@latest
python3 -m pip install -r docs/requirements.txt
diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md
index 31252eaa0..065b52822 100644
--- a/docs/architecture/data-model.md
+++ b/docs/architecture/data-model.md
@@ -383,7 +383,23 @@ That applies one level further down as well. A team bout carries its two fighter
MEMBER ids beside their names, and the winner's id beside the winner's name. Without them
a bout between two people who happen to share a display name could not be attributed at
all, because the name identifies neither of them, and competitors are allowed to share
-one.
+one. The representative bout is the one row whose sides are the TEAM names: it keeps them
+and carries the two representatives' member ids beside them, the members picked for each
+side. Each side's id is a change of its own, ordered apart from the other side's and from
+the bout's score and result: a point struck on the representative bout never alters who was
+picked, a pick never alters the score, and a pick for one side never alters the other side's
+pick. So two captains picking at the same moment both land, and so do two boards doing one
+each. The winner's member id
+stays with the bout, and after a merge it always names one of the two picked members (or is
+empty): when a pick changes under it, it is worked out again from the side that won. A pick
+is a member of the team seated on its side, so when a knockout match is given another team on
+a side (a winner corrected upstream, a slot taken back to its placeholder, a qualifier
+repainted), that side's pick is cleared and dated at that moment, later than the pick had
+been. The write that made the old pick, replayed from a queue, is then kept in the match's
+history instead of seating the old team's member on the new team. A side whose team does not
+change (the same winner stored again, or the team only renamed) keeps its pick. A side taken
+back to its placeholder loses its pick, even when the match that feeds it is finished again
+with the same winner: the pick is picked again.
Those are two different namespaces, and the field names keep them apart on purpose. A
match side carries a PARTICIPANT id, which names a competitor or a whole team; a bout side
@@ -575,8 +591,9 @@ sequenceDiagram
**Concurrent writes are merged, never dropped.** A match result is split into groups of
fields that change together: the scoreline, the verdict (status, winner, decision and the
-notes that describe it), the overtime, the engi flags, the representative players, and each
-bout of a team match on its own. A write names the groups it changes, and each group records
+notes that describe it), the overtime, the engi flags, the representative players, each side's
+fighter picked for a representative bout (one group per side), and each bout of a team match on
+its own. A write names the groups it changes, and each group records
the time of the last change applied to it. A change applies only when it was made no earlier
than the stored change to the same group, so two operators changing different things (one
records the overtime while another enters a point, or two bouts of a team match are scored on
diff --git a/docs/architecture/network-architecture.md b/docs/architecture/network-architecture.md
index 865fab618..be6b373d8 100644
--- a/docs/architecture/network-architecture.md
+++ b/docs/architecture/network-architecture.md
@@ -153,7 +153,7 @@ Key client mechanisms (all in `web-mobile/js/api_client.jsx` + consumers):
| Half-open / stalled sockets | 12s write timeouts, 35s SSE silence watchdog (armed at connect, not only `onopen`) |
| Reconnect storms | exponential backoff + jitter (vs. a fixed delay) |
| Lost writes | durable outbox persisted to `localStorage`, retried until it lands, with no age limit; survives tab refresh. Each pending write is its own entry, and each is sent on its own. A flush pass sends the entries in the order they were made (their `modifiedAt`), but one that does not land (offline, or a 5xx that keeps coming back) holds back nothing after it, and a new write goes straight to the server whatever is queued, so holding it back would gain nothing (refer to **Order of writes** in this table) and would let one failing write stop every write after it. A later running update never takes the place of a queued Finish or decision, and a decision and a score for the same match are both kept; only a score write folds into the score write queued just before it (a running update into a running update, a Finish into either, as when the editor re-sends a held Finish), naming every group either changed. A lineup save is the other write that folds: it takes the place of a queued save of the same lineup, because a lineup save restates the whole lineup and carries no time the server could order it by. A refusal on replay is always reported, never taken as success. Every tab of the app shares the stored outbox. A tab saving its queue merges into it: it adds its own writes, keeps the newer copy where two tabs hold the same entry, and removes only the writes it settled. Each tab also takes up the writes another tab stores, so a write queued in a tab that then closes is still sent by a tab left open. A write two tabs hold can be sent by both, which is harmless, because the server orders writes by their stamps (refer to **Order of writes** in this table) |
-| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with the same `heldReason` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision whose first send landed but whose answer was lost is answered as recorded when it is sent again |
+| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, each side's representative pick, `repPickA` and `repPickB`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with `heldReason: "needs_winner"` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. A representative pick made after its representative bout was removed is moved to the history the same way when the removal arrives, and that answer names it in `displacedGroups` with no `heldReason`. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision whose first send landed but whose answer was lost is answered as recorded when it is sent again |
| Edits not yet sent | a score editor holds each edit for a short autosave window (300ms) before it writes it. The sync pill reads "Syncing…" from the first tap, not "Synced". If the editor goes away inside that window (closed, Prev/Next, another match picked, a court switch), the edit is written at once. If the tab is hidden or unloaded inside it, the edit is written straight into the outbox, so a reload does not lose it. An autosave carries the time of the tap, not the time it is sent, so it never outranks a result another device recorded after the tap. A running write already sent but not yet answered is also kept when the page goes away, which cancels its request; while the request is still open, that copy is not sent |
| Server errors (5xx / 429), and 403 | write stays queued and keeps retrying until it lands, holding back no other write; after 10 consecutive rejections the operator gets a notice and the sync pill shows "Not saving", and the match's score editor offers **Discard held result**, which discards that write and nothing else after a confirm. The held-writes count in the admin topbar opens a list of every held write (score updates, results, decisions, winners set by hand, lineups), where any write that keeps failing can be discarded the same way, one at a time; a write only waiting for the connection cannot be discarded there. On this server 403 is never a bad credential (that is 401): it means the tournament is not configured yet, or is missing its password. Only an admin fixing the server state clears it, so signing in again cannot help and the write keeps retrying instead of parking |
| Invalid credential (401) | the one 4xx a retry can fix: the write is parked, not discarded, stops retrying, and shows "Sign in to save"; signing in again re-sends it with the new credential, together with every other queued write still carrying the refused password (one queued after it, before its own 401 came back), so one sign-in is enough. A write queued without a password (a competitor's in a self-run event) is never given one |
diff --git a/docs/dev-guide/contributing.md b/docs/dev-guide/contributing.md
index 5f91985ab..b36a9d303 100644
--- a/docs/dev-guide/contributing.md
+++ b/docs/dev-guide/contributing.md
@@ -9,7 +9,7 @@ By participating in this project, you agree to abide by our
Prerequisites:
-- [Go 1.27.1+](https://golang.org/doc/install)
+- [Go 1.27.2+](https://golang.org/doc/install)
Other things you might need to run the tests:
diff --git a/docs/screenshots/console-correct-completed.png b/docs/screenshots/console-correct-completed.png
index 1538f92e6..37cd7bf04 100644
Binary files a/docs/screenshots/console-correct-completed.png and b/docs/screenshots/console-correct-completed.png differ
diff --git a/docs/screenshots/realtime-update.webm b/docs/screenshots/realtime-update.webm
index f9b8caad5..d6512b90b 100644
Binary files a/docs/screenshots/realtime-update.webm and b/docs/screenshots/realtime-update.webm differ
diff --git a/docs/screenshots/viewer-competition.png b/docs/screenshots/viewer-competition.png
index 0ec21102c..bf2927a80 100644
Binary files a/docs/screenshots/viewer-competition.png and b/docs/screenshots/viewer-competition.png differ
diff --git a/docs/screenshots/viewer-result-encho.png b/docs/screenshots/viewer-result-encho.png
index ab41967b2..730266086 100644
Binary files a/docs/screenshots/viewer-result-encho.png and b/docs/screenshots/viewer-result-encho.png differ
diff --git a/docs/user-guide/commands/version.md b/docs/user-guide/commands/version.md
index 80d06e9b1..c5bbeb820 100644
--- a/docs/user-guide/commands/version.md
+++ b/docs/user-guide/commands/version.md
@@ -8,6 +8,6 @@ bracket-creator - v1.1.0
Git Commit: ee8538fa83f36ccf9910fe6fc0c42e56afaf07ca
Build date: 2026-09-04 13:14:21 +0100
-Go version: go1.27.1
+Go version: go1.27.2
OS / Arch: darwin arm64
```
diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md
index 5e20fee6f..a40168504 100644
--- a/docs/user-guide/court-operators/recording-decisions.md
+++ b/docs/user-guide/court-operators/recording-decisions.md
@@ -67,6 +67,8 @@ A tie that does not affect advancement is left as a shared rank with no extra bo
The bout is a single-point ippon-shobu with no time limit, between one representative from each tied team. Because it runs until someone scores, a daihyosen has no encho. The score editor lets you pick each team's representative from its roster. On the court console, the bout appears with a **(DH)** mark in the centre of the score. In pool and league standings, the team that won its daihyosen carries a **DH** badge.
+When a knockout team encounter is settled by a representative bout, the results workbook (**Export & print**, then **Download results (.xlsx)**) lists it on a **Representative Bouts** sheet, one section for each such encounter. A section is titled as that encounter is on the **Elimination Matches** sheet, followed by (Representative bout), with White (Shiro) on the left and Red (Aka) on the right. The bout's row shows **DH** where a numbered bout shows its number, then each team's representative with their number and name, the score, **(DH)** in the centre column, and the result mark (Ht, Kiken or Fus.) beside the side it names. A side whose representative was not picked shows no name. The sheet appears only when at least one encounter had a representative bout. The tie-break daihyosen of a team pool or league is not on it.
+
## Chusen (drawing lots)
Chusen is the last resort when two or more tied teams have played a round of daihyosen and the bouts still do not produce a strict order. Examples are a cycle where each team beats another, an all-drawn round, or two teams finishing level on daihyosen wins.
diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md
index 1fa107a18..b904fec41 100644
--- a/docs/user-guide/court-operators/scoring-a-match.md
+++ b/docs/user-guide/court-operators/scoring-a-match.md
@@ -15,6 +15,8 @@ On a laptop you can score without the mouse. When scoring an individual match or
Everything you enter on a running match is saved as you go, so closing the editor or moving to another match keeps it, even straight after a tap. Two things are only kept when you press **Finish** or save a correction: a **Mark draw** on a running match, and changes to a finished match. If you close the editor or move to another match with one of those not yet saved, the app asks first: **Discard changes** drops it, and **Cancel** keeps you on the match.
+On the court console, **Finish + Start Next** records the result and starts the court's next match in the same competition. If that match cannot start, for example because one of its competitors is fighting on another shiai-jo, the **Up next** card says why.
+
To take back a strike, tap the scored mark itself in the centre of the board. The editor says so while any mark is scored: under the marks on an individual match, and once above the first bout on a team match. Taking one back frees a cell, and the next strike you award fills it. Take both back and the next two strikes fill the cells in the order you award them, from the outside in.
### Match history
diff --git a/docs/user-guide/install/install.md b/docs/user-guide/install/install.md
index 77a91237e..8fbddfbc5 100644
--- a/docs/user-guide/install/install.md
+++ b/docs/user-guide/install/install.md
@@ -115,7 +115,7 @@ go install github.com/gitrgoliveira/bracket-creator@latest
If you prefer to compile from source, `bracket-creator` is written in [Go](https://golang.org/).
Prerequisites:
-- [Go 1.27.1+](https://golang.org/doc/install)
+- [Go 1.27.2+](https://golang.org/doc/install)
- [Node.js](https://nodejs.org/) (`make go/build` runs `npx esbuild` to compile the web assets)
- `curl` (used to fetch the vendored frontend runtime)
diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md
index b48bf3b45..2c98a61f2 100644
--- a/docs/user-guide/organisers/run-tournament.md
+++ b/docs/user-guide/organisers/run-tournament.md
@@ -77,6 +77,10 @@ The same **Edit details** page also has branding and sponsor fields, below tourn
Click **Announce** from the dashboard to broadcast a short message to every viewer. Choose a duration of 5, 10, 15, or 30 minutes; the message clears itself automatically when the time expires. It appears as an overlay on the viewer and display screens. Spectators who allow browser notifications can receive it in the background.
+Posting the same text again while it is still showing replaces the banner rather than adding a second one: viewers see one message, and its time starts again from the full duration.
+
+A court call, made with **Call to court** on the court console, is announced the same way. It disappears once the match starts, or is closed with a decision such as a fusensho, from any device, as long as the court console that made the call is still open, even if another console called the same match again meanwhile. If that console was closed or reloaded meanwhile, the call stays up until its five minutes run out.
+
## Registration desk
Open **Registration desk** from the dashboard to access the check-in surface for the welcome table. Check-in exists only for competitions with **Check-in tracking** turned on in their Settings, and this desk is the one place to do it: it lists every competitor across those competitions so a registration helper can mark participants present as they arrive. A competition with the setting off does not appear here, and its Ordering & seeding panel carries no check-in controls either. Refer to the [Check-in workflow](#check-in-workflow).
diff --git a/docs/user-guide/organisers/team-tournaments.md b/docs/user-guide/organisers/team-tournaments.md
index d7288503b..e5bc19cb2 100644
--- a/docs/user-guide/organisers/team-tournaments.md
+++ b/docs/user-guide/organisers/team-tournaments.md
@@ -102,7 +102,7 @@ Individual bouts are scored first. Once all bouts are done, the encounter result
1. The team with the highest number of individual wins (victories) wins the encounter.
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.
+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. The results workbook lists each representative bout on its own sheet, described under [Daihyosen](../court-operators/recording-decisions.md#daihyosen).
When the encounter itself ends on a 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.
diff --git a/go.mod b/go.mod
index 04e92bc73..01e85f693 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/gitrgoliveira/bracket-creator
-go 1.27.1
+go 1.27.2
require (
github.com/gin-gonic/gin v1.12.0
@@ -11,7 +11,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.1
- github.com/xuri/excelize/v2 v2.11.0
+ github.com/xuri/excelize/v2 v2.11.1-0.20261010030226-066c84b8f7de
golang.org/x/crypto v0.57.0
golang.org/x/text v0.42.0
golang.org/x/time v0.16.0
@@ -51,13 +51,13 @@ require (
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
- github.com/xuri/efp v0.0.1 // indirect
+ github.com/xuri/efp v0.0.2 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/image v0.46.0 // indirect
- golang.org/x/net v0.58.0 // indirect
+ golang.org/x/net v0.60.0 // indirect
golang.org/x/sys v0.48.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
diff --git a/go.sum b/go.sum
index 3d4c643db..f217062c3 100644
--- a/go.sum
+++ b/go.sum
@@ -104,10 +104,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
-github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
-github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
-github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
-github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
+github.com/xuri/efp v0.0.2 h1:74yhBh0nfIWLOrN76wtShYVse94pADGYS0/c86jOp8Q=
+github.com/xuri/efp v0.0.2/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.11.1-0.20261010030226-066c84b8f7de h1:QRQGy38L8FOUwBEvBQarwtRqHVori7I0ryxhOiL8GjQ=
+github.com/xuri/excelize/v2 v2.11.1-0.20261010030226-066c84b8f7de/go.mod h1:d2KG/tW81cIhSeC3S7wENv3qRnOC3MDcrjDicKuZa6U=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
@@ -123,8 +123,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
-golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
-golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
+golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
diff --git a/internal/engine/bracket_reseat_rep_picks_test.go b/internal/engine/bracket_reseat_rep_picks_test.go
new file mode 100644
index 000000000..cab505e7a
--- /dev/null
+++ b/internal/engine/bracket_reseat_rep_picks_test.go
@@ -0,0 +1,239 @@
+package engine
+
+// A team knockout match whose side is given ANOTHER team loses the
+// representative it held on that side: the pick is a member of the old team,
+// and the picker offers only the seated team's members (GroupRepPickA and
+// GroupRepPickB, state/match_groups.go). seatBracketSide is the one owner; these tests drive
+// it through the doors that re-seat a side with no store handle of their own
+// (a forced score correction, override-winner) and pin what must NOT change: a
+// correction that stores the same winner, and the side nobody re-seated.
+
+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"
+)
+
+const (
+ rpPickA = "pick-ryu"
+ rpPickC = "pick-kuma"
+ rpNextID = "m-r2-0"
+)
+
+// rpRow is the representative bout of Ryu v Kuma with a pick on both sides.
+func rpRow() state.SubMatchResult {
+ return state.SubMatchResult{
+ Position: state.DaihyosenSubPosition, SideA: wrTeamA, SideB: wrTeamC,
+ SideAMemberID: rpPickA, SideBMemberID: rpPickC,
+ Decision: string(domain.DecisionDaihyosen),
+ }
+}
+
+// rpSetup stores a team knockout: m-r1-0 Ryu v Tora is played and Ryu advanced
+// into m-r2-0 side A, Kuma sits on side B (a bye through m-r1-1). m-r2-0 holds
+// a representative bout with both picks, dated mmT1 while the match itself was
+// last written at mmT2 (a point). played makes m-r2-0 a finished match whose
+// rep bout Ryu's pick won.
+func rpSetup(t *testing.T, played bool) (*Engine, *state.Store, string) {
+ t.Helper()
+ eng, store, _ := setupTestEngine(t)
+ const compID = "rp-reseat"
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: "rp", Kind: "team", TeamSize: 3, Status: state.CompStatusKnockout,
+ }))
+ wrSaveTeams(t, store, compID)
+ next := state.BracketMatch{
+ ID: rpNextID, MatchNumber: 3,
+ SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamC, SideBID: wrTeamCID,
+ Status: state.MatchStatusScheduled,
+ SubResults: []state.SubMatchResult{rpRow()},
+ ModifiedAt: mmT2,
+ GroupStamps: map[string]int64{
+ state.GroupRepPickA: mmT1,
+ state.GroupRepPickB: mmT1,
+ state.BoutGroup(state.DaihyosenSubPosition): mmT1,
+ state.GroupPoints: mmT2,
+ },
+ }
+ if played {
+ next.Status = state.MatchStatusCompleted
+ next.Winner, next.WinnerID = wrTeamA, wrTeamAID
+ next.SubResults[0].Winner = wrTeamA
+ next.SubResults[0].WinnerMemberID = rpPickA
+ next.SubResults[0].IpponsA = []string{"M"}
+ next.GroupStamps[state.GroupResult] = mmT2
+ }
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{
+ {
+ {ID: "m-r1-0", MatchNumber: 1, SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID,
+ Status: state.MatchStatusCompleted, Winner: wrTeamA, WinnerID: wrTeamAID,
+ SubResults: []state.SubMatchResult{wrBout1("M")}},
+ {ID: "m-r1-1", MatchNumber: 2, SideA: wrTeamC, SideAID: wrTeamCID,
+ Status: state.MatchStatusCompleted, Winner: wrTeamC, WinnerID: wrTeamCID},
+ },
+ {next},
+ }}))
+ return eng, store, compID
+}
+
+// rpCorrect is the team editor's correction of m-r1-0 naming winner (Ryu or
+// Tora), with the bout that team's fighter won.
+func rpCorrect(winner string) *state.MatchResult {
+ id, bout := wrTeamAID, state.SubMatchResult{Position: 1, SideA: "r1", SideB: "t1", Winner: "r1", IpponsA: []string{"K"}}
+ if winner == wrTeamB {
+ id, bout = wrTeamBID, state.SubMatchResult{Position: 1, SideA: "r1", SideB: "t1", Winner: "t1", IpponsB: []string{"K"}}
+ }
+ return &state.MatchResult{
+ ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID,
+ Winner: winner, WinnerID: id, IpponsA: []string{}, IpponsB: []string{},
+ Status: state.MatchStatusCompleted, CorrectionReason: "Scoring error: wrong waza entered",
+ SubResults: []state.SubMatchResult{bout},
+ }
+}
+
+func rpNext(t *testing.T, store *state.Store, compID string) state.BracketMatch {
+ t.Helper()
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ m := b.MatchByID(rpNextID)
+ require.NotNil(t, m)
+ return *m
+}
+
+// rpOldPickReplay is the write that seated side A's pick, replayed: it names
+// that pick alone, at the stamp it was made at, and carries no sides (the stored
+// pairing fills them).
+func rpOldPickReplay() *state.MatchResult {
+ return &state.MatchResult{
+ ID: rpNextID, Status: state.MatchStatusScheduled, ModifiedAt: mmT1,
+ Changed: []string{state.GroupRepPickA}, WriteDoor: DoorScore,
+ SubResults: []state.SubMatchResult{rpRow()},
+ }
+}
+
+// assertSideAPickGone checks what every re-seat of side A must leave: side A is
+// Tora, its pick is gone, Kuma's pick stands, and the picks are dated after the
+// ones the old team's member was made at and not before the match itself.
+func assertSideAPickGone(t *testing.T, next state.BracketMatch, priorModifiedAt int64) {
+ t.Helper()
+ assert.Equal(t, wrTeamB, next.SideA)
+ assert.Equal(t, wrTeamBID, next.SideAID)
+ a, c := next.RepPicks()
+ assert.Empty(t, a, "side A was given another team: Ryu's member no longer stands for it")
+ assert.Equal(t, rpPickC, c, "side B was not re-seated: its pick stays")
+ assert.Greater(t, next.GroupStamp(state.GroupRepPickA), mmT1, "dated after the stamp the pick held")
+ assert.GreaterOrEqual(t, next.GroupStamp(state.GroupRepPickA), priorModifiedAt, "and never before the match")
+ assert.GreaterOrEqual(t, next.ModifiedAt, next.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, mmT1, next.GroupStamp(state.GroupRepPickB), "side B's pick keeps its date")
+}
+
+func TestReseat_ScheduledDownstreamLosesTheReseatedSidesPick(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ prior := rpNext(t, store, compID)
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamB), ForceOptions{})
+ return err
+ }))
+ assertSideAPickGone(t, rpNext(t, store, compID), prior.ModifiedAt)
+
+ // The write that made the old pick, replayed from a queue, is older than the
+ // clear: it is kept in the history, not put back.
+ _, err := eng.RecordMatchResultWithIneligibility(compID, rpNextID, rpOldPickReplay())
+ require.ErrorIs(t, err, ErrMatchSuperseded)
+ assert.Equal(t, []string{state.GroupRepPickA}, HeldGroupsOf(err))
+ after := rpNext(t, store, compID)
+ a, c := after.RepPicks()
+ assert.Empty(t, a, "the replay did not seat Ryu's member on Tora's side")
+ assert.Equal(t, rpPickC, c)
+}
+
+func TestReseat_PlayedDownstreamLosesThePickAndItsReopenLineNamesIt(t *testing.T) {
+ eng, store, compID := rpSetup(t, true)
+ prior := rpNext(t, store, compID)
+
+ var reopened []ReopenedMatch
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamB),
+ ForceOptions{Force: true, Reopened: &reopened})
+ return err
+ }))
+ require.Equal(t, []string{rpNextID}, reopenedIDs(reopened))
+ assert.Equal(t, []string{state.GroupRepPickA}, reopened[0].RepPicksCleared, "the reopened match is told its side A pick went with the team")
+
+ next := rpNext(t, store, compID)
+ assertSideAPickGone(t, next, prior.ModifiedAt)
+ assert.Equal(t, state.MatchStatusScheduled, next.Status, "its verdict went")
+ assert.Empty(t, next.Winner)
+ assert.Empty(t, next.SubResults[0].WinnerMemberID, "the rep bout's winner id went with the verdict and the pick")
+
+ entries, err := store.LoadMatchHistory(compID, rpNextID)
+ require.NoError(t, err)
+ var reopenLine *state.MatchHistoryEntry
+ for i := range entries {
+ if entries[i].Door == doorDownstreamReopen {
+ reopenLine = &entries[i]
+ }
+ }
+ require.NotNil(t, reopenLine, "the played match gets a line for its reopen")
+ assert.Contains(t, reopenLine.Changed, state.GroupRepPickA)
+ assert.NotContains(t, reopenLine.Changed, state.GroupRepPickB, "side B kept its pick")
+ assert.Contains(t, reopenLine.Changed, state.GroupResult)
+}
+
+func TestReseat_PlayedDownstreamReopenLineNamesNoPicksWhenNoneWentWithIt(t *testing.T) {
+ eng, store, compID := rpSetup(t, true)
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ m := b.MatchByID(rpNextID)
+ m.SubResults[0].SideAMemberID, m.SubResults[0].SideBMemberID = "", ""
+ require.NoError(t, store.SaveBracket(compID, b))
+
+ var reopened []ReopenedMatch
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamB),
+ ForceOptions{Force: true, Reopened: &reopened})
+ return err
+ }))
+ require.Equal(t, []string{rpNextID}, reopenedIDs(reopened))
+ assert.Empty(t, reopened[0].RepPicksCleared)
+ entries, err := store.LoadMatchHistory(compID, rpNextID)
+ require.NoError(t, err)
+ for _, e := range entries {
+ assert.NotContains(t, e.Changed, state.GroupRepPickA, "there was no pick to clear")
+ assert.NotContains(t, e.Changed, state.GroupRepPickB, "there was no pick to clear")
+ }
+}
+
+func TestReseat_OverrideWinnerLosesTheReseatedSidesPick(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ prior := rpNext(t, store, compID)
+
+ applied, err := eng.OverrideBracketWinner(compID, "m-r1-0", wrTeamB, 0)
+ require.NoError(t, err)
+ require.True(t, applied)
+ assertSideAPickGone(t, rpNext(t, store, compID), prior.ModifiedAt)
+}
+
+// A correction that stores the winner already recorded re-propagates the same
+// team: nothing about the downstream match moves, its picks and their date
+// included. Green before the owner existed too; it is the no-op guard.
+func TestReseat_CorrectionStoringTheSameWinnerLeavesThePicksAlone(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ prior := rpNext(t, store, compID)
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamA), ForceOptions{})
+ return err
+ }))
+ next := rpNext(t, store, compID)
+ a, c := next.RepPicks()
+ assert.Equal(t, rpPickA, a)
+ assert.Equal(t, rpPickC, c)
+ assert.Equal(t, mmT1, next.GroupStamp(state.GroupRepPickA), "side A's pick keeps its date")
+ assert.Equal(t, mmT1, next.GroupStamp(state.GroupRepPickB), "and so does side B's")
+ assert.Equal(t, prior.ModifiedAt, next.ModifiedAt, "and the match is not touched")
+}
diff --git a/internal/engine/bracket_result.go b/internal/engine/bracket_result.go
index 4bf79cb85..8106b414b 100644
--- a/internal/engine/bracket_result.go
+++ b/internal/engine/bracket_result.go
@@ -92,6 +92,15 @@ func bracketMatchAsResult(bm *state.BracketMatch) *state.MatchResult {
}
}
+// BracketMatchAsResult is a bracket match as the pool-shaped record every
+// write and merge reads (bracketMatchAsResult), for callers outside the engine
+// that read a stored bracket match the way they read a pool row (mobileapp's
+// matchSnapshot, before and after a score write). The result shares bm's bout
+// rows: a caller that changes it clones them first.
+func BracketMatchAsResult(bm *state.BracketMatch) *state.MatchResult {
+ return bracketMatchAsResult(bm)
+}
+
// bracketMatchCourtAndSchedule returns the Court and ScheduledAt a bracket
// match (a round match or the bronze/3rd-place match) currently holds,
// bypassing bracketMatchAsResult's projection above, which deliberately
diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go
new file mode 100644
index 000000000..fe9146d14
--- /dev/null
+++ b/internal/engine/bracket_seat.go
@@ -0,0 +1,202 @@
+package engine
+
+import (
+ "log"
+ "slices"
+
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
+ "github.com/gitrgoliveira/bracket-creator/internal/state"
+)
+
+// seatBracketSide is the ONE writer of a knockout match's side name and id
+// once the match exists: every re-seat of a side after the draw (a winner
+// propagated into the next round, a semifinal loser into the 3rd-place match,
+// a slot cleared back to its "Winner of ..." placeholder, a pool qualifier
+// painted over a placeholder) goes through it. It owns the one consequence a
+// plain assignment misses: the representative a team match's rep bout holds
+// for a side is a member of the team that was seated there
+// (SubMatchResult.SideAMemberID/SideBMemberID, the state.GroupRepPickA and
+// state.GroupRepPickB groups), so a side given ANOTHER team takes its pick away.
+//
+// ReplaceParticipantInDraw, which renames a team while the competition is
+// draw-ready, writes a side through here when the side carries the team's id
+// (the same id under a new name: the same team, so no pick is cleared). A
+// representative bout CAN exist in a draw-ready competition: the add commits
+// the running match in its own transaction and starts the competition after
+// it, and a failed start is only logged. A side with no id (a legacy row) is
+// renamed there directly, and only the row's name follows it (nameRepBoutRow),
+// since this function would read an id-less old name to a new one as another
+// team. The match's Winner is written directly in both cases; it is not a side.
+//
+// INVARIANT: after every call -- a winner propagated, a slot cleared back to
+// "" or a "Winner of ..." placeholder, a pool qualifier painted, a rename
+// re-propagated -- the representative bout row's SideA/SideB for THIS side
+// EQUALS the match's SideA/SideB for that side, whatever the string is (a
+// placeholder or empty included: one rule, no special case). The hantei mark
+// is placed on the winner's side by comparing the winner against that row's
+// names (placeHt), so a row still naming the team that was taken out would
+// attribute the verdict to nobody. The name is written on EVERY call, before
+// anything is judged, so it follows a rename as well as a re-seat; it dates
+// nothing on its own (no group stamp, no ModifiedAt: a name write is not a
+// change of the bout, which is what lets a draw-time caller produce exactly
+// the bracket it always did). A side that kept its team and only has a new name
+// takes the row's Winner along when it named the side by the old name, the
+// row's own or the match's (nameRepBoutRow). A match with no row has nothing to
+// write.
+//
+// Whether the side was given ANOTHER team is judged by id when both the side as
+// stored and the incoming one carry an id, and by name otherwise
+// (seatedAnotherTeam):
+//
+// - Both ids set: another team exactly when they differ. A rename (the same
+// id under a new name), or a correction that stores the winner already
+// recorded and so re-propagates the same team, is not one: the name is
+// written and the pick stays.
+// - Either id empty: another team exactly when the names differ. That covers
+// a slot going back to a placeholder or to "" (the name differs from any
+// team's), a placeholder resolved to a team, and legacy id-less data,
+// where the client resolves an id-less side's team by name and so a pick
+// CAN sit on it. The same name with an id arriving (the side that carried
+// only its team's name gaining the team's id) is the same team, and so is
+// a writer that does not know the id (an incoming "" beside the name
+// already seated): neither clears the pick, and the stored id is never
+// thrown away for a writer that did not know it.
+//
+// This mirrors the client's rule (admin_scoring_team.jsx: a side is given
+// another team only when BOTH its lookup key and the team it resolves to
+// change; "a side that carried only its team's name gaining that team's id is
+// not one") and the carve-out BracketMatch has in CLAUDE.md (resolve by id when
+// present, fall back to the name only for an empty slot, an unresolved feeder
+// or a legacy row a repair has not reached).
+//
+// When the side was given another team, name and id are written, and a pick
+// held for THAT side is cleared. The other side's pick is never touched. With
+// no pick on that side, or no rep bout row at all, nothing else moves.
+//
+// The cleared pick is dated max(now, its previous stamp + 1, the match's
+// ModifiedAt) through StampGroups, which never lowers, and ONLY that side's
+// group is dated: the other side's pick keeps its date, so a pick made for it
+// on another device is still ordered by its own write. The date is strictly
+// above the date the pick held, so a replay of the write that made it is kept
+// in the match's history instead of seating the old team's member on the new
+// team's side, and never below the match. It is NOT the stamp of the correction
+// that caused the re-seat: that is a client stamp made against a different
+// match, and a pick written after the correction but before it arrived would
+// keep a stamp at or above it.
+//
+// It runs inside UpdateBracket callbacks, which hold no store handle, so it
+// writes the match in place and records no history line of its own (a played
+// downstream match gets one for its reopen, which names the group
+// (markRepPicksCleared, from the door's snapshot diff in bracket_seat_audit.go);
+// a scheduled one is logged here).
+func seatBracketSide(bm *state.BracketMatch, side domain.MatchSide, name, id string) {
+ var nameField, idField *string
+ switch side {
+ case domain.MatchSideA:
+ nameField, idField = &bm.SideA, &bm.SideAID
+ case domain.MatchSideB:
+ nameField, idField = &bm.SideB, &bm.SideBID
+ default:
+ log.Printf("engine: BUG: bracket match %s: seatBracketSide called with side %q; nothing written", bm.ID, side)
+ return
+ }
+ another := seatedAnotherTeam(*nameField, *idField, name, id)
+ matchOld := *nameField
+ *nameField = name
+ // An incoming "" beside the name already seated is a writer that did not
+ // know the id, not a removal: the id a repair resolved stays.
+ if id != "" || another {
+ *idField = id
+ }
+
+ row := state.DaihyosenSubIndex(bm.SubResults)
+ if row < 0 {
+ return
+ }
+ // The row's name follows the match's on every call, a rename included, and
+ // dates nothing (the invariant in the doc comment).
+ nameRepBoutRow(bm, row, side, name, matchOld, another)
+ if !another {
+ return
+ }
+ pickA, pickB := bm.RepPicks()
+ held := pickA
+ if side == domain.MatchSideB {
+ held = pickB
+ }
+ if held == "" {
+ return
+ }
+ bm.SetRepPick(side, "")
+ // The bout's winner id is derived from the picks and the winner's name, so
+ // it follows the pick that went.
+ bm.SubResults[row].ReconcileWinnerMemberID()
+ log.Printf("engine: bracket match %s: side %s was given another team, so its representative pick %q is cleared", bm.ID, side, held)
+ g := state.RepPickGroup(side)
+ bm.StampGroups(max(serverNowMs(), bm.GroupStamp(g)+1, bm.ModifiedAt), g)
+}
+
+// seatedAnotherTeam reports whether re-seating a side that is stored as
+// (storedName, storedID) to (name, id) gives it another team: by id when both
+// carry one, by name otherwise (the rule in seatBracketSide's doc comment).
+func seatedAnotherTeam(storedName, storedID, name, id string) bool {
+ if storedID != "" && id != "" {
+ return storedID != id
+ }
+ return storedName != name
+}
+
+// nameRepBoutRow writes name as side's name on the rep bout row at index row.
+// The list is replaced, never edited in place, so a row another copy of the
+// match shares is never written through (state.withRepPick does the same for a
+// pick); a name the row already has writes nothing and copies nothing, unless
+// its Winner still names the side by matchOld (the carry below). another
+// says the side was given another team: a side that kept its team (a rename)
+// also takes the row's Winner along when it names the side by the old name,
+// whether that is the row's own old side name or matchOld, the match's name for
+// the side before the rename. The match's counts because a row's side name can
+// be blank or stale while its Winner still reads as the match's: AddDaihyosen
+// stamps the names and adoptCurrentSideName only rewrites a row carrying the old
+// name, never fills a blank, but the merge lands a payload row's names as sent,
+// so a writer that does not restate them (rows written before the names rule,
+// the ones resolveBoutSideName's filter exists for; hand-edited data) leaves
+// them blank, and the rename would otherwise leave the Winner on the old team
+// name and the hantei credited to nobody.
+func nameRepBoutRow(bm *state.BracketMatch, row int, side domain.MatchSide, name, matchOld string, another bool) {
+ field := func(r *state.SubMatchResult) *string {
+ if side == domain.MatchSideA {
+ return &r.SideA
+ }
+ return &r.SideB
+ }
+ old := *field(&bm.SubResults[row])
+ // A side that kept its team and only has a new name keeps what the row
+ // decided: the row's Winner names that team by the name it was recorded
+ // under (the row's, or the match's), and the mark is placed by comparing
+ // the two. A side given another team leaves the Winner as it was, the
+ // decision of the team that left. It is worked out before the row's own
+ // name is compared with the new one, because a row can already read the new
+ // name while its Winner still reads the old (a writer that restated the
+ // rename on the row alone).
+ w := bm.SubResults[row].Winner
+ carry := !another && w != "" && w != name && (w == old || w == matchOld)
+ if old == name && !carry {
+ return
+ }
+ subs := slices.Clone(bm.SubResults)
+ *field(&subs[row]) = name
+ if carry {
+ subs[row].Winner = name
+ }
+ bm.SubResults = subs
+}
+
+// feedsSide is the side of the next match (and of the 3rd-place match, for a
+// semifinal loser) that the match at index mIdx of its round feeds: the
+// round's even matches feed side A, the odd ones side B.
+func feedsSide(mIdx int) domain.MatchSide {
+ if mIdx%2 == 0 {
+ return domain.MatchSideA
+ }
+ return domain.MatchSideB
+}
diff --git a/internal/engine/bracket_seat_audit.go b/internal/engine/bracket_seat_audit.go
new file mode 100644
index 000000000..299dcc93f
--- /dev/null
+++ b/internal/engine/bracket_seat_audit.go
@@ -0,0 +1,165 @@
+package engine
+
+import (
+ "slices"
+
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
+ "github.com/gitrgoliveira/bracket-creator/internal/state"
+)
+
+// The history is the record of a re-seat; the log line seatBracketSide prints is
+// only its trace.
+//
+// A write that gives a knockout side another team takes the representative the
+// old team's member held on that side with it (seatBracketSide, the one clearer).
+// A downstream match the same write REOPENS names the group on its reopen line
+// (ReopenedMatch.RepPicksCleared, restoreForceReopened). Every other match a
+// bracket door re-seats -- the scheduled or requeued next round, the bronze, a
+// round further on that a retraction unwinds into, a slot a requalification
+// repaint paints -- changed outside any score write of its own, so it records
+// its own line too (recordDirectHistory: "every direct writer appends its own,
+// INSIDE the transaction that makes its write"), or a pick would vanish with
+// nothing in the match's history to say when or why.
+//
+// The mechanism needs no change to the re-seating functions and so covers every
+// route to a re-seat, the byes a winner passes through included: a door takes
+// ONE snapshot of the bracket's picks before it mutates the bracket
+// (repPickSnapshot) and diffs it afterwards (repPickClears) while it still holds
+// the bracket. That diff is the only account of what a door took from a pick: a
+// reopen captures none of its own. markRepPicksCleared copies it onto the reopen
+// line of each match the same write reopened (ReopenedMatch.RepPicksCleared), and
+// recordRepPickClears records the rest beside the place that already records the
+// reopen lines, in the same transaction. Only clears are diffed: nothing a door
+// does inside its bracket callback sets a pick, and one a writer sets is that
+// write's own change, which the merge's history line names. A pick replaced by
+// another is not a clear.
+
+// clearedPick is one side's representative pick that a door's mutation took
+// out, with the stamp seatBracketSide gave the group when it did.
+type clearedPick struct {
+ Group string
+ Stamp int64
+}
+
+// repPickClear is the picks one match lost to a door's mutation.
+type repPickClear struct {
+ MatchID string
+ Cleared []clearedPick
+}
+
+// repPickSnapshot records, for every round match and the 3rd-place match, the
+// representatives it holds on side A and side B, leaving out a match that holds
+// none. own is the match the door itself writes: its pick groups are that
+// write's to name (the merge's per-group line), never a re-seat's, so it is left
+// out of the snapshot and so can never appear in the diff. "" for a door that
+// writes no bracket match of its own.
+func repPickSnapshot(b *state.Bracket, own string) map[string][2]string {
+ out := map[string][2]string{}
+ if b == nil {
+ return out
+ }
+ note := func(m *state.BracketMatch) {
+ if m.ID == own {
+ return
+ }
+ if a, c := m.RepPicks(); a != "" || c != "" {
+ out[m.ID] = [2]string{a, c}
+ }
+ }
+ for ri := range b.Rounds {
+ for mi := range b.Rounds[ri] {
+ note(&b.Rounds[ri][mi])
+ }
+ }
+ if b.ThirdPlaceMatch != nil {
+ note(b.ThirdPlaceMatch)
+ }
+ return out
+}
+
+// repPickClears lists, in bracket order, the picks of before that the bracket no
+// longer holds: a side whose pick was non-empty and is now empty or has no
+// bout row left to carry one. The stamp of each is read from the bracket now, so
+// the recorder needs no second read. A nil before (a door that records nothing,
+// e.g. the K3 rollback replaying a prior) yields nothing.
+func repPickClears(before map[string][2]string, b *state.Bracket) []repPickClear {
+ if len(before) == 0 || b == nil {
+ return nil
+ }
+ var out []repPickClear
+ diff := func(m *state.BracketMatch) {
+ was, ok := before[m.ID]
+ if !ok {
+ return
+ }
+ a, c := m.RepPicks()
+ now := [2]string{a, c}
+ lost := repPickClear{MatchID: m.ID}
+ for i, side := range [...]domain.MatchSide{domain.MatchSideA, domain.MatchSideB} {
+ if was[i] != "" && now[i] == "" {
+ g := state.RepPickGroup(side)
+ lost.Cleared = append(lost.Cleared, clearedPick{Group: g, Stamp: m.GroupStamp(g)})
+ }
+ }
+ if len(lost.Cleared) > 0 {
+ out = append(out, lost)
+ }
+ }
+ for ri := range b.Rounds {
+ for mi := range b.Rounds[ri] {
+ diff(&b.Rounds[ri][mi])
+ }
+ }
+ if b.ThirdPlaceMatch != nil {
+ diff(b.ThirdPlaceMatch)
+ }
+ return out
+}
+
+// markRepPicksCleared records, on every reopened match the diff names, the pick
+// group of each side whose representative the door's re-seats took
+// (ReopenedMatch.RepPicksCleared, side A before side B, as repPickClears lists
+// them). A door calls it once, right after taking clears, so its reopened
+// matches and its reseat lines are both judged from the one snapshot; a match
+// the diff does not name keeps a nil list. recordRepPickClears then leaves out
+// the groups a reopen line names.
+func markRepPicksCleared(reopened []ReopenedMatch, clears []repPickClear) {
+ for i := range reopened {
+ for _, c := range clears {
+ if c.MatchID != reopened[i].ID {
+ continue
+ }
+ for _, p := range c.Cleared {
+ reopened[i].RepPicksCleared = append(reopened[i].RepPicksCleared, p.Group)
+ }
+ }
+ }
+}
+
+// recordRepPickClears appends, for each match in clears, one history line with
+// door doorReseat naming the pick groups that went (state.RepPickGroup), applied,
+// stamped with the group's stamp after the mutation (the later of the two when a
+// match lost both). A group a reopen line in reopened already names is skipped:
+// that line is its record, and a second would claim the change twice. It is
+// called with the SAME handle the bracket write used, beside restoreForceReopened,
+// so the lines land with the write or not at all.
+func (e *Engine) recordRepPickClears(h state.StoreTx, compID string, clears []repPickClear, reopened []ReopenedMatch) {
+ for _, c := range clears {
+ var groups []string
+ var stamp int64
+ for _, p := range c.Cleared {
+ named := slices.ContainsFunc(reopened, func(r ReopenedMatch) bool {
+ return r.ID == c.MatchID && slices.Contains(r.RepPicksCleared, p.Group)
+ })
+ if named {
+ continue
+ }
+ groups = append(groups, p.Group)
+ stamp = max(stamp, p.Stamp)
+ }
+ if len(groups) == 0 {
+ continue
+ }
+ e.recordDirectHistory(h, compID, c.MatchID, doorReseat, stamp, groups...)
+ }
+}
diff --git a/internal/engine/bracket_seat_audit_test.go b/internal/engine/bracket_seat_audit_test.go
new file mode 100644
index 000000000..5a112a9a8
--- /dev/null
+++ b/internal/engine/bracket_seat_audit_test.go
@@ -0,0 +1,501 @@
+package engine
+
+// A bracket door that gives a knockout side another team takes the
+// representative pick the old team's member held on it (seatBracketSide). A
+// downstream match the write REOPENS names the pick on its reopen line; every
+// other match the write re-seats -- scheduled or requeued, the bronze, a round
+// further on that a retraction unwinds into, a slot a requalification repaint
+// paints -- used to get a log line and nothing in its history. These tests pin
+// that each such match gets exactly one `reseat` line naming the group, from
+// each door, and that a reopened match keeps ONLY its reopen line.
+
+import (
+ "slices"
+ "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 (
+ auHawk = "Hawk"
+ auHawkID = "44444444-4444-4444-8444-444444444444"
+)
+
+// auLines is the whole history of one match.
+func auLines(t *testing.T, store *state.Store, compID, matchID string) []state.MatchHistoryEntry {
+ t.Helper()
+ entries, err := store.LoadMatchHistory(compID, matchID)
+ require.NoError(t, err)
+ return entries
+}
+
+// auDoor is the entries of lines written through door.
+func auDoor(lines []state.MatchHistoryEntry, door string) []state.MatchHistoryEntry {
+ var out []state.MatchHistoryEntry
+ for _, l := range lines {
+ if l.Door == door {
+ out = append(out, l)
+ }
+ }
+ return out
+}
+
+// auRow is a representative bout row naming both teams and a pick for each.
+func auRow(nameA, nameB, pickA, pickB string) []state.SubMatchResult {
+ return []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: nameA, SideB: nameB,
+ SideAMemberID: pickA, SideBMemberID: pickB,
+ Decision: string(domain.DecisionDaihyosen),
+ }}
+}
+
+// auStamped dates a match's picks mmT1 and the match itself mmT2, as rpSetup
+// does, so a pick a re-seat takes is dated after the stamp it held.
+func auStamped(m state.BracketMatch) state.BracketMatch {
+ m.ModifiedAt = mmT2
+ m.GroupStamps = map[string]int64{
+ state.GroupRepPickA: mmT1,
+ state.GroupRepPickB: mmT1,
+ state.BoutGroup(state.DaihyosenSubPosition): mmT1,
+ state.GroupPoints: mmT2,
+ }
+ return m
+}
+
+func auSaveFourTeams(t *testing.T, store *state.Store, compID string) {
+ t.Helper()
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: compID, Kind: "team", TeamSize: 3, Status: state.CompStatusKnockout,
+ }))
+ 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"},
+ {ID: auHawkID, Name: auHawk, Dojo: "DojoH"},
+ }))
+}
+
+// auSemifinals stores a four-team knockout with a 3rd-place match: Ryu beat
+// Tora (m-r1-0) and Kuma beat Hawk (m-r1-1), so Ryu v Kuma is the final and
+// Tora v Hawk the bronze match. Both hold a representative bout with a pick on
+// each side.
+func auSemifinals(t *testing.T) (*Engine, *state.Store, string) {
+ t.Helper()
+ eng, store, _ := setupTestEngine(t)
+ const compID = "au-bronze"
+ auSaveFourTeams(t, store, compID)
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{
+ Rounds: [][]state.BracketMatch{
+ {
+ {ID: "m-r1-0", MatchNumber: 1, SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID,
+ Status: state.MatchStatusCompleted, Winner: wrTeamA, WinnerID: wrTeamAID,
+ SubResults: []state.SubMatchResult{wrBout1("M")}},
+ {ID: "m-r1-1", MatchNumber: 2, SideA: wrTeamC, SideAID: wrTeamCID, SideB: auHawk, SideBID: auHawkID,
+ Status: state.MatchStatusCompleted, Winner: wrTeamC, WinnerID: wrTeamCID},
+ },
+ {auStamped(state.BracketMatch{ID: "m-final", MatchNumber: 4,
+ SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamC, SideBID: wrTeamCID,
+ Status: state.MatchStatusScheduled, SubResults: auRow(wrTeamA, wrTeamC, "pick-ryu", "pick-kuma")})},
+ },
+ ThirdPlaceMatch: func() *state.BracketMatch {
+ m := auStamped(state.BracketMatch{ID: "m-bronze", MatchNumber: 3,
+ SideA: wrTeamB, SideAID: wrTeamBID, SideB: auHawk, SideBID: auHawkID,
+ Status: state.MatchStatusScheduled, SubResults: auRow(wrTeamB, auHawk, "pick-tora", "pick-hawk")})
+ return &m
+ }(),
+ }))
+ return eng, store, compID
+}
+
+// A scheduled downstream match a correction re-seats gets one `reseat` line
+// naming the side's group, dated with the stamp the re-seat gave it.
+func TestReseatAudit_ScheduledDownstreamGetsAReseatLine(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ require.Empty(t, auLines(t, store, compID, rpNextID), "no history before the write")
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamB), ForceOptions{})
+ return err
+ }))
+
+ next := rpNext(t, store, compID)
+ a, _ := next.RepPicks()
+ require.Empty(t, a, "the pick went with the team")
+ lines := auLines(t, store, compID, rpNextID)
+ require.Len(t, lines, 1, "exactly one line on the re-seated match")
+ assert.Equal(t, doorReseat, lines[0].Door)
+ assert.Equal(t, []string{state.GroupRepPickA}, lines[0].Changed, "side A only: side B was not re-seated")
+ assert.Equal(t, state.HistoryOutcomeApplied, lines[0].Outcomes[state.GroupRepPickA])
+ assert.Equal(t, next.GroupStamp(state.GroupRepPickA), lines[0].Stamp, "dated with the stamp the re-seat gave the group")
+ assert.Empty(t, lines[0].Held, "nothing was held")
+ assert.Empty(t, lines[0].Reason)
+}
+
+// A played downstream match the same write reopens keeps its reopen line as the
+// record of the pick, and gets no second line.
+func TestReseatAudit_PlayedDownstreamHasOnlyItsReopenLine(t *testing.T) {
+ eng, store, compID := rpSetup(t, true)
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamB), ForceOptions{Force: true})
+ return err
+ }))
+
+ lines := auLines(t, store, compID, rpNextID)
+ require.Len(t, lines, 1, "the reopen line, and nothing else")
+ assert.Equal(t, doorDownstreamReopen, lines[0].Door)
+ assert.Contains(t, lines[0].Changed, state.GroupRepPickA, "the reopen line names the pick that went")
+ assert.Empty(t, auDoor(lines, doorReseat))
+}
+
+// The 3rd-place match is re-seated by the same write as the final: each gets
+// its own line, for the side the semifinal feeds.
+func TestReseatAudit_TheBronzeAndTheFinalEachGetALine(t *testing.T) {
+ eng, store, compID := auSemifinals(t)
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamB), ForceOptions{})
+ return err
+ }))
+
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ for _, id := range []string{"m-final", "m-bronze"} {
+ m := b.MatchByID(id)
+ require.NotNil(t, m, id)
+ a, c := m.RepPicks()
+ assert.Empty(t, a, "%s: side A was given another team", id)
+ assert.NotEmpty(t, c, "%s: side B was not", id)
+
+ lines := auLines(t, store, compID, id)
+ require.Len(t, lines, 1, "%s: one line", id)
+ assert.Equal(t, doorReseat, lines[0].Door, id)
+ assert.Equal(t, []string{state.GroupRepPickA}, lines[0].Changed, id)
+ assert.Equal(t, m.GroupStamp(state.GroupRepPickA), lines[0].Stamp, id)
+ }
+ assert.Empty(t, auLines(t, store, compID, "m-r1-1"), "the other semifinal was not written")
+}
+
+// A round further on that the retraction of a reopened match unwinds into is
+// re-seated without being reopened: it gets a `reseat` line while the match
+// reopened beside it keeps only its reopen line.
+func TestReseatAudit_AMatchUnwoundBeyondTheReopenedOneGetsALine(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ const compID = "au-chain"
+ auSaveFourTeams(t, store, compID)
+ played := auStamped(state.BracketMatch{ID: "m-r2-0", MatchNumber: 3,
+ SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamC, SideBID: wrTeamCID,
+ Status: state.MatchStatusCompleted, Winner: wrTeamA, WinnerID: wrTeamAID,
+ SubResults: auRow(wrTeamA, wrTeamC, "pick-ryu", "pick-kuma")})
+ final := auStamped(state.BracketMatch{ID: "m-r3-0", MatchNumber: 4,
+ SideA: wrTeamA, SideAID: wrTeamAID, SideB: auHawk, SideBID: auHawkID,
+ Status: state.MatchStatusScheduled, SubResults: auRow(wrTeamA, auHawk, "pick-ryu-final", "pick-hawk")})
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{
+ {
+ {ID: "m-r1-0", MatchNumber: 1, SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID,
+ Status: state.MatchStatusCompleted, Winner: wrTeamA, WinnerID: wrTeamAID,
+ SubResults: []state.SubMatchResult{wrBout1("M")}},
+ {ID: "m-r1-1", MatchNumber: 2, SideA: wrTeamC, SideAID: wrTeamCID,
+ Status: state.MatchStatusCompleted, Winner: wrTeamC, WinnerID: wrTeamCID},
+ },
+ {played},
+ {final},
+ }}))
+
+ var reopened []ReopenedMatch
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamB),
+ ForceOptions{Force: true, Reopened: &reopened})
+ return err
+ }))
+ require.Equal(t, []string{"m-r2-0"}, reopenedIDs(reopened), "only the played match is reopened")
+
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ a, c := b.MatchByID("m-r3-0").RepPicks()
+ require.Empty(t, a, "the slot went back to a placeholder, and the pick with it")
+ require.Equal(t, "pick-hawk", c)
+
+ finalLines := auLines(t, store, compID, "m-r3-0")
+ require.Len(t, finalLines, 1)
+ assert.Equal(t, doorReseat, finalLines[0].Door)
+ assert.Equal(t, []string{state.GroupRepPickA}, finalLines[0].Changed)
+
+ playedLines := auLines(t, store, compID, "m-r2-0")
+ require.Len(t, playedLines, 1, "the reopened match keeps its reopen line alone")
+ assert.Equal(t, doorDownstreamReopen, playedLines[0].Door)
+ assert.Contains(t, playedLines[0].Changed, state.GroupRepPickA)
+}
+
+func TestReseatAudit_OverrideDoorRecordsTheLine(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+
+ applied, err := eng.OverrideBracketWinner(compID, "m-r1-0", wrTeamB, 0)
+ require.NoError(t, err)
+ require.True(t, applied)
+
+ next := rpNext(t, store, compID)
+ lines := auLines(t, store, compID, rpNextID)
+ require.Len(t, lines, 1)
+ assert.Equal(t, doorReseat, lines[0].Door)
+ assert.Equal(t, []string{state.GroupRepPickA}, lines[0].Changed)
+ assert.Equal(t, next.GroupStamp(state.GroupRepPickA), lines[0].Stamp)
+}
+
+// The decision door reaches the bracket through the same primitive as a score,
+// so a withdrawal that turns the winner re-seats the next round the same way.
+func TestReseatAudit_DecisionDoorRecordsTheLine(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+
+ _, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "aka", "illness", nil)
+ require.NoError(t, err)
+
+ next := rpNext(t, store, compID)
+ require.Equal(t, wrTeamB, next.SideA, "Tora advanced in place of the team that withdrew")
+ a, _ := next.RepPicks()
+ require.Empty(t, a)
+ lines := auLines(t, store, compID, rpNextID)
+ require.Len(t, lines, 1)
+ assert.Equal(t, doorReseat, lines[0].Door)
+ assert.Equal(t, []string{state.GroupRepPickA}, lines[0].Changed)
+}
+
+// An engi pair never holds a representative bout, so the picks here are built by
+// hand: the door's wiring is what is pinned, the same as the others'.
+func TestReseatAudit_EngiDoorRecordsTheLine(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ const compID = "au-engi"
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: "Engi Knockout", Kind: "individual",
+ Format: state.CompFormatKnockout, PoolSize: 3, PoolWinners: 2,
+ Courts: []string{"A"}, StartTime: "09:00", Status: "setup", Engi: true,
+ }))
+ saveTestParticipants(t, store, compID, []string{"Alice", "Bob", "Charlie", "Dave"})
+ require.NoError(t, eng.StartCompetition(compID))
+
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ last := len(b.Rounds) - 1
+ sf0, sf1, finalID := b.Rounds[last-1][0].ID, b.Rounds[last-1][1].ID, b.Rounds[last][0].ID
+ _, err = eng.recordEngiMatchResult(store, compID, sf0, 3, 2, "")
+ require.NoError(t, err)
+ _, err = eng.recordEngiMatchResult(store, compID, sf1, 3, 2, "")
+ require.NoError(t, err)
+
+ b, err = store.LoadBracket(compID)
+ require.NoError(t, err)
+ final := &b.Rounds[last][0]
+ final.SubResults = auRow(final.SideA, final.SideB, "pick-a", "pick-b")
+ require.NoError(t, store.SaveBracket(compID, b))
+ require.Empty(t, auLines(t, store, compID, finalID))
+
+ _, err = eng.recordEngiMatchResult(store, compID, sf0, 2, 3, "correction")
+ require.NoError(t, err)
+
+ got, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ a, c := got.Rounds[last][0].RepPicks()
+ require.Empty(t, a, "the final's side A was given the other pair")
+ require.Equal(t, "pick-b", c)
+ lines := auLines(t, store, compID, finalID)
+ require.Len(t, lines, 1)
+ assert.Equal(t, doorReseat, lines[0].Door)
+ assert.Equal(t, []string{state.GroupRepPickA}, lines[0].Changed)
+}
+
+// A reopen retracts the winner it had advanced: the slot goes back to a
+// placeholder, which is a re-seat too.
+func TestReseatAudit_ReopenDoorRecordsTheLine(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ first := b.MatchByID("m-r1-0")
+ first.Decision, first.DecisionBy = string(domain.DecisionKikenVoluntary), "B"
+ require.NoError(t, store.SaveBracket(compID, b))
+
+ _, err = eng.ReopenMatch(compID, "m-r1-0", "wrong waza")
+ require.NoError(t, err)
+
+ next := rpNext(t, store, compID)
+ a, _ := next.RepPicks()
+ require.Empty(t, a)
+ lines := auLines(t, store, compID, rpNextID)
+ require.Len(t, lines, 1)
+ assert.Equal(t, doorReseat, lines[0].Door)
+ assert.Equal(t, []string{state.GroupRepPickA}, lines[0].Changed)
+ assert.Equal(t, next.GroupStamp(state.GroupRepPickA), lines[0].Stamp)
+}
+
+// A pool correction that moves a qualifier repaints the knockout slots it
+// filled; a repainted match nobody has played gets a `reseat` line.
+func TestReseatAudit_RequalificationRepaintRecordsTheLine(t *testing.T) {
+ f := newRQFixture(t, "au-rq", 2, [][]string{{"A1", "A2"}, {"B1", "B2"}})
+ f.scorePool("Pool A-0", "A1")
+ f.scorePool("Pool B-0", "B1")
+ f.resolve()
+ first, firstSide := f.slot("Pool A-1st")
+ second, secondSide := f.slot("Pool A-2nd")
+ require.NotEqual(t, first.ID, second.ID, "the two Pool A places sit in different matches")
+
+ group := func(side string) string {
+ if side == "A" {
+ return state.GroupRepPickA
+ }
+ return state.GroupRepPickB
+ }
+ b := f.bracket()
+ for _, p := range []struct{ id, side, pick string }{
+ {first.ID, firstSide, "pick-a1"},
+ {second.ID, secondSide, "pick-a2"},
+ } {
+ row := state.SubMatchResult{Position: state.DaihyosenSubPosition}
+ if p.side == "A" {
+ row.SideAMemberID = p.pick
+ } else {
+ row.SideBMemberID = p.pick
+ }
+ findBracketMatchInBracket(b, p.id).SubResults = []state.SubMatchResult{row}
+ }
+ require.NoError(t, f.store.SaveBracket(f.compID, b))
+
+ // A2 now wins the pool: nothing is played in the knockout, so no
+ // confirmation is asked and both places are repainted.
+ require.NoError(t, f.write("Pool A-0", f.poolResult("Pool A-0", "A2")))
+
+ after := f.bracket()
+ for _, p := range []struct{ id, side string }{{first.ID, firstSide}, {second.ID, secondSide}} {
+ m := findBracketMatchInBracket(after, p.id)
+ a, c := m.RepPicks()
+ assert.Empty(t, a+c, "%s: the repaint took the pick of the team that left the slot", p.id)
+ lines := auLines(t, f.store, f.compID, p.id)
+ reseats := auDoor(lines, doorReseat)
+ require.Len(t, reseats, 1, "%s: one reseat line", p.id)
+ assert.Equal(t, []string{group(p.side)}, reseats[0].Changed, p.id)
+ assert.Equal(t, m.GroupStamp(group(p.side)), reseats[0].Stamp, p.id)
+ }
+}
+
+// A correction that stores the winner already recorded re-seats nothing.
+func TestReseatAudit_SameWinnerLeavesNoLine(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", rpCorrect(wrTeamA), ForceOptions{})
+ return err
+ }))
+
+ assert.Empty(t, auLines(t, store, compID, rpNextID), "nothing was re-seated, so nothing is recorded")
+}
+
+// The K3 rollback replays the prior result through the same bracket callback
+// under matchWriteRestore. That re-seats the next round's side again, but a
+// restore is not a door: it records no line.
+func TestReseatAudit_ARestoreIsNotADoor(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ // The stored state a rejected write left: Tora advanced and holds a pick.
+ prior := bracketMatchAsResult(b.MatchByID("m-r1-0"))
+ first := b.MatchByID("m-r1-0")
+ first.Winner, first.WinnerID = wrTeamB, wrTeamBID
+ next := b.MatchByID(rpNextID)
+ next.SideA, next.SideAID = wrTeamB, wrTeamBID
+ next.SubResults[0].SideA, next.SubResults[0].SideAMemberID = wrTeamB, "pick-tora"
+ require.NoError(t, store.SaveBracket(compID, b))
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ eng.rollbackMatchResultTx(tx, compID, "m-r1-0", prior)
+ return nil
+ }))
+
+ got := rpNext(t, store, compID)
+ assert.Equal(t, wrTeamA, got.SideA, "the restore put the prior winner back")
+ a, _ := got.RepPicks()
+ assert.Empty(t, a, "and the pick of the team that left went with the re-seat")
+ assert.Empty(t, auLines(t, store, compID, rpNextID), "but a restore is not a door and records no line")
+}
+
+// The recorder skips the groups a reopen line already names and only those: a
+// reopened match whose reopen line does not name a group that went still gets
+// the reseat line for it.
+func TestReseatAudit_RecorderSkipsOnlyWhatAReopenLineNames(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ clears := []repPickClear{{MatchID: rpNextID, Cleared: []clearedPick{
+ {Group: state.GroupRepPickA, Stamp: 5},
+ {Group: state.GroupRepPickB, Stamp: 9},
+ }}}
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ eng.recordRepPickClears(tx, compID, clears, []ReopenedMatch{{ID: rpNextID, RepPicksCleared: []string{state.GroupRepPickA}}})
+ return nil
+ }))
+ lines := auLines(t, store, compID, rpNextID)
+ require.Len(t, lines, 1)
+ assert.Equal(t, []string{state.GroupRepPickB}, lines[0].Changed, "side A is the reopen line's, side B is not")
+ assert.Equal(t, int64(9), lines[0].Stamp, "dated with the group the line names")
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ eng.recordRepPickClears(tx, compID, clears, []ReopenedMatch{{ID: rpNextID, RepPicksCleared: []string{state.GroupRepPickA, state.GroupRepPickB}}})
+ return nil
+ }))
+ assert.Len(t, auLines(t, store, compID, rpNextID), 1, "every group named by the reopen line: no further line")
+
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ eng.recordRepPickClears(tx, compID, clears, []ReopenedMatch{{ID: "another-match", RepPicksCleared: []string{state.GroupRepPickA, state.GroupRepPickB}}})
+ return nil
+ }))
+ both := auLines(t, store, compID, rpNextID)
+ require.Len(t, both, 2, "a reopen line on another match names nothing here")
+ assert.ElementsMatch(t, []string{state.GroupRepPickA, state.GroupRepPickB}, both[1].Changed)
+ assert.Equal(t, int64(9), both[1].Stamp, "a match that lost both is dated with the later of the two")
+}
+
+// The snapshot leaves out the match the door itself writes and any match with
+// no pick, and the diff lists only a pick that is gone.
+func TestReseatAudit_SnapshotAndDiff(t *testing.T) {
+ picked := func(id, a, c string) state.BracketMatch {
+ return state.BracketMatch{ID: id, SubResults: auRow("A", "B", a, c)}
+ }
+ build := func() *state.Bracket {
+ return &state.Bracket{
+ Rounds: [][]state.BracketMatch{
+ {picked("own", "own-a", "own-b"), picked("one", "p1", "p2")},
+ {picked("two", "p3", ""), {ID: "bare"}},
+ },
+ ThirdPlaceMatch: func() *state.BracketMatch { m := picked("bronze", "", "p4"); return &m }(),
+ }
+ }
+ b := build()
+ snap := repPickSnapshot(b, "own")
+ assert.Equal(t, map[string][2]string{
+ "one": {"p1", "p2"}, "two": {"p3", ""}, "bronze": {"", "p4"},
+ }, snap, "the written match and a match with no pick are not in it")
+ assert.Contains(t, repPickSnapshot(b, ""), "own")
+ assert.Empty(t, repPickSnapshot(nil, ""))
+
+ assert.Empty(t, repPickClears(snap, b), "nothing changed")
+ assert.Empty(t, repPickClears(nil, b), "no snapshot, no diff")
+
+ b.Rounds[0][1].SetRepPick(domain.MatchSideB, "")
+ b.Rounds[1][0].SubResults = nil // the whole row went
+ b.ThirdPlaceMatch.SetRepPick(domain.MatchSideB, "p9")
+ b.Rounds[0][0].SetRepPick(domain.MatchSideA, "")
+ got := repPickClears(snap, b)
+ require.Len(t, got, 2, "a pick replaced by another is not a clear; the written match is not diffed")
+ assert.Equal(t, "one", got[0].MatchID)
+ assert.Equal(t, []string{state.GroupRepPickB}, groupsOf(got[0]))
+ assert.Equal(t, "two", got[1].MatchID)
+ assert.Equal(t, []string{state.GroupRepPickA}, groupsOf(got[1]))
+}
+
+func groupsOf(c repPickClear) []string {
+ var out []string
+ for _, p := range c.Cleared {
+ out = append(out, p.Group)
+ }
+ slices.Sort(out)
+ return out
+}
diff --git a/internal/engine/bracket_seat_test.go b/internal/engine/bracket_seat_test.go
new file mode 100644
index 000000000..035733bbc
--- /dev/null
+++ b/internal/engine/bracket_seat_test.go
@@ -0,0 +1,751 @@
+package engine
+
+// seatBracketSide's contract, and every writer that routes through it:
+// propagateBracketWinner (next match and 3rd-place match), clearPropagatedSlots
+// (reached by a reopen) and resolveSlots' qualifier paint (reached by a pool
+// requalification). The doors that carry a score correction or an override are
+// in bracket_reseat_rep_picks_test.go.
+
+import (
+ "slices"
+ "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"
+)
+
+// seatFixture is a match whose rep bout holds a pick on each side, dated 400,
+// while the match itself was last written at 500.
+func seatFixture() *state.BracketMatch {
+ return &state.BracketMatch{
+ ID: "m",
+ SideA: "Ryu", SideAID: "a", SideB: "Kuma", SideBID: "c",
+ ModifiedAt: 500,
+ GroupStamps: map[string]int64{
+ state.GroupRepPickA: 400,
+ state.GroupRepPickB: 400,
+ state.BoutGroup(state.DaihyosenSubPosition): 400,
+ state.GroupPoints: 500,
+ },
+ SubResults: []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: "Ryu", SideB: "Kuma",
+ SideAMemberID: "pa", SideBMemberID: "pc",
+ Winner: "Ryu", WinnerMemberID: "pa",
+ }},
+ }
+}
+
+func TestSeatBracketSide(t *testing.T) {
+ t.Run("the same team under another name keeps the pick and every stamp", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideA, "Ryu II", "a")
+ assert.Equal(t, "Ryu II", bm.SideA)
+ assert.Equal(t, "a", bm.SideAID)
+ a, c := bm.RepPicks()
+ assert.Equal(t, "pa", a)
+ assert.Equal(t, "pc", c)
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickB))
+ assert.Equal(t, int64(400), bm.GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), "a name write dates nothing")
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ assert.Equal(t, "Ryu II", bm.SubResults[0].SideA, "the row follows the rename")
+ assert.Equal(t, "Kuma", bm.SubResults[0].SideB, "the other side's name is not touched")
+ assert.Equal(t, "pa", bm.SubResults[0].WinnerMemberID, "a rename keeps the pick and the id that rides on it")
+ })
+
+ t.Run("another team on side A clears that pick only, and the winner id that rode on it", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideA, "Tora", "b")
+ assert.Equal(t, "Tora", bm.SideA)
+ assert.Equal(t, "b", bm.SideAID)
+ assert.Equal(t, "Tora", bm.SubResults[0].SideA, "the row names the team now seated")
+ assert.Equal(t, "Kuma", bm.SubResults[0].SideB, "the other side's name is not touched")
+ a, c := bm.RepPicks()
+ assert.Empty(t, a)
+ assert.Equal(t, "pc", c, "the other side was not re-seated")
+ assert.Empty(t, bm.SubResults[0].WinnerMemberID, "the winner id is derived from the picks that stand")
+ stamp := bm.GroupStamp(state.GroupRepPickA)
+ assert.Greater(t, stamp, int64(400))
+ assert.GreaterOrEqual(t, stamp, int64(500))
+ assert.Equal(t, stamp, bm.ModifiedAt, "ModifiedAt is the newest group stamp")
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickB), "the other side's pick keeps its date")
+ assert.Equal(t, int64(400), bm.GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), "the bout row is not what changed")
+ })
+
+ t.Run("another team on side B clears that pick only", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideB, "Tora", "b")
+ assert.Equal(t, "Tora", bm.SubResults[0].SideB, "the row names the team now seated")
+ assert.Equal(t, "Ryu", bm.SubResults[0].SideA, "the other side's name is not touched")
+ a, c := bm.RepPicks()
+ assert.Equal(t, "pa", a)
+ assert.Empty(t, c)
+ assert.Equal(t, "pa", bm.SubResults[0].WinnerMemberID, "the winner's pick stands")
+ assert.Greater(t, bm.GroupStamp(state.GroupRepPickB), int64(400))
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA), "the other side's pick keeps its date")
+ })
+
+ t.Run("a slot going back to a placeholder clears the pick", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideA, "Winner of r1-m0", "")
+ assert.Equal(t, "Winner of r1-m0", bm.SideA)
+ assert.Empty(t, bm.SideAID)
+ assert.Equal(t, "Winner of r1-m0", bm.SubResults[0].SideA, "the row names whatever the match does, a placeholder included")
+ a, _ := bm.RepPicks()
+ assert.Empty(t, a)
+ })
+
+ t.Run("a slot cleared to nothing empties the row's name too, and clears the pick", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideB, "", "")
+ assert.Empty(t, bm.SideB)
+ assert.Empty(t, bm.SideBID)
+ assert.Empty(t, bm.SubResults[0].SideB, "one rule, no special case for the empty string")
+ _, c := bm.RepPicks()
+ assert.Empty(t, c)
+ assert.Equal(t, "Ryu", bm.SubResults[0].SideA)
+ })
+
+ t.Run("the cleared pick is dated above a stamp ahead of the server clock", func(t *testing.T) {
+ bm := seatFixture()
+ ahead := serverNowMs() + 3_600_000
+ bm.GroupStamps[state.GroupRepPickA] = ahead
+ bm.ModifiedAt = ahead
+ seatBracketSide(bm, domain.MatchSideA, "Tora", "b")
+ assert.Equal(t, ahead+1, bm.GroupStamp(state.GroupRepPickA))
+ })
+
+ t.Run("a placeholder resolved to a team has no pick to lose and changes nothing else", func(t *testing.T) {
+ bm := seatFixture()
+ bm.SideA, bm.SideAID = "Winner of r1-m0", ""
+ bm.SubResults[0].SideAMemberID, bm.SubResults[0].WinnerMemberID = "", ""
+ seatBracketSide(bm, domain.MatchSideA, "Ryu", "a")
+ assert.Equal(t, "Ryu", bm.SideA)
+ assert.Equal(t, "a", bm.SideAID)
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickB))
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ })
+
+ t.Run("a side with no pick leaves the other side's pick and every stamp", func(t *testing.T) {
+ bm := seatFixture()
+ bm.SubResults[0].SideAMemberID = ""
+ seatBracketSide(bm, domain.MatchSideA, "Tora", "b")
+ _, c := bm.RepPicks()
+ assert.Equal(t, "pc", c)
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickB))
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ })
+
+ t.Run("a match with no rep bout row gains no stamp", func(t *testing.T) {
+ bm := seatFixture()
+ bm.SubResults = nil
+ bm.GroupStamps = nil
+ seatBracketSide(bm, domain.MatchSideA, "Tora", "b")
+ assert.Equal(t, "b", bm.SideAID)
+ assert.Nil(t, bm.GroupStamps)
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ })
+
+ t.Run("the row another copy of the match shares is not written through", func(t *testing.T) {
+ bm := seatFixture()
+ shared := bm.SubResults
+ seatBracketSide(bm, domain.MatchSideA, "Tora", "b")
+ assert.Equal(t, "pa", shared[0].SideAMemberID)
+ assert.Equal(t, "pa", shared[0].WinnerMemberID)
+ assert.Equal(t, "Ryu", shared[0].SideA, "nor is the name the re-seat writes")
+ })
+
+ t.Run("the row another copy of the match shares is not written through by a rename either", func(t *testing.T) {
+ bm := seatFixture()
+ shared := bm.SubResults
+ seatBracketSide(bm, domain.MatchSideA, "Ryu II", "a")
+ assert.Equal(t, "Ryu", shared[0].SideA)
+ assert.Equal(t, "Ryu II", bm.SubResults[0].SideA)
+ })
+
+ t.Run("a name that already matches the row leaves the row's list alone", func(t *testing.T) {
+ bm := seatFixture()
+ before := &bm.SubResults[0]
+ seatBracketSide(bm, domain.MatchSideA, "Ryu", "a")
+ assert.Same(t, before, &bm.SubResults[0], "no copy when there is nothing to write")
+ })
+
+ // "Given another team" is judged by id when both sides have one, by name
+ // otherwise (the client's rule in admin_scoring_team.jsx: another team only
+ // when BOTH the side's lookup key and the resolved team changed). The cases
+ // below are legacy, id-less data; the id comparison alone got both wrong.
+ t.Run("an id-less side re-seated under another name is another team: the pick goes", func(t *testing.T) {
+ bm := seatFixture()
+ bm.SideAID = ""
+ seatBracketSide(bm, domain.MatchSideA, "Tora", "")
+ assert.Equal(t, "Tora", bm.SideA)
+ assert.Empty(t, bm.SideAID)
+ assert.Equal(t, "Tora", bm.SubResults[0].SideA)
+ a, c := bm.RepPicks()
+ assert.Empty(t, a, "the team changed although neither side has an id")
+ assert.Equal(t, "pc", c)
+ assert.Greater(t, bm.GroupStamp(state.GroupRepPickA), int64(400))
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickB))
+ })
+
+ t.Run("an id-less side re-seated under the same name keeps the pick and every stamp", func(t *testing.T) {
+ bm := seatFixture()
+ bm.SideAID = ""
+ seatBracketSide(bm, domain.MatchSideA, "Ryu", "")
+ a, _ := bm.RepPicks()
+ assert.Equal(t, "pa", a)
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ })
+
+ t.Run("a writer that does not know the id, naming the same team, neither clears the pick nor blanks the id", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideA, "Ryu", "")
+ assert.Equal(t, "Ryu", bm.SideA)
+ assert.Equal(t, "a", bm.SideAID, "a resolved id is never thrown away for a writer that did not know it")
+ a, _ := bm.RepPicks()
+ assert.Equal(t, "pa", a)
+ assert.Equal(t, "pa", bm.SubResults[0].WinnerMemberID)
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickB))
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ })
+
+ t.Run("a side that carried only its team's name gaining the team's id is not another team", func(t *testing.T) {
+ bm := seatFixture()
+ bm.SideAID = ""
+ seatBracketSide(bm, domain.MatchSideA, "Ryu", "a")
+ assert.Equal(t, "a", bm.SideAID, "the id is written")
+ a, _ := bm.RepPicks()
+ assert.Equal(t, "pa", a, "the pick stays")
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ })
+
+ t.Run("an id-less placeholder resolved to a team is another team, with nothing to clear", func(t *testing.T) {
+ bm := seatFixture()
+ bm.SideA, bm.SideAID = "Winner of r1-m2", ""
+ bm.SubResults[0].SideA = "Winner of r1-m2"
+ bm.SubResults[0].SideAMemberID, bm.SubResults[0].WinnerMemberID = "", ""
+ seatBracketSide(bm, domain.MatchSideA, "Ryu", "a")
+ assert.Equal(t, "Ryu", bm.SideA)
+ assert.Equal(t, "a", bm.SideAID)
+ assert.Equal(t, "Ryu", bm.SubResults[0].SideA, "the row follows the match from the placeholder to the team")
+ assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPickA), "no pick, so nothing is dated")
+ assert.Equal(t, int64(500), bm.ModifiedAt)
+ })
+
+ t.Run("two different ids are another team whatever the names say", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideA, "Ryu", "b")
+ assert.Equal(t, "b", bm.SideAID)
+ a, _ := bm.RepPicks()
+ assert.Empty(t, a)
+ })
+
+ t.Run("a stored id and an empty one under another name is another team: the id goes with the pick", func(t *testing.T) {
+ bm := seatFixture()
+ seatBracketSide(bm, domain.MatchSideA, "Winner of r1-m2", "")
+ assert.Empty(t, bm.SideAID)
+ a, _ := bm.RepPicks()
+ assert.Empty(t, a)
+ })
+}
+
+// A semifinal re-scored: the final's and the 3rd-place match's sides both get
+// another team, and each loses the pick it held for the team that left it.
+func TestPropagateBracketWinner_ReseatsTheNextAndTheBronzeSidesAndTheirPicks(t *testing.T) {
+ eng, _, _ := setupTestEngine(t)
+ row := func(nameA, nameB, a, b string) []state.SubMatchResult {
+ return []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: nameA, SideB: nameB,
+ SideAMemberID: a, SideBMemberID: b,
+ }}
+ }
+ bracket := &state.Bracket{
+ Rounds: [][]state.BracketMatch{
+ {
+ {ID: "sf0", SideA: "Ryu", SideAID: "a", SideB: "Tora", SideBID: "b",
+ Status: state.MatchStatusCompleted, Winner: "Ryu", WinnerID: "a"},
+ {ID: "sf1", SideA: "Kuma", SideAID: "c", SideB: "Hawk", SideBID: "d",
+ Status: state.MatchStatusCompleted, Winner: "Kuma", WinnerID: "c"},
+ },
+ {
+ {ID: "final", SideA: "Tora", SideAID: "b", SideB: "Kuma", SideBID: "c",
+ Status: state.MatchStatusScheduled, SubResults: row("Tora", "Kuma", "pb", "pk")},
+ },
+ },
+ ThirdPlaceMatch: &state.BracketMatch{
+ ID: "bronze", SideA: "Ryu", SideAID: "a", SideB: "Hawk", SideBID: "d",
+ Status: state.MatchStatusScheduled, SubResults: row("Ryu", "Hawk", "pa", "ph"),
+ },
+ }
+
+ eng.propagateBracketWinner(bracket, 0, 0)
+
+ final := bracket.Rounds[1][0]
+ assert.Equal(t, "Ryu", final.SideA)
+ fa, fb := final.RepPicks()
+ assert.Empty(t, fa, "Tora left the final's side A")
+ assert.Equal(t, "pk", fb, "Kuma did not")
+ assert.Equal(t, final.SideA, final.SubResults[0].SideA, "the final's row names the team now seated")
+ assert.Equal(t, final.SideB, final.SubResults[0].SideB, "and the side that was not re-seated")
+ bronze := bracket.ThirdPlaceMatch
+ assert.Equal(t, "Tora", bronze.SideA)
+ ba, bb := bronze.RepPicks()
+ assert.Empty(t, ba, "Ryu left the bronze match's side A")
+ assert.Equal(t, "ph", bb, "Hawk did not")
+ assert.Equal(t, bronze.SideA, bronze.SubResults[0].SideA, "the bronze match's row names the team now seated")
+ assert.Equal(t, bronze.SideB, bronze.SubResults[0].SideB)
+}
+
+// A reopen retracts the winner it had sent on: the slot goes back to its
+// "Winner of" placeholder, and the pick the advanced team's member held goes
+// with it.
+func TestReopenMatch_RetractionTakesThePickOfTheClearedSlot(t *testing.T) {
+ // A reopen is for a match a withdrawal decided: Tora withdrew from m-r1-0.
+ withdrawn := func(t *testing.T, store *state.Store, compID string) {
+ t.Helper()
+ b, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ first := b.MatchByID("m-r1-0")
+ first.Decision, first.DecisionBy = string(domain.DecisionKikenVoluntary), "B"
+ require.NoError(t, store.SaveBracket(compID, b))
+ }
+ t.Run("a match nobody has played", func(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ withdrawn(t, store, compID)
+ prior := rpNext(t, store, compID)
+
+ _, err := eng.ReopenMatch(compID, "m-r1-0", "wrong waza")
+ require.NoError(t, err)
+
+ next := rpNext(t, store, compID)
+ assert.Empty(t, next.SideAID, "the slot is a placeholder again")
+ assert.NotEqual(t, wrTeamA, next.SideA)
+ require.GreaterOrEqual(t, state.DaihyosenSubIndex(next.SubResults), 0)
+ assert.Equal(t, next.SideA, next.SubResults[state.DaihyosenSubIndex(next.SubResults)].SideA, "the row names the placeholder the slot went back to")
+ a, c := next.RepPicks()
+ assert.Empty(t, a)
+ assert.Equal(t, rpPickC, c)
+ assert.Greater(t, next.GroupStamp(state.GroupRepPickA), mmT1)
+ assert.GreaterOrEqual(t, next.GroupStamp(state.GroupRepPickA), prior.ModifiedAt)
+ assert.Equal(t, mmT1, next.GroupStamp(state.GroupRepPickB), "side B's pick keeps its date")
+ })
+
+ t.Run("a match already played and reopened with it", func(t *testing.T) {
+ eng, store, compID := rpSetup(t, true)
+ withdrawn(t, store, compID)
+ var reopened []ReopenedMatch
+ _, err := eng.ReopenMatch(compID, "m-r1-0", "wrong waza", ForceOptions{Force: true, Reopened: &reopened})
+ require.NoError(t, err)
+ require.Equal(t, []string{rpNextID}, reopenedIDs(reopened))
+ assert.Equal(t, []string{state.GroupRepPickA}, reopened[0].RepPicksCleared, "judged after the retraction, which comes after the reopen")
+
+ a, c := func() (string, string) { n := rpNext(t, store, compID); return n.RepPicks() }()
+ assert.Empty(t, a)
+ assert.Equal(t, rpPickC, c)
+ entries, err := store.LoadMatchHistory(compID, rpNextID)
+ require.NoError(t, err)
+ var named, namedB bool
+ for _, e := range entries {
+ if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, state.GroupRepPickA) {
+ named = true
+ }
+ if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, state.GroupRepPickB) {
+ namedB = true
+ }
+ }
+ assert.True(t, named, "the reopen line names the cleared side's pick group")
+ assert.False(t, namedB, "and not the side that kept its pick")
+ })
+}
+
+// The qualifier paint gives a match's side another team: its pick goes. A
+// match that is running, or has its own result, is not repainted at all, so
+// its picks stay (the side is not changed).
+func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T) {
+ build := func(status state.MatchStatus) *state.Bracket {
+ return &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: "m", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-1st",
+ SideA: "Ryu", SideAID: "a", SideB: "Tora", SideBID: "b",
+ Status: status, ModifiedAt: 500,
+ GroupStamps: map[string]int64{state.GroupRepPickA: 400, state.GroupRepPickB: 400, state.BoutGroup(state.DaihyosenSubPosition): 400},
+ SubResults: []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: "Ryu", SideB: "Tora",
+ SideAMemberID: "pa", SideBMemberID: "pb",
+ }},
+ }}}}
+ }
+ resolver := map[string]resolvedFinisher{
+ "Pool A-1st": {Name: "Kuma", ID: "c"}, // Pool A's winner changed
+ "Pool B-1st": {Name: "Tora", ID: "b"}, // Pool B's did not
+ }
+
+ t.Run("scheduled", func(t *testing.T) {
+ eng, _, _ := setupTestEngine(t)
+ bracket := build(state.MatchStatusScheduled)
+ eng.resolveSlots(bracket, resolver)
+ m := bracket.Rounds[0][0]
+ assert.Equal(t, "Kuma", m.SideA)
+ assert.Equal(t, "c", m.SideAID)
+ a, b := m.RepPicks()
+ assert.Empty(t, a, "side A was given another team")
+ assert.Equal(t, "pb", b, "side B was not")
+ assert.Equal(t, "Kuma", m.SubResults[0].SideA, "the row names the competitor painted over the placeholder")
+ assert.Equal(t, "Tora", m.SubResults[0].SideB)
+ assert.Greater(t, m.GroupStamp(state.GroupRepPickA), int64(400))
+ assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPickB), "side B's pick keeps its date")
+ })
+
+ t.Run("running", func(t *testing.T) {
+ eng, _, _ := setupTestEngine(t)
+ bracket := build(state.MatchStatusRunning)
+ eng.resolveSlots(bracket, resolver)
+ m := bracket.Rounds[0][0]
+ assert.Equal(t, "Ryu", m.SideA, "a running match keeps its competitor")
+ assert.Equal(t, "Ryu", m.SubResults[0].SideA, "and its row keeps the name")
+ a, b := m.RepPicks()
+ assert.Equal(t, "pa", a)
+ assert.Equal(t, "pb", b)
+ assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPickA))
+ assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPickB))
+ })
+}
+
+// A team renamed while the competition is draw-ready: the add of a
+// representative bout commits the running match in its own transaction and
+// starts the competition after it, and a failed start is only logged, so a
+// draw-ready competition can hold a representative bout. The hantei mark is
+// placed on the winner's side by comparing the winner to that row's names, so
+// the row's names and its Winner follow the rename (the row carries no pick
+// and no winner member id here: either would credit the side by id and make
+// the assertion green without the rename).
+func TestReplaceParticipantInDraw_ARenamedTeamKeepsTheRepresentativeRowInStep(t *testing.T) {
+ const compID = "rename-rep-row"
+ // The representative bout was decided for side B, the team renamed.
+ build := func(sideBID string) *state.Bracket {
+ return &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: "m1",
+ SideA: "Ryu", SideAID: "id-a", SideB: "Tora", SideBID: sideBID,
+ Status: state.MatchStatusRunning, ModifiedAt: 500,
+ SubResults: []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: "Ryu", SideB: "Tora",
+ IpponsA: []string{}, IpponsB: []string{domain.HanteiMark},
+ Winner: "Tora", Decision: "daihyosen",
+ }},
+ }}}}
+ }
+ for _, tc := range []struct {
+ name string
+ sideBID string // "" is a legacy side no id was ever stamped on
+ pid string
+ }{
+ {name: "a side carrying the team's id", sideBID: "id-b", pid: "id-b"},
+ {name: "a legacy side with no id", sideBID: "", pid: "id-elsewhere"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: "Rename", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed,
+ Format: state.CompFormatKnockout, Courts: []string{"A"}, StartTime: "09:00",
+ Status: state.CompStatusDrawReady,
+ }))
+ require.NoError(t, store.SaveBracket(compID, build(tc.sideBID)))
+
+ _, err := eng.ReplaceParticipantInDraw(compID, tc.pid, "Tora", "Dojo", "", "Lion", "Dojo", "")
+ require.NoError(t, err)
+
+ bracket, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ m := bracket.Rounds[0][0]
+ require.Equal(t, "Lion", m.SideB, "the match side is renamed")
+ row := m.SubResults[state.DaihyosenSubIndex(m.SubResults)]
+ assert.Equal(t, "Lion", row.SideB, "the representative row follows the match's name")
+ assert.Equal(t, "Lion", row.Winner, "and so does its winner, which the mark is placed by")
+ assert.Equal(t, "Ryu", row.SideA, "side A is untouched")
+ assert.Equal(t, domain.MatchSideB, state.SubBoutWinnerSide(row, m.SideA, m.SideB), "side B is still credited")
+ })
+ }
+}
+
+// A rename never clears a representative pick: the side keeps its team. Pass 2
+// of the rename (a side with no id) writes the name directly and must not read
+// "Tora" -> "Lion" as another team, which seatBracketSide would for an id-less
+// side.
+func TestReplaceParticipantInDraw_ARenamedIdlessSideKeepsItsPick(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ const compID = "rename-keeps-pick"
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: "Rename", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed,
+ Format: state.CompFormatKnockout, Courts: []string{"A"}, StartTime: "09:00",
+ Status: state.CompStatusDrawReady,
+ }))
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: "m1",
+ SideA: "Ryu", SideAID: "id-a", SideB: "Tora",
+ Status: state.MatchStatusRunning, ModifiedAt: 500,
+ GroupStamps: map[string]int64{state.GroupRepPickB: 400},
+ SubResults: []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: "Ryu", SideB: "Tora", SideBMemberID: "pb",
+ }},
+ }}}}))
+
+ _, err := eng.ReplaceParticipantInDraw(compID, "id-elsewhere", "Tora", "Dojo", "", "Lion", "Dojo", "")
+ require.NoError(t, err)
+
+ bracket, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ m := bracket.Rounds[0][0]
+ _, pickB := m.RepPicks()
+ assert.Equal(t, "pb", pickB, "the renamed side keeps its pick")
+ assert.Equal(t, "Lion", m.SubResults[0].SideB)
+ assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPickB), "and its date")
+}
+
+// Pass 2 of ReplaceParticipantInDraw renames an id-less side directly and hands
+// the representative row the match's old name (the one it matched on), so a row
+// that names no one still carries the winner recorded under that name.
+func TestReplaceParticipantInDraw_AnIdlessRenameCarriesTheWinnerOfARowThatNamesNoOne(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ const compID = "rename-carries-winner"
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: "Rename", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed,
+ Format: state.CompFormatKnockout, Courts: []string{"A"}, StartTime: "09:00",
+ Status: state.CompStatusDrawReady,
+ }))
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: "m1",
+ SideA: "Ryu", SideAID: "id-a", SideB: "Tora",
+ Status: state.MatchStatusRunning, ModifiedAt: 500,
+ SubResults: []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: "Ryu", SideB: "",
+ IpponsB: []string{domain.HanteiMark}, Winner: "Tora", Decision: "daihyosen",
+ }},
+ }}}}))
+
+ _, err := eng.ReplaceParticipantInDraw(compID, "id-elsewhere", "Tora", "Dojo", "", "Lion", "Dojo", "")
+ require.NoError(t, err)
+
+ bracket, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ m := bracket.Rounds[0][0]
+ assert.Equal(t, "Lion", m.SideB)
+ assert.Equal(t, "Lion", m.SubResults[0].SideB)
+ assert.Equal(t, "Lion", m.SubResults[0].Winner, "the winner follows the match's old name")
+ assert.Equal(t, domain.MatchSideB, state.SubBoutWinnerSide(m.SubResults[0], m.SideA, m.SideB))
+}
+
+// The same id under a new name is the same team: the representative row's
+// winner follows the row's name, or the mark would be placed on nobody. A side
+// given another team leaves the winner alone (the row keeps what was decided).
+func TestSeatBracketSide_ARenameCarriesTheRowsWinnerAndAnotherTeamDoesNot(t *testing.T) {
+ build := func() *state.BracketMatch {
+ return &state.BracketMatch{
+ ID: "m",
+ SideA: "Ryu", SideAID: "a", SideB: "Tora", SideBID: "b",
+ SubResults: []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: "Ryu", SideB: "Tora",
+ IpponsB: []string{domain.HanteiMark}, Winner: "Tora", Decision: "daihyosen",
+ }},
+ }
+ }
+
+ t.Run("the same id under a new name", func(t *testing.T) {
+ bm := build()
+ seatBracketSide(bm, domain.MatchSideB, "Lion", "b")
+ assert.Equal(t, "Lion", bm.SubResults[0].SideB)
+ assert.Equal(t, "Lion", bm.SubResults[0].Winner, "the winner follows the name it was recorded under")
+ assert.Equal(t, domain.MatchSideB, state.SubBoutWinnerSide(bm.SubResults[0], bm.SideA, bm.SideB))
+ })
+ t.Run("the other side's rename leaves a winner who is not it", func(t *testing.T) {
+ bm := build()
+ seatBracketSide(bm, domain.MatchSideA, "Dragon", "a")
+ assert.Equal(t, "Dragon", bm.SubResults[0].SideA)
+ assert.Equal(t, "Tora", bm.SubResults[0].Winner)
+ })
+ t.Run("another team", func(t *testing.T) {
+ bm := build()
+ seatBracketSide(bm, domain.MatchSideB, "Kuma", "c")
+ assert.Equal(t, "Kuma", bm.SubResults[0].SideB)
+ assert.Equal(t, "Tora", bm.SubResults[0].Winner, "a decision made for the team that left is left as it was")
+ })
+
+ // A row's side name can be blank or stale: AddDaihyosen stamps the names and
+ // adoptCurrentSideName only rewrites a row carrying the OLD name, never
+ // fills a blank, while the merge lands a payload row's names as sent, so a
+ // writer that does not restate them (rows written before the names rule,
+ // hand-edited data) leaves them blank. The Winner names the team by the
+ // match's name, so the rename carries it from there too.
+ t.Run("a row that names no one carries the winner the match's old name recorded", func(t *testing.T) {
+ bm := build()
+ bm.SubResults[0].SideB = ""
+ seatBracketSide(bm, domain.MatchSideB, "Lion", "b")
+ assert.Equal(t, "Lion", bm.SubResults[0].SideB)
+ assert.Equal(t, "Lion", bm.SubResults[0].Winner, "the winner follows the match's old name")
+ assert.Equal(t, domain.MatchSideB, state.SubBoutWinnerSide(bm.SubResults[0], bm.SideA, bm.SideB))
+ })
+ t.Run("a row that names a stale team carries the winner the match's old name recorded", func(t *testing.T) {
+ bm := build()
+ bm.SubResults[0].SideB = "Tigre"
+ seatBracketSide(bm, domain.MatchSideB, "Lion", "b")
+ assert.Equal(t, "Lion", bm.SubResults[0].SideB)
+ assert.Equal(t, "Lion", bm.SubResults[0].Winner)
+ })
+ // The carry is not skipped for a row that already reads the new name: a writer
+ // that restated the rename on the row (the merge lands a payload row's names as
+ // sent) can leave the Winner on the match's old name.
+ t.Run("a row that already reads the new name carries the winner the match's old name recorded", func(t *testing.T) {
+ bm := build()
+ bm.SubResults[0].SideB = "Lion"
+ seatBracketSide(bm, domain.MatchSideB, "Lion", "b")
+ assert.Equal(t, "Lion", bm.SubResults[0].SideB)
+ assert.Equal(t, "Lion", bm.SubResults[0].Winner, "the winner follows the match's old name")
+ assert.Equal(t, domain.MatchSideB, state.SubBoutWinnerSide(bm.SubResults[0], bm.SideA, bm.SideB))
+ })
+ // PIN, green by design: a row that already reads the new name and whose Winner
+ // already follows it has nothing to carry, so it is not copied (the list a
+ // row's other holders share is replaced only when a row changes).
+ t.Run("a row that already reads the new name and winner is untouched and not copied", func(t *testing.T) {
+ bm := build()
+ bm.SubResults[0].SideB, bm.SubResults[0].Winner = "Lion", "Lion"
+ before := &bm.SubResults[0]
+ seatBracketSide(bm, domain.MatchSideB, "Lion", "b")
+ assert.Same(t, before, &bm.SubResults[0], "the list is the one it was")
+ assert.Equal(t, "Lion", bm.SubResults[0].Winner)
+ })
+ // PIN, green by design: the match's old name carries the Winner only for a
+ // side that keeps its team, as the row's own old name does.
+ t.Run("a row that names no one, given another team, leaves the winner", func(t *testing.T) {
+ bm := build()
+ bm.SubResults[0].SideB = ""
+ seatBracketSide(bm, domain.MatchSideB, "Kuma", "c")
+ assert.Equal(t, "Kuma", bm.SubResults[0].SideB)
+ assert.Equal(t, "Tora", bm.SubResults[0].Winner, "a decision made for the team that left is left as it was")
+ })
+ // PIN, green by design: the other side's rename must not take a winner that
+ // names this side, even through the match's old name.
+ t.Run("a row that names no one, the other side renamed, leaves a winner who is not it", func(t *testing.T) {
+ bm := build()
+ bm.SubResults[0].SideA = ""
+ seatBracketSide(bm, domain.MatchSideA, "Dragon", "a")
+ assert.Equal(t, "Dragon", bm.SubResults[0].SideA)
+ assert.Equal(t, "Tora", bm.SubResults[0].Winner)
+ })
+}
+
+// A pool requalification that reopens a played knockout match and repaints its
+// slot with another competitor: the reopen line names the picks group.
+func TestRequalify_ForcedReopenTakesThePickAndItsLineNamesIt(t *testing.T) {
+ f := newRQFixture(t, "rq-picks", 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")
+ require.NoError(t, f.scoreKO(m1.ID, "A1"))
+
+ // The played match's rep bout holds a pick for Pool A's winner.
+ b := f.bracket()
+ played := findBracketMatchInBracket(b, m1.ID)
+ pick := state.SubMatchResult{Position: state.DaihyosenSubPosition}
+ if s1 == "A" {
+ pick.SideAMemberID = "pick-a1"
+ } else {
+ pick.SideBMemberID = "pick-a1"
+ }
+ played.SubResults = []state.SubMatchResult{pick}
+ require.NoError(t, f.store.SaveBracket(f.compID, b))
+
+ 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)
+ // The pick sat on the side Pool A's winner holds.
+ pickGroup, keptGroup := state.GroupRepPickB, state.GroupRepPickA
+ if s1 == "A" {
+ pickGroup, keptGroup = state.GroupRepPickA, state.GroupRepPickB
+ }
+ assert.Equal(t, []string{pickGroup}, reopened[0].RepPicksCleared)
+
+ got := findBracketMatchInBracket(f.bracket(), m1.ID)
+ name, _ := sideOf(*got, s1)
+ assert.Equal(t, "A2", name)
+ a, c := got.RepPicks()
+ assert.Empty(t, a)
+ assert.Empty(t, c)
+
+ entries, err := f.store.LoadMatchHistory(f.compID, m1.ID)
+ require.NoError(t, err)
+ var named, namedKept bool
+ for _, e := range entries {
+ if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, pickGroup) {
+ named = true
+ }
+ if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, keptGroup) {
+ namedKept = true
+ }
+ }
+ assert.True(t, named, "the reopen line names the cleared side's pick group")
+ assert.False(t, namedKept, "and not the side that held no pick")
+}
+
+// DELETE .../overrides removes a pool's hand-set order without asking the
+// planner (it only writes overrides.json), so the knockout still seats the
+// order that was removed until the next auto-complete repaints it through
+// ResolveQualifiedPools, which no confirmation or reopen answers for. A match
+// sent back to the queue keeps its representative bout's picks and is not
+// locked, so the repaint can take a pick from it; it records the re-seat in the
+// match's history like every other door that does (bracket_seat_audit.go).
+func TestResolveQualifiedPools_ARepaintTheOverridesRemovalLeftRecordsTheReseat(t *testing.T) {
+ f := newRQFixture(t, "rq-overrides-removed", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}})
+ f.scorePool("Pool A-0", "A1")
+ f.scorePool("Pool B-0", "B1")
+ f.resolve()
+ // Pool A's order is set by hand: the planner seats A2.
+ _, err := f.eng.OverridePoolRanks(f.compID, "Pool A", []RankOverride{{PlayerID: rqID("A2"), Rank: 1}}, ForceOptions{})
+ require.NoError(t, err)
+ m, side := f.slot("Pool A-1st")
+ name, _ := sideOf(m, side)
+ require.Equal(t, "A2", name, "the hand-set order is seated")
+
+ // The match was sent back to the queue holding A2's representative pick.
+ b := f.bracket()
+ queued := findBracketMatchInBracket(b, m.ID)
+ require.Equal(t, state.MatchStatusScheduled, queued.Status)
+ pick := state.SubMatchResult{Position: state.DaihyosenSubPosition}
+ pickGroup := state.GroupRepPickB
+ if side == "A" {
+ pick.SideAMemberID, pickGroup = "pick-a2", state.GroupRepPickA
+ } else {
+ pick.SideBMemberID = "pick-a2"
+ }
+ queued.SubResults = []state.SubMatchResult{pick}
+ require.NoError(t, f.store.SaveBracket(f.compID, b))
+
+ changed, err := f.store.ResetOverridesChanged(f.compID)
+ require.NoError(t, err)
+ require.True(t, changed)
+ _, err = f.eng.MaybeAutoCompletePools(f.compID)
+ require.NoError(t, err)
+
+ got := findBracketMatchInBracket(f.bracket(), m.ID)
+ name, _ = sideOf(*got, side)
+ require.Equal(t, "A1", name, "the natural order is seated again")
+ a, c := got.RepPicks()
+ assert.Empty(t, a)
+ assert.Empty(t, c)
+
+ entries, err := f.store.LoadMatchHistory(f.compID, m.ID)
+ require.NoError(t, err)
+ var recorded bool
+ for _, e := range entries {
+ if e.Door == doorReseat && slices.Contains(e.Changed, pickGroup) {
+ recorded = true
+ }
+ }
+ assert.True(t, recorded, "the re-seat that took the pick is in the match's history, not only in a log line")
+}
diff --git a/internal/engine/daihyosen.go b/internal/engine/daihyosen.go
index 2b88c2017..1e74bd58b 100644
--- a/internal/engine/daihyosen.go
+++ b/internal/engine/daihyosen.go
@@ -379,9 +379,12 @@ func ComputeTeamSummary(subResults []state.SubMatchResult, sideAName, sideBName
//
// The returned SubMatchResult has Position = -1 (a sentinel for "this
// is a daihyosen bout, not a numbered roster position") and
-// Decision = "daihyosen". The handler appends this to the match's
-// SubResults; the operator subsequently records the rep players +
-// ippon via the score modal.
+// Decision = "daihyosen", and its SideA/SideB are nameA/nameB, the
+// match's own team names (the caller passes match.SideA/match.SideB;
+// TeamSummary carries no name). The row is never nameless, not even
+// between the add and the first score write. The handler appends this
+// to the match's SubResults; the operator subsequently records the rep
+// players + ippon via the score modal.
//
// CHK026: when a team has exactly 1 eligible competitor, that
// competitor MUST be the rep, the operator selects them in the score
@@ -391,7 +394,7 @@ func ComputeTeamSummary(subResults []state.SubMatchResult, sideAName, sideBName
//
// FR-046, CHK026, T139.
func (e *Engine) AddDaihyosen(
- compID, matchID string,
+ compID, matchID, nameA, nameB string,
sideA, sideB TeamSummary,
isPool bool,
sideAEligible, sideBEligible int,
@@ -407,6 +410,13 @@ func (e *Engine) AddDaihyosen(
}
return &state.SubMatchResult{
Position: -1, // sentinel: daihyosen, not a numbered roster bout
+ // The row names the match's two teams from the moment it exists: the
+ // hantei mark is placed on the winner's side by comparing the winner
+ // against THIS row's sideA/sideB, so a nameless row is one nothing can
+ // attribute. seatBracketSide keeps them equal to the match's from
+ // then on.
+ SideA: nameA,
+ SideB: nameB,
Decision: string(domain.DecisionDaihyosen),
}, nil
}
diff --git a/internal/engine/daihyosen_test.go b/internal/engine/daihyosen_test.go
index 21f2f1da7..c886a4c1e 100644
--- a/internal/engine/daihyosen_test.go
+++ b/internal/engine/daihyosen_test.go
@@ -91,7 +91,7 @@ func TestDaihyosenAddableOnlyWhenTied(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- sub, err := eng.AddDaihyosen(compID, matchID, tc.sideA, tc.sideB, tc.isPool, tc.sideAEligible, tc.sideBEligible)
+ sub, err := eng.AddDaihyosen(compID, matchID, "Alpha", "Bravo", tc.sideA, tc.sideB, tc.isPool, tc.sideAEligible, tc.sideBEligible)
if tc.wantErr != nil {
require.Error(t, err)
assert.Truef(t, errors.Is(err, tc.wantErr), "want errors.Is == %v, got %v", tc.wantErr, err)
@@ -103,6 +103,8 @@ func TestDaihyosenAddableOnlyWhenTied(t *testing.T) {
if tc.wantSub {
assert.Equal(t, -1, sub.Position, "daihyosen sentinel position must be -1")
assert.Equal(t, string(domain.DecisionDaihyosen), sub.Decision)
+ assert.Equal(t, "Alpha", sub.SideA, "the row names the match's side A from the moment it exists")
+ assert.Equal(t, "Bravo", sub.SideB, "the row names the match's side B from the moment it exists")
}
})
}
diff --git a/internal/engine/engi.go b/internal/engine/engi.go
index b9e000269..d2f21fb8f 100644
--- a/internal/engine/engi.go
+++ b/internal/engine/engi.go
@@ -252,6 +252,9 @@ func (e *Engine) recordEngiMatch(
fo := firstForceOptions(opts)
force := fo.Force
var reopened []ReopenedMatch
+ // The picks the propagation takes from matches it re-seats without
+ // reopening, for their own history lines (bracket_seat_audit.go).
+ var clears []repPickClear
if !engiValidTotal(flagsA, flagsB) {
return nil, validationErrorf(
"engi: flag total %d+%d=%d is invalid; total must be odd and in {1,3,5} (3- or 5-referee panel, no draw possible)",
@@ -319,12 +322,15 @@ func (e *Engine) recordEngiMatch(
return err
}
priorWinner, priorWinnerID := propagatedWinnerOf(b, rIdx, mIdx, bm)
+ before := repPickSnapshot(b, matchID)
result = applyEngiToBracketMatch(bm, flagsA, flagsB, winnerSide, correctionReason)
stampEngiChanges(bm, stamp, groupStamps)
e.propagateBracketWinner(b, rIdx, mIdx)
if force && winnerActuallyChanged(priorWinner, priorWinnerID, bm) {
reopened = forceReopenDownstreamChain(b, rIdx, mIdx, bm.ID)
}
+ clears = repPickClears(before, b)
+ markRepPicksCleared(reopened, clears)
return nil
}
}
@@ -348,6 +354,7 @@ func (e *Engine) recordEngiMatch(
e.recordDirectHistory(h, compID, matchID, doorEngi, stamp, engiChangedGroups...)
}
e.restoreForceReopened(h, compID, reopened)
+ e.recordRepPickClears(h, compID, clears, reopened)
if fo.Reopened != nil {
*fo.Reopened = append(*fo.Reopened, reopened...)
}
diff --git a/internal/engine/export.go b/internal/engine/export.go
index 1f55448e7..7985e02ed 100644
--- a/internal/engine/export.go
+++ b/internal/engine/export.go
@@ -127,8 +127,17 @@ func (e *Engine) ExportCompetitionXlsx(id string) ([]byte, error) {
return nil, err
}
+ // Representative Bouts sheet's bouts. Opt-in the same way: only a
+ // fixed-order team competition whose draw holds a representative bout has
+ // any.
+ representativeBouts, err := e.collectRepresentativeBouts(id, comp)
+ if err != nil {
+ return nil, err
+ }
+
// The shared sheet pipeline (mp-yuy8): Data, Pool Draw, Pool Matches,
- // knockout, Tree cleanup, Names to Print, Kachinuki Detail -- identical
+ // knockout, Tree cleanup, Names to Print, Kachinuki Detail, Representative
+ // Bouts -- identical
// steps and order to internal/export.BuildResultsWorkbook.
// RenderCompetitionWorkbook derives namesToPrintPlayers via
// KnockoutNamesToPrint (numbering.go, bc-pnum A8: a
@@ -137,7 +146,7 @@ func (e *Engine) ExportCompetitionXlsx(id string) ([]byte, error) {
// no-ops) -- internal/export.BuildResultsWorkbook resolves through the
// SAME derivation, so the two exports of one competition agree on
// whether that sheet exists at all.
- _, namesToPrintPlayers, err := e.RenderCompetitionWorkbook(f, comp, pools, bracket, courts, courtOfPool, draw, kachinukiMatches)
+ _, namesToPrintPlayers, err := e.RenderCompetitionWorkbook(f, comp, pools, bracket, courts, courtOfPool, draw, kachinukiMatches, representativeBouts)
if err != nil {
return nil, err
}
diff --git a/internal/engine/k3_refused_before_reseat_test.go b/internal/engine/k3_refused_before_reseat_test.go
new file mode 100644
index 000000000..d890f9d9e
--- /dev/null
+++ b/internal/engine/k3_refused_before_reseat_test.go
@@ -0,0 +1,94 @@
+package engine
+
+// K3 refuses a withdrawal whose loser ANOTHER match already made ineligible,
+// and a refusal must leave the bracket exactly as it found it. The pre-write
+// half (refuseConcurrentWithdrawal) used to resolve the loser from a probe copy
+// of the payload whose side NAMES were still empty, so a `/score`-shaped
+// withdrawal that names a winner but no sides, no winnerSide and no winnerId
+// resolved no loser there: the write went on, propagateBracketWinner re-seated
+// the next round's side (its representative pick cleared, its stamp moved), and
+// only THEN did the post-write check raise AlreadyIneligibleError, whose
+// rollback restores the written match row alone. The probe now takes the stored
+// pairing's names, so the loser resolves before anything is written.
+
+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"
+)
+
+// k3Bracket stores rpSetup's team knockout with Ryu (who advanced into m-r2-0
+// side A, holding a representative pick) already barred by "other-match".
+func k3Bracket(t *testing.T) (*Engine, *state.Store, string) {
+ t.Helper()
+ eng, store, compID := rpSetup(t, false)
+ require.NoError(t, store.SetCompetitorStatus(compID, domain.CompetitorStatus{
+ PlayerID: wrTeamAID, Eligible: false, Reason: "kiken", MatchID: "other-match",
+ }))
+ return eng, store, compID
+}
+
+// k3Payload is the hand-built withdrawal: Ryu withdraws (kiken), so the winner
+// is Tora. It carries a winner NAME and nothing that names a side.
+func k3Payload() *state.MatchResult {
+ return &state.MatchResult{
+ Status: state.MatchStatusCompleted,
+ Decision: string(domain.DecisionKikenVoluntary),
+ Winner: wrTeamB,
+ CorrectionReason: "Wrong side withdrew",
+ }
+}
+
+func TestK3_ARefusedWithdrawalNeverReseatsTheNextRound(t *testing.T) {
+ eng, store, compID := k3Bracket(t)
+ priorNext := rpNext(t, store, compID)
+ priorBracket, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ priorFirst := *priorBracket.MatchByID("m-r1-0")
+
+ err = inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, werr := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", k3Payload(), ForceOptions{})
+ return werr
+ })
+ var already *AlreadyIneligibleError
+ require.ErrorAs(t, err, &already, "the withdrawal is refused")
+ assert.Equal(t, wrTeamAID, already.PlayerID)
+ assert.Equal(t, "other-match", already.MatchID)
+
+ next := rpNext(t, store, compID)
+ assert.Equal(t, wrTeamA, next.SideA, "the refusal left the next round's side A where it was")
+ assert.Equal(t, wrTeamAID, next.SideAID)
+ a, c := next.RepPicks()
+ assert.Equal(t, rpPickA, a, "and the representative pick on it")
+ assert.Equal(t, rpPickC, c)
+ assert.Equal(t, mmT1, next.GroupStamp(state.GroupRepPickA), "dated as it was, not moved by a re-seat that was then refused")
+ assert.Equal(t, priorNext.ModifiedAt, next.ModifiedAt, "the next match was not written at all")
+
+ after, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ first := after.MatchByID("m-r1-0")
+ assert.Equal(t, priorFirst.Winner, first.Winner, "the refused match keeps its recorded winner")
+ assert.Equal(t, priorFirst.Status, first.Status)
+ assert.Equal(t, priorFirst.Decision, first.Decision)
+
+ for _, id := range []string{rpNextID, "m-r1-0"} {
+ entries, herr := store.LoadMatchHistory(compID, id)
+ require.NoError(t, herr)
+ assert.Empty(t, entries, "a refused write leaves no history line on %s, a re-seat's included", id)
+ }
+}
+
+// The same payload with the loser NOT barred is an ordinary correction: the
+// probe's names must not turn every withdrawal into a refusal.
+func TestK3_AWithdrawalNobodyBarredStillLands(t *testing.T) {
+ eng, store, compID := rpSetup(t, false)
+ require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error {
+ _, werr := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", k3Payload(), ForceOptions{})
+ return werr
+ }))
+ next := rpNext(t, store, compID)
+ assert.Equal(t, wrTeamB, next.SideA, "Tora advanced in Ryu's place")
+}
diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go
index be6ae6989..d5654ec02 100644
--- a/internal/engine/kachinuki.go
+++ b/internal/engine/kachinuki.go
@@ -1146,14 +1146,25 @@ func (e *Engine) reopenUnderCourtLock(compID string, comp *state.Competition, ma
// documented check-before-mutate contract (a no-op here), unwinds
// the byes and does the retraction.
var reopenedDownstream []ReopenedMatch
+ // The picks the retraction takes from the matches it re-seats
+ // without reopening, for their own history lines
+ // (bracket_seat_audit.go); the target is this door's own write.
+ var clears []repPickClear
if !h.Bronze {
+ before := repPickSnapshot(h.BracketRoot, matchID)
if fo.Force {
+ // No propagation precedes this reopen: the retraction
+ // below is what re-seats the slot, so the picks the
+ // reopened match held are judged after it, against the
+ // snapshot above.
reopenedDownstream = forceReopenDownstreamChain(h.BracketRoot, h.RIdx, h.MIdx, matchID)
}
if derr := retractPropagatedWinner(h.BracketRoot, h.RIdx, h.MIdx); derr != nil {
opErr = derr
return nil
}
+ clears = repPickClears(before, h.BracketRoot)
+ markRepPicksCleared(reopenedDownstream, clears)
}
prior := h.Bracket.Decision
reopenBracketMatchKeepingTheFight(h.Bracket, reason, targetStatus)
@@ -1162,6 +1173,7 @@ func (e *Engine) reopenUnderCourtLock(compID string, comp *state.Competition, ma
}
e.recordDirectHistory(tx, compID, matchID, doorReopen, h.Bracket.GroupStamp(state.GroupResult), reopenedBracketGroups...)
e.restoreForceReopened(tx, compID, reopenedDownstream)
+ e.recordRepPickClears(tx, compID, clears, reopenedDownstream)
if fo.Reopened != nil {
*fo.Reopened = reopenedDownstream
}
@@ -1974,7 +1986,7 @@ type bracketPos struct{ R, M int }
// unresolveBye can take it back exactly.
func resolvedByByeFrom(bm *state.BracketMatch, feedM int) bool {
fed, other := bm.SideB, bm.SideA
- if feedM%2 == 0 {
+ if feedsSide(feedM) == domain.MatchSideA {
fed, other = bm.SideA, bm.SideB
}
return bm.Status == state.MatchStatusCompleted &&
@@ -2209,14 +2221,10 @@ func clearPropagatedSlots(bracket *state.Bracket, rIdx, mIdx int, bronze, next *
// alongside the name (bc-brid): propagateBracketWinner set both
// together, so undoing it must clear both together too, or the
// bronze slot would keep a stale id pointing at a name it no longer
- // carries.
- if mIdx%2 == 0 {
- bronze.SideA = ""
- bronze.SideAID = ""
- } else {
- bronze.SideB = ""
- bronze.SideBID = ""
- }
+ // carries. Through seatBracketSide, which also takes away the
+ // representative a team match's rep bout held for the team that left
+ // the slot.
+ seatBracketSide(bronze, feedsSide(mIdx), "", "")
}
if next != nil {
// winnerOfPlaceholder (bracket.go) is the ONE producer this now shares
@@ -2225,14 +2233,9 @@ func clearPropagatedSlots(bracket *state.Bracket, rIdx, mIdx int, bronze, next *
placeholder := winnerOfPlaceholder(len(bracket.Rounds)-rIdx, mIdx)
// Same id-follows-name rule as the bronze clear above: a "Winner of
// ..." placeholder is not a resolved competitor, so its slot must
- // carry no id (bc-brid).
- if mIdx%2 == 0 {
- next.SideA = placeholder
- next.SideAID = ""
- } else {
- next.SideB = placeholder
- next.SideBID = ""
- }
+ // carry no id (bc-brid), and no pick: seatBracketSide clears the
+ // representative the team that left the slot held.
+ seatBracketSide(next, feedsSide(mIdx), placeholder, "")
}
}
diff --git a/internal/engine/kachinuki_export.go b/internal/engine/kachinuki_export.go
index d52b45c82..41f11302a 100644
--- a/internal/engine/kachinuki_export.go
+++ b/internal/engine/kachinuki_export.go
@@ -51,26 +51,16 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition)
return nil, nil
}
- poolMatches, err := e.store.LoadPoolMatches(compID)
- if err != nil {
- return nil, err
- }
- pools, err := e.store.LoadPools(compID)
+ draw, err := e.loadDetailDraw("engine.collectKachinukiMatches", compID)
if err != nil {
return nil, err
}
- // A bracket that cannot be read leaves the pool sections alone.
- bracket, err := e.store.LoadBracket(compID)
- if err != nil {
- log.Printf("engine.collectKachinukiMatches compId=%s: bracket.json load error: %v; the export lists the pool sections only", compID, err)
- bracket = nil
- }
// Each match's section reads its fighters' positions from the lineup in
// force for its teams at that match, over the draw loaded above. The
// lineups are read once per export; they may be missing entirely, in which
// case positions render as empty strings.
- rule := e.lineupRuleOrNone("engine.collectKachinukiMatches", compID, comp.IsKnockoutEnabled(), poolMatches, bracket)
+ rule := e.lineupRuleOrNone("engine.collectKachinukiMatches", compID, comp.IsKnockoutEnabled(), draw.poolMatches, draw.bracket)
// Squad member labels (bc-pnum: "make a team member's label available
// to the public surfaces" -- the printed record is one of the
@@ -81,40 +71,86 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition)
teamNumbers := e.buildKachinukiTeamNumbers(compID, comp)
squads := e.buildKachinukiSquads(compID)
- var out []helper.KachinukiMatchDetail
- // section builds a match's section; one not yet decided and with no bout
- // recorded gets the empty rows for hand entry.
- section := func(m *state.MatchResult, label string) helper.KachinukiMatchDetail {
+ // A match not yet decided and with no bout recorded gets the empty rows
+ // for hand entry.
+ return draw.sections(comp, func(m *state.MatchResult, label string) (helper.KachinukiMatchDetail, bool) {
detail := buildKachinukiDetail(m, label, rule.positionsForMatch(m), teamNumbers, squads)
if len(detail.Bouts) == 0 && m.Status != state.MatchStatusCompleted {
detail.BlankBoutRows = comp.TeamBoutRows()
}
- return detail
+ return detail, true
+ }), nil
+}
+
+// detailDraw is a competition's draw as the bout-detail sheets read it. The
+// Kachinuki Detail and Representative Bouts sheets list their sections from
+// the one walk of it (sections), so they cannot order a draw differently.
+type detailDraw struct {
+ pools []helper.Pool
+ poolMatches []state.MatchResult
+ bracket *state.Bracket // nil when bracket.json could not be read
+}
+
+// detailSection builds the section of one match titled label, or reports that
+// the match has none.
+type detailSection func(m *state.MatchResult, label string) (helper.KachinukiMatchDetail, bool)
+
+// loadDetailDraw reads the pools, pool matches and bracket of compID. A
+// bracket that cannot be read leaves the pool sections alone; caller names the
+// export in that log line.
+func (e *Engine) loadDetailDraw(caller, compID string) (detailDraw, error) {
+ poolMatches, err := e.store.LoadPoolMatches(compID)
+ if err != nil {
+ return detailDraw{}, err
+ }
+ pools, err := e.store.LoadPools(compID)
+ if err != nil {
+ return detailDraw{}, err
+ }
+ bracket, err := e.store.LoadBracket(compID)
+ if err != nil {
+ log.Printf("%s compId=%s: bracket.json load error: %v; the export lists the pool sections only", caller, compID, err)
+ bracket = nil
+ }
+ return detailDraw{pools: pools, poolMatches: poolMatches, bracket: bracket}, nil
+}
+
+// sections lists the draw's matches that section gives a section for, in the
+// order every bout-detail sheet prints them: pool matches in the Pool Matches
+// grid's order, then bracket matches by match number (the order the
+// Elimination Matches sheet prints them, not storage order), then the
+// 3rd-place match.
+func (d detailDraw) sections(comp *state.Competition, section detailSection) []helper.KachinukiMatchDetail {
+ var out []helper.KachinukiMatchDetail
+ add := func(m *state.MatchResult, label string) {
+ if detail, ok := section(m, label); ok {
+ out = append(out, detail)
+ }
}
// Pool matches first, numbered as the Pool Matches grid lists them
// (helper.EachPoolMatch, shared with the blank template). A row the grid
// has no block for (a tie-break or daihyosen) has a section only once it
// has bouts, titled by its operator label rather than a draw number.
- ordinals := AttachPoolMatches(pools, poolMatches)
- byID := make(map[string]*state.MatchResult, len(poolMatches))
- for i := range poolMatches {
- byID[poolMatches[i].ID] = &poolMatches[i]
+ ordinals := AttachPoolMatches(d.pools, d.poolMatches)
+ byID := make(map[string]*state.MatchResult, len(d.poolMatches))
+ for i := range d.poolMatches {
+ byID[d.poolMatches[i].ID] = &d.poolMatches[i]
}
- listed := make(map[string]bool, len(poolMatches))
- helper.EachPoolMatch(pools, func(label string, pool helper.Pool, i int) {
+ listed := make(map[string]bool, len(d.poolMatches))
+ helper.EachPoolMatch(d.pools, func(label string, pool helper.Pool, i int) {
id := fmt.Sprintf("%s-%d", pool.PoolName, ordinals[pool.PoolName][i])
if m := byID[id]; m != nil {
listed[id] = true
- out = append(out, section(m, label))
+ add(m, label)
}
})
- for i := range poolMatches {
- m := &poolMatches[i]
+ for i := range d.poolMatches {
+ m := &d.poolMatches[i]
if listed[m.ID] || len(m.SubResults) == 0 {
continue
}
- out = append(out, section(m, OperatorMatchLabel(comp, nil, m.ID)))
+ add(m, OperatorMatchLabel(comp, nil, m.ID))
}
// Bracket matches in match-number order, the order the Elimination Matches
@@ -124,14 +160,14 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition)
// (state.Bracket.NumberMatches). A bye, hidden with one side empty, has no
// section. Each is titled and its sides named as the sheet prints them, so
// a side reading "M 3" leads to the section titled with match 3.
- if bracket != nil {
- printed := PrintedBracket(bracket)
+ if d.bracket != nil {
+ printed := PrintedBracket(d.bracket)
type stored struct {
bm state.BracketMatch
fallbackTitle string // for a bracket stored before match numbers
}
var matches []stored
- for rIdx, round := range bracket.Rounds {
+ for rIdx, round := range d.bracket.Rounds {
for mIdx, bm := range round {
if len(bm.SubResults) == 0 && (bm.Hidden || bm.SideA == "" || bm.SideB == "") {
continue
@@ -141,25 +177,28 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition)
}
// Stable, so matches without a number come first, in storage order.
slices.SortStableFunc(matches, func(a, b stored) int { return cmp.Compare(a.bm.MatchNumber, b.bm.MatchNumber) })
- bracketSection := func(bm state.BracketMatch, fallbackTitle string) helper.KachinukiMatchDetail {
+ bracketSection := func(bm state.BracketMatch, fallbackTitle string) {
p := printed[bm.ID]
title := p.Title
if title == "" {
title = fallbackTitle
}
- detail := section(bracketMatchToTeamResult(bm), title)
+ detail, ok := section(bracketMatchToTeamResult(bm), title)
+ if !ok {
+ return
+ }
detail.SideATeam, detail.SideBTeam = p.SideA, p.SideB
- return detail
+ out = append(out, detail)
}
for _, m := range matches {
- out = append(out, bracketSection(m.bm, m.fallbackTitle))
+ bracketSection(m.bm, m.fallbackTitle)
}
- if bm := bracket.ThirdPlaceMatch; bm != nil {
- out = append(out, bracketSection(*bm, helper.ThirdPlaceLabel))
+ if bm := d.bracket.ThirdPlaceMatch; bm != nil {
+ bracketSection(*bm, helper.ThirdPlaceLabel)
}
}
- return out, nil
+ return out
}
// buildKachinukiTeamNumbers resolves every team-shaped participant's
@@ -281,6 +320,21 @@ func resolveKachinukiDisplayName(squads map[string][]domain.TeamMember, teamID,
return storedName
}
+// boutFigures is what a bout's row prints beside its fighters, in side order:
+// each side's score string and result mark, and the one closed-set middle
+// mark. Every bout-detail sheet reads them here, attributed, marked and
+// maru-filled by the same domain owners as the main sheets' bout rows
+// (export's writeTeamSubMatchScores), so a bout reads the same on all of them;
+// IpponsScore drops placeholder dots and the Ht mark from a recorded score.
+func boutFigures(m *state.MatchResult, sub state.SubMatchResult) (scoreA, scoreB, markA, markB, middle string) {
+ att := domain.SubBoutAttributionForTeamRow(sub.Attribution(), m.SideA, m.SideB)
+ markA, markB = domain.SideMarksAB(sub.Decision, sub.HanteiDecided(), att)
+ scoreA, scoreB = domain.DefaultWinMaruAB(
+ domain.IpponsScore(sub.IpponsA), domain.IpponsScore(sub.IpponsB),
+ sub.Decision, sub.Encho.On(), att)
+ return scoreA, scoreB, markA, markB, domain.MiddleMark(sub.Decision, sub.Encho.On())
+}
+
// buildKachinukiDetail converts a single state.MatchResult into the
// helper-layer detail struct. positions is the match's own position lookup
// (lineupRule.positionsForMatch).
@@ -290,15 +344,7 @@ func buildKachinukiDetail(m *state.MatchResult, label string, positions map[stri
}
bouts := make([]helper.KachinukiBout, 0, len(m.SubResults))
for _, sub := range m.SubResults {
- // Attributed, marked and maru-filled by the same domain owners as the
- // main sheets' bout rows (export's writeTeamSubMatchScores), so a bout
- // reads the same on both; IpponsScore drops placeholder dots and the
- // Ht mark from a recorded score.
- att := domain.SubBoutAttributionForTeamRow(sub.Attribution(), m.SideA, m.SideB)
- markA, markB := domain.SideMarksAB(sub.Decision, sub.HanteiDecided(), att)
- scoreA, scoreB := domain.DefaultWinMaruAB(
- domain.IpponsScore(sub.IpponsA), domain.IpponsScore(sub.IpponsB),
- sub.Decision, sub.Encho.On(), att)
+ scoreA, scoreB, markA, markB, middle := boutFigures(m, sub)
bouts = append(bouts, helper.KachinukiBout{
Position: sub.Position,
SideAName: resolveKachinukiDisplayName(squads, m.SideAID, sub.SideAMemberID, sub.SideA),
@@ -309,7 +355,7 @@ func buildKachinukiDetail(m *state.MatchResult, label string, positions map[stri
SideBLabel: resolveKachinukiMemberLabel(teamNumbers, squads, m.SideBID, sub.SideBMemberID),
SideBPos: resolvePos(m.SideB, sub.SideBMemberID, sub.SideB),
ScoreB: scoreB,
- Middle: domain.MiddleMark(sub.Decision, sub.Encho.On()),
+ Middle: middle,
MarkA: markA,
MarkB: markB,
})
diff --git a/internal/engine/kachinuki_test.go b/internal/engine/kachinuki_test.go
index 21a0b1f74..79cbb2bb6 100644
--- a/internal/engine/kachinuki_test.go
+++ b/internal/engine/kachinuki_test.go
@@ -2176,7 +2176,7 @@ func TestReopenKachinukiMatch_ReopenPending(t *testing.T) {
// reopened without a reason carries ReopenPending, and sending it back to the
// queue must CLEAR that audit debt. If it didn't, the requeued (now scheduled,
// empty) match would still owe a correctionReason for a result that no longer
-// exists, and applyCorrectionReasonUnderTx would reject its next honest
+// exists, and the handler's applyCorrectionReason would reject its next honest
// finalization demanding one. reopenBracketMatch's doc names this mirror
// obligation on RevertMatchToQueue; the two homes carry the flag separately.
func TestRevertMatchToQueue_ClearsReopenPending(t *testing.T) {
@@ -2223,7 +2223,7 @@ func TestRevertMatchToQueue_ClearsReopenPending(t *testing.T) {
// TestOverrideBracketWinner_ClearsReopenPending pins the related half of R1:
// override-winner is a second finalization path that bypasses
-// applyCorrectionReasonUnderTx, so a reopened bracket match closed out via an
+// applyCorrectionReason, so a reopened bracket match closed out via an
// override must also discharge ReopenPending, or the flag lingers indefinitely.
func TestOverrideBracketWinner_ClearsReopenPending(t *testing.T) {
compID := "override-pending"
diff --git a/internal/engine/knockout.go b/internal/engine/knockout.go
index ff9143991..0e2c818f4 100644
--- a/internal/engine/knockout.go
+++ b/internal/engine/knockout.go
@@ -7,6 +7,7 @@ import (
"sort"
"strings"
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
"github.com/gitrgoliveira/bracket-creator/internal/helper"
"github.com/gitrgoliveira/bracket-creator/internal/state"
)
@@ -228,6 +229,28 @@ func qualifierLabelPool(label string) string {
return label
}
+// slotFields returns the name and id fields of the bracket slot that side
+// names: side A's pair, side B's pair, or the Winner pair for
+// domain.MatchSideNone. paint compares, logs and writes through it, so a slot's
+// fields cannot disagree with its side. It only names the fields. A side write
+// still goes through seatBracketSide, which also takes away the representative
+// the leaving team held; the Winner is not a side, so it is written directly and
+// never through seatBracketSide. Any other side value is a BUG: it logs, and it
+// hands back scratch fields, so nothing on the match is compared or written.
+func slotFields(m *state.BracketMatch, side domain.MatchSide) (name, id *string) {
+ switch side {
+ case domain.MatchSideA:
+ return &m.SideA, &m.SideAID
+ case domain.MatchSideB:
+ return &m.SideB, &m.SideBID
+ case domain.MatchSideNone:
+ return &m.Winner, &m.WinnerID
+ }
+ log.Printf("engine: BUG: bracket match %s: slotFields called with side %q; nothing is compared or written", m.ID, side)
+ var scratchName, scratchID string
+ return &scratchName, &scratchID
+}
+
// 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
@@ -304,9 +327,28 @@ func (e *Engine) resolveSlots(bracket *state.Bracket, resolver map[string]resolv
// 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) {
+ //
+ // paint writes the slot labelled label, which takes the competitor of side
+ // (domain.MatchSideA or B) or the Winner (domain.MatchSideNone). Its fields
+ // come from slotFields, so the compare, the log line and the write name the
+ // same ones. A side is written through seatBracketSide, which takes away the
+ // representative the team that leaves the slot held; the Winner is not a side
+ // and is written directly.
+ paint := func(label string, side domain.MatchSide) {
+ name, id := slotFields(m, side)
rf, ok := resolver[label]
- if !ok || (*name == rf.Name && *id == rf.ID) {
+ if !ok {
+ return
+ }
+ // A slot whose resolver entry has no id (a standings player of
+ // legacy data) keeps the id it holds, as seatBracketSide keeps it
+ // for an incoming "" and the Winner (a bye passes its competitor
+ // through with the id the side holds) must: it is already seated,
+ // and repainting it would count a change on every pool write that
+ // never converges, and wipe the Winner's id each time. Only that
+ // direction: a slot with no id against an entry that has one is
+ // still painted, which is the id backfill above.
+ if *name == rf.Name && (*id == rf.ID || rf.ID == "") {
return
}
if frozen[qualifierLabelPool(label)] != "" {
@@ -316,13 +358,17 @@ func (e *Engine) resolveSlots(bracket *state.Bracket, resolver map[string]resolv
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
+ if side == domain.MatchSideNone {
+ *name, *id = rf.Name, rf.ID
+ } else {
+ seatBracketSide(m, side, rf.Name, rf.ID)
+ }
n++
}
- paint(m.PlaceholderA, &m.SideA, &m.SideAID)
- paint(m.PlaceholderB, &m.SideB, &m.SideBID)
+ paint(m.PlaceholderA, domain.MatchSideA)
+ paint(m.PlaceholderB, domain.MatchSideB)
// Winner-only changes count too, so a bye-propagated Winner fix is persisted.
- paint(m.PlaceholderWinner, &m.Winner, &m.WinnerID)
+ paint(m.PlaceholderWinner, domain.MatchSideNone)
}
for ri := range bracket.Rounds {
for mi := range bracket.Rounds[ri] {
@@ -461,7 +507,13 @@ func (e *Engine) ResolveQualifiedPools(compID string) (int, bool, error) {
resolvedNow := 0
allResolved := false
backfilled := false
- uerr := e.store.UpdateBracket(compID, func(bracket *state.Bracket) error {
+ // The picks the repaint takes from a re-seated match, for its history lines
+ // (bracket_seat_audit.go). This resolver repaints a slot whose pool moved
+ // outside the requalification planner (DELETE .../overrides writes the
+ // overrides alone), so it owns the record of what its re-seat took, written
+ // in the transaction that writes the bracket.
+ var clears []repPickClear
+ mutate := func(bracket *state.Bracket) error {
if bracket == nil || len(bracket.Rounds) == 0 {
return errMatchNotFound // nothing to resolve; signal no-save
}
@@ -486,7 +538,11 @@ func (e *Engine) ResolveQualifiedPools(compID string) (int, bool, error) {
// pools-times-winners layout this backfill reconstructs.
backfilled = backfillDrawPlaceholdersV1(bracket, poolNames, poolWinners)
}
+ before := repPickSnapshot(bracket, "")
n := e.resolveSlots(bracket, resolver)
+ // The repaint may have given a side another team and taken its pick
+ // with it. No reopen names it here: a locked match is never repainted.
+ clears = repPickClears(before, bracket)
allResolved = !bracketHasPoolPlaceholders(bracket)
if n == 0 && !backfilled {
@@ -500,6 +556,13 @@ func (e *Engine) ResolveQualifiedPools(compID string) (int, bool, error) {
bracket.Preview = false
}
return nil
+ }
+ uerr := e.store.WithTransaction(compID, func(tx state.StoreTx) error {
+ if err := tx.UpdateBracket(compID, mutate); err != nil {
+ return err
+ }
+ e.recordRepPickClears(tx, compID, clears, nil)
+ return nil
})
if uerr != nil && !errors.Is(uerr, errMatchNotFound) {
return 0, false, uerr
diff --git a/internal/engine/knockout_test.go b/internal/engine/knockout_test.go
index 8df9bf664..a28145d05 100644
--- a/internal/engine/knockout_test.go
+++ b/internal/engine/knockout_test.go
@@ -838,3 +838,133 @@ func TestResolveQualifiedPools_FivePoolsTwoWinners_ByePools(t *testing.T) {
assert.Empty(t, m.Winner, "empty slot %d has no winner to record", idx)
}
}
+
+// TestResolveSlots_PaintsTheSideItNames pins that resolveSlots paints each slot
+// into the fields its side names and no others: side A's placeholder lands on
+// SideA/SideAID, side B's on SideB/SideBID, and the winner paint on
+// Winner/WinnerID. It returns how many sides it changed, so a slot already
+// holding its resolved occupant must not be repainted (and must not count).
+func TestResolveSlots_PaintsTheSideItNames(t *testing.T) {
+ eng, _, _ := setupTestEngine(t)
+ resolver := map[string]resolvedFinisher{
+ "Pool A-1st": {Name: "Alice", ID: "id-a"},
+ "Pool B-1st": {Name: "Bob", ID: "id-b"},
+ "Pool C-1st": {Name: "Carol", ID: "id-c"},
+ }
+ tests := []struct {
+ name string
+ sideA string
+ sideAID string
+ sideB string
+ sideBID string
+ wantPainted int
+ }{
+ {name: "empty slots take their own occupants", wantPainted: 3},
+ {name: "side A already holding its occupant is not repainted", sideA: "Alice", sideAID: "id-a", wantPainted: 2},
+ {name: "side B already holding its occupant is not repainted", sideB: "Bob", sideBID: "id-b", wantPainted: 2},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ b := &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: "m1",
+ Status: state.MatchStatusScheduled,
+ PlaceholderA: "Pool A-1st",
+ PlaceholderB: "Pool B-1st",
+ PlaceholderWinner: "Pool C-1st",
+ SideA: tc.sideA,
+ SideAID: tc.sideAID,
+ SideB: tc.sideB,
+ SideBID: tc.sideBID,
+ }}}}
+ got := eng.resolveSlots(b, resolver)
+ m := b.Rounds[0][0]
+ assert.Equal(t, tc.wantPainted, got, "sides changed")
+ assert.Equal(t, "Alice", m.SideA)
+ assert.Equal(t, "id-a", m.SideAID)
+ assert.Equal(t, "Bob", m.SideB)
+ assert.Equal(t, "id-b", m.SideBID)
+ assert.Equal(t, "Carol", m.Winner)
+ assert.Equal(t, "id-c", m.WinnerID)
+ })
+ }
+}
+
+// A resolver entry with no id (a standings player of legacy data) names a side
+// whose id seatBracketSide keeps (an incoming "" beside the same name is a writer
+// that did not know the id). Such a side is already seated, so it is not counted
+// and not repainted on every pool write; a slot with no id against an entry that
+// has one is still painted, which is the id backfill.
+func TestResolveSlots_ASideTheResolverKnowsByNameOnlyIsSeated(t *testing.T) {
+ tests := []struct {
+ name string
+ slotID string
+ resolverID string
+ wantPainted int
+ wantID string
+ }{
+ {name: "a stored id against a resolver with none converges", slotID: "id-a", resolverID: "", wantPainted: 0, wantID: "id-a"},
+ {name: "no stored id against a resolver with one is the id backfill", slotID: "", resolverID: "id-a", wantPainted: 1, wantID: "id-a"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ eng, _, _ := setupTestEngine(t)
+ b := &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: "m1",
+ Status: state.MatchStatusScheduled,
+ PlaceholderA: "Pool A-1st",
+ PlaceholderB: "Pool B-1st",
+ SideA: "Alice",
+ SideAID: tc.slotID,
+ SideB: "Bob",
+ SideBID: "id-b",
+ }}}}
+ resolver := map[string]resolvedFinisher{
+ "Pool A-1st": {Name: "Alice", ID: tc.resolverID},
+ "Pool B-1st": {Name: "Bob", ID: "id-b"},
+ }
+ got := eng.resolveSlots(b, resolver)
+ assert.Equal(t, tc.wantPainted, got, "sides changed")
+ assert.Equal(t, "Alice", b.Rounds[0][0].SideA)
+ assert.Equal(t, tc.wantID, b.Rounds[0][0].SideAID)
+ })
+ }
+}
+
+// The Winner converges the same way a side does. A bye passes its competitor
+// through as the match's Winner with the id the side holds, and a resolver entry
+// with no id must not repaint it: the repaint wiped WinnerID, the legacy upgrade
+// restored it on the next start, and the next pool write wiped it again. A
+// Winner with no id against an entry that has one is still written, which is
+// the id backfill.
+func TestResolveSlots_TheWinnerTheResolverKnowsByNameOnlyIsSeated(t *testing.T) {
+ tests := []struct {
+ name string
+ winnerID string
+ resolverID string
+ wantPainted int
+ wantID string
+ }{
+ {name: "a stored id against a resolver with none converges", winnerID: "id-a", resolverID: "", wantPainted: 0, wantID: "id-a"},
+ {name: "no stored id against a resolver with one is the id backfill", winnerID: "", resolverID: "id-a", wantPainted: 1, wantID: "id-a"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ eng, _, _ := setupTestEngine(t)
+ b := &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: "m1",
+ Status: state.MatchStatusCompleted,
+ PlaceholderA: "Pool A-1st",
+ PlaceholderWinner: "Pool A-1st",
+ SideA: "Alice",
+ SideAID: "id-a",
+ Winner: "Alice",
+ WinnerID: tc.winnerID,
+ }}}}
+ resolver := map[string]resolvedFinisher{"Pool A-1st": {Name: "Alice", ID: tc.resolverID}}
+ got := eng.resolveSlots(b, resolver)
+ assert.Equal(t, tc.wantPainted, got, "slots changed")
+ assert.Equal(t, "Alice", b.Rounds[0][0].Winner)
+ assert.Equal(t, tc.wantID, b.Rounds[0][0].WinnerID)
+ })
+ }
+}
diff --git a/internal/engine/landed_member_ids_pin_test.go b/internal/engine/landed_member_ids_pin_test.go
new file mode 100644
index 000000000..f2abd86e4
--- /dev/null
+++ b/internal/engine/landed_member_ids_pin_test.go
@@ -0,0 +1,355 @@
+package engine
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
+ "github.com/gitrgoliveira/bracket-creator/internal/state"
+)
+
+// The self-run member judge (mobileapp.landedMembersRefusal) is only asked
+// about a write whose payload INTRODUCES a member id (introducesJudgedMember),
+// and that gate is equivalent to judging every write only while the ENGINE never
+// lands a member id the judge would refuse that the payload did not introduce.
+// This is a PIN, green by design: it holds the invariant from this side, so a
+// change inside the write that fills ids from somewhere new fails here rather
+// than silently slipping past a gate that does not look.
+//
+// The id fillers it exercises inside the write are preserveKachinukiMemberIDs
+// (the stored row's id at the same position and name), ResolveMemberWinnerID /
+// ReconcileWinnerMemberID (the winner's id, derived from the row's own landed
+// side ids, which the judge accepts) and the merge's copies of the groups a
+// write does not apply, state.CopyGroup and SetRepPick (the stored row's ids
+// and picks). The load repair (state.resolveSubMemberIDs) runs once per
+// competition per process, before the write under test, on rows that already
+// carry their ids, and fills only a row's own team's members.
+//
+// What the fixture is built to reach:
+// - Both teams' starting lineups and team members are saved. The store mints
+// the members' ids, so no row carries one: an id taken from a lineup or from
+// the members by anything inside the write would be a FOREIGN id (neither
+// the payload's nor the stored match's), which the invariant refuses.
+// MaybeAdvanceKachinuki is NOT called: its lineup ids are added after the
+// transaction, in the handler, and this pins what the write itself lands.
+// - The match is stored at mmT0 and every write but one is stamped, so the
+// merge orders it. The "older" cases send a write stamped between mmT0 and
+// the stamp of the group it rewrites, with another group applying, so that
+// group is HELD and copied back from the stored match; each case states
+// which groups it expects held, and the match's history is asked.
+// - Kachinuki runs on a league (the pool branch) and on a knockout (the
+// bracket branch). The representative bout runs on the knockout only, the
+// one place production writes it (AddDaihyosen refuses a pool match).
+// - Nothing here sends startOnly, so keepQueuedScore (a start) is not pinned.
+//
+// The invariant, per landed row and judged field (either side's member id, and
+// the winner's): the id is empty, or the payload's row at that position carried
+// it, or the stored row at that position held it, or (the winner only) it is one
+// of the landed row's own side ids.
+
+// assertNoIntroducedMemberID fails for any landed member id at a judged field
+// that neither the payload nor the stored match supplied.
+func assertNoIntroducedMemberID(t *testing.T, stored, payload, landed []state.SubMatchResult) {
+ t.Helper()
+ supplied := func(got string, field func(state.SubMatchResult) string, row state.SubMatchResult) bool {
+ return got == "" ||
+ got == field(state.SubResultAt(payload, row.Position)) ||
+ got == field(state.SubResultAt(stored, row.Position))
+ }
+ sideA := func(r state.SubMatchResult) string { return r.SideAMemberID }
+ sideB := func(r state.SubMatchResult) string { return r.SideBMemberID }
+ winner := func(r state.SubMatchResult) string { return r.WinnerMemberID }
+ for _, row := range landed {
+ assert.True(t, supplied(row.SideAMemberID, sideA, row),
+ "position %d: side A member id %q was supplied by neither the payload nor the stored row", row.Position, row.SideAMemberID)
+ assert.True(t, supplied(row.SideBMemberID, sideB, row),
+ "position %d: side B member id %q was supplied by neither the payload nor the stored row", row.Position, row.SideBMemberID)
+ ownFighter := row.WinnerMemberID == row.SideAMemberID || row.WinnerMemberID == row.SideBMemberID
+ assert.True(t, supplied(row.WinnerMemberID, winner, row) || ownFighter,
+ "position %d: winner member id %q was supplied by neither the payload, the stored row, nor the row's own landed fighters", row.Position, row.WinnerMemberID)
+ }
+}
+
+// pinBetween is a stamp after the match was stored (mmT0) and before the stamps
+// the seeding writes give the groups they change (mmT1): a write made then is
+// older than those groups and newer than every other.
+const pinBetween = mmT0 + 30_000
+
+// pinIDs are the member ids a landed row must hold: its two fighters', and the
+// winner's.
+type pinIDs struct{ a, b, winner string }
+
+// pinWriteSpec is one stamped score write: when it was made, the groups it names
+// (nil: every group its rows carry, as an older client sends it), and its rows.
+type pinWriteSpec struct {
+ at int64
+ changed []string
+ rows []state.SubMatchResult
+}
+
+// pinPeople are the people each team is given, in lineup order. The store mints
+// their member ids, which no row of the fixtures carries.
+var pinPeople = []struct {
+ teamID string
+ names []string
+}{
+ {wrTeamAID, []string{"Sato", "Ota", "Hara"}},
+ {wrTeamBID, []string{"Tanaka", "Ito", "Mori"}},
+}
+
+// pinHome seeds a team match of the given type, running since mmT0 and holding
+// the stored rows, on the pool branch (a league) or the bracket branch (a
+// knockout), with both teams' starting lineups and team members saved.
+func pinHome(t *testing.T, knockout bool, matchType state.TeamMatchType, stored []state.SubMatchResult) mmHome {
+ t.Helper()
+ eng, store, dir := setupTestEngine(t)
+ h := mmHome{eng: eng, store: store, dir: dir}
+ team := func(c *state.Competition) { c.Kind, c.TeamSize, c.TeamMatchType = "team", 3, matchType }
+ if knockout {
+ h.compID, h.matchID = "pin-ko", "m-r1-0"
+ c := &state.Competition{ID: h.compID, Name: "pin", Status: state.CompStatusKnockout}
+ team(c)
+ require.NoError(t, store.SaveCompetition(c))
+ } else {
+ h.compID, h.matchID = "pin-pool", "Pool A-0"
+ createTestCompetition(t, store, h.compID, "league", 3, func(c *state.Competition) { team(c); c.Status = state.CompStatusPools })
+ }
+ wrSaveTeams(t, store, h.compID)
+ for _, p := range pinPeople {
+ lineup := domain.TeamLineup{
+ TeamID: p.teamID, Round: 0,
+ Positions: map[domain.Position]string{}, MemberIDs: map[domain.Position]string{},
+ }
+ for i, name := range p.names {
+ member, err := store.AddTeamMember(h.compID, p.teamID, name)
+ require.NoError(t, err)
+ pos := domain.PositionNumbered(i + 1)
+ lineup.Positions[pos], lineup.MemberIDs[pos] = name, member.ID
+ }
+ require.NoError(t, store.SetTeamLineup(h.compID, lineup, 3))
+ }
+ if knockout {
+ require.NoError(t, store.SaveBracket(h.compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{
+ ID: h.matchID, SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID,
+ Status: state.MatchStatusRunning, ModifiedAt: mmT0, MatchNumber: 1,
+ SubResults: state.CloneSubResults(stored),
+ }}}}))
+ return h
+ }
+ require.NoError(t, store.SavePoolMatches(h.compID, []state.MatchResult{{
+ ID: h.matchID, SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID,
+ Status: state.MatchStatusRunning, ModifiedAt: mmT0, SubResults: state.CloneSubResults(stored),
+ }}))
+ return h
+}
+
+// pinWrite is the running score write a board makes. The write reshapes the rows
+// it is handed in place, so it is given a copy: the spec keeps what was sent.
+func pinWrite(h mmHome, s pinWriteSpec) *state.MatchResult {
+ w := mmRunning(h, s.at, s.changed...)
+ w.SubResults = state.CloneSubResults(s.rows)
+ return w
+}
+
+// pinHeld lists, sorted, the groups the last write in the match's history was
+// held on.
+func pinHeld(t *testing.T, entries []state.MatchHistoryEntry) []string {
+ t.Helper()
+ require.NotEmpty(t, entries)
+ var held []string
+ for group, outcome := range entries[len(entries)-1].Outcomes {
+ if outcome == state.HistoryOutcomeHeld {
+ held = append(held, group)
+ }
+ }
+ slices.Sort(held)
+ return held
+}
+
+func TestEngineLandsNoMemberIDThePayloadDidNotIntroduce(t *testing.T) {
+ // Sato (team Ryu) fought Tanaka and won; Sato now faces Ito.
+ kachinukiRows := func() []state.SubMatchResult {
+ return []state.SubMatchResult{
+ {Position: 1, SideA: "Sato", SideAMemberID: "k-sato", SideB: "Tanaka", SideBMemberID: "o-tanaka",
+ IpponsA: []string{"M"}, Winner: "Sato", WinnerMemberID: "k-sato", Decision: "fought"},
+ {Position: 2, SideA: "Sato", SideAMemberID: "k-sato", SideB: "Ito", SideBMemberID: "o-ito"},
+ }
+ }
+ // ito has Ito win bout 2 against Sato.
+ ito := func(rows []state.SubMatchResult) []state.SubMatchResult {
+ rows[1].IpponsB = []string{"K"}
+ rows[1].Winner, rows[1].WinnerMemberID, rows[1].Decision = "Ito", "o-ito", "fought"
+ return rows
+ }
+ // satoTwice gives Sato a second point in bout 1.
+ satoTwice := func(rows []state.SubMatchResult) []state.SubMatchResult {
+ rows[0].IpponsA = []string{"M", "K"}
+ return rows
+ }
+ fixedRows := func() []state.SubMatchResult {
+ return []state.SubMatchResult{
+ {Position: 1, SideA: "Sato", SideAMemberID: "k1", SideB: "Tanaka", SideBMemberID: "o1",
+ IpponsA: []string{"M"}, Winner: "Sato", WinnerMemberID: "k1", Decision: "fought"},
+ }
+ }
+ // repBout is the representative-bout row, with the picks and the point the
+ // board knows of (none: ""). A point is Ryu's, so Ryu is its winner.
+ repBout := func(sideAID, sideBID, winnerID string, point bool) state.SubMatchResult {
+ if !point {
+ return repRow(sideAID, sideBID, nil, nil)
+ }
+ row := repRow(sideAID, sideBID, []string{"M"}, nil)
+ row.Winner, row.WinnerMemberID = wrTeamA, winnerID
+ return row
+ }
+ // stripIDs is a write that names its fighters and sends no member id.
+ stripIDs := func(rows []state.SubMatchResult) []state.SubMatchResult {
+ out := make([]state.SubMatchResult, len(rows))
+ for i, r := range rows {
+ r.SideAMemberID, r.SideBMemberID, r.WinnerMemberID = "", "", ""
+ out[i] = r
+ }
+ return out
+ }
+ // repPicks is the write that seeds both representatives' picks at mmT1 (each
+ // side's pick is its own group), beside the stored numbered bout.
+ repPicks := pinWriteSpec{
+ at: mmT1,
+ changed: []string{repBoutGroup, repPickAName, repPickBName},
+ rows: append(fixedRows(), repBout("k-rep", "o-rep", "", false)),
+ }
+
+ tests := []struct {
+ name string
+ matchType state.TeamMatchType
+ // bracketOnly keeps a case off the pool branch: the representative bout
+ // is a knockout shape.
+ bracketOnly bool
+ stored []state.SubMatchResult
+ // setup are the writes made before the one under test.
+ setup []pinWriteSpec
+ write pinWriteSpec
+ // held are the groups the write under test must be held on, per the
+ // match's history: without them the held-group copies the "older"
+ // cases are built for would not be known to have run.
+ held []string
+ // inherited are the ids the landed rows must hold, by position, so the
+ // pin is not vacuously true of a write that dropped every id: the
+ // carry-over mechanisms it guards are really exercised.
+ inherited map[int]pinIDs
+ }{
+ {
+ name: "kachinuki: a write echoing the stored member ids",
+ matchType: state.TeamMatchTypeKachinuki,
+ stored: kachinukiRows(),
+ write: pinWriteSpec{at: mmT1, changed: []string{"bout:2"}, rows: ito(kachinukiRows())},
+ inherited: map[int]pinIDs{1: {"k-sato", "o-tanaka", "k-sato"}, 2: {"k-sato", "o-ito", "o-ito"}},
+ },
+ {
+ name: "kachinuki: a write whose rows carry names and no member ids, over a position the stored match lacks",
+ matchType: state.TeamMatchTypeKachinuki,
+ stored: kachinukiRows(),
+ write: pinWriteSpec{at: mmT1, rows: append(stripIDs(ito(kachinukiRows())),
+ state.SubMatchResult{Position: 3, SideA: "Ota", SideB: "Ito"})},
+ inherited: map[int]pinIDs{1: {"k-sato", "o-tanaka", "k-sato"}, 2: {"k-sato", "o-ito", "o-ito"}, 3: {}},
+ },
+ {
+ name: "kachinuki: the same write from a client that stamps nothing",
+ matchType: state.TeamMatchTypeKachinuki,
+ stored: kachinukiRows(),
+ write: pinWriteSpec{rows: append(stripIDs(ito(kachinukiRows())),
+ state.SubMatchResult{Position: 3, SideA: "Ota", SideB: "Ito"})},
+ inherited: map[int]pinIDs{1: {"k-sato", "o-tanaka", "k-sato"}, 2: {"k-sato", "o-ito", "o-ito"}, 3: {}},
+ },
+ {
+ name: "kachinuki: a write older than bout 1's last change, whose rows carry no member ids",
+ matchType: state.TeamMatchTypeKachinuki,
+ stored: kachinukiRows(),
+ setup: []pinWriteSpec{{at: mmT1, changed: []string{"bout:1"}, rows: satoTwice(kachinukiRows())}},
+ write: pinWriteSpec{at: pinBetween, rows: stripIDs(ito(kachinukiRows()))},
+ held: []string{"bout:1"},
+ inherited: map[int]pinIDs{1: {"k-sato", "o-tanaka", "k-sato"}, 2: {"k-sato", "o-ito", "o-ito"}},
+ },
+ {
+ name: "fixed order with a representative bout: a write echoing the stored member ids",
+ matchType: state.TeamMatchTypeFixed,
+ bracketOnly: true,
+ stored: fixedRows(),
+ setup: []pinWriteSpec{repPicks},
+ write: pinWriteSpec{at: mmT2,
+ rows: append(satoTwice(fixedRows()), repBout("k-rep", "o-rep", "k-rep", true))},
+ inherited: map[int]pinIDs{1: {"k1", "o1", "k1"}, state.DaihyosenSubPosition: {"k-rep", "o-rep", "k-rep"}},
+ },
+ {
+ name: "fixed order with a representative bout: a point on it from a board that holds no picks",
+ matchType: state.TeamMatchTypeFixed,
+ bracketOnly: true,
+ stored: fixedRows(),
+ setup: []pinWriteSpec{repPicks},
+ write: pinWriteSpec{at: mmT2, changed: []string{repBoutGroup},
+ rows: append(stripIDs(fixedRows()), repBout("", "", "", true))},
+ inherited: map[int]pinIDs{1: {"k1", "o1", "k1"}, state.DaihyosenSubPosition: {"k-rep", "o-rep", "k-rep"}},
+ },
+ {
+ name: "fixed order with a representative bout: a write older than the picks, whose representative row carries none",
+ matchType: state.TeamMatchTypeFixed,
+ bracketOnly: true,
+ stored: fixedRows(),
+ setup: []pinWriteSpec{repPicks},
+ write: pinWriteSpec{at: pinBetween,
+ rows: append(satoTwice(fixedRows()), repBout("", "", "", true))},
+ held: []string{repBoutGroup, repPickAName, repPickBName},
+ inherited: map[int]pinIDs{1: {"k1", "o1", "k1"}, state.DaihyosenSubPosition: {"k-rep", "o-rep", ""}},
+ },
+ {
+ name: "fixed order with a representative bout: a write from a sheet that does not list the row",
+ matchType: state.TeamMatchTypeFixed,
+ bracketOnly: true,
+ stored: fixedRows(),
+ setup: []pinWriteSpec{repPicks},
+ write: pinWriteSpec{at: mmT2, changed: []string{"bout:1"}, rows: satoTwice(fixedRows())},
+ inherited: map[int]pinIDs{1: {"k1", "o1", "k1"}, state.DaihyosenSubPosition: {"k-rep", "o-rep", ""}},
+ },
+ }
+ branches := []struct {
+ name string
+ knockout bool
+ }{{"pool", false}, {"knockout", true}}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for _, b := range branches {
+ if tt.bracketOnly && !b.knockout {
+ continue
+ }
+ t.Run(b.name, func(t *testing.T) {
+ h := pinHome(t, b.knockout, tt.matchType, tt.stored)
+ for _, s := range tt.setup {
+ require.NoError(t, h.write(pinWrite(h, s)))
+ }
+ // The match as it stands before the write under test: after the
+ // setup writes it is no longer what the fixture stored.
+ before := h.load(t)
+ require.NotEmpty(t, before.SubResults)
+
+ require.NoError(t, h.write(pinWrite(h, tt.write)),
+ "the write applies in part: a write held whole answers superseded")
+
+ landed := h.load(t)
+ require.NotEmpty(t, landed.SubResults)
+ assertNoIntroducedMemberID(t, before.SubResults, tt.write.rows, landed.SubResults)
+ assert.ElementsMatch(t, tt.held, pinHeld(t, h.history(t)),
+ "the groups the merge held, per the match's history")
+
+ for position, want := range tt.inherited {
+ row := state.SubResultAt(landed.SubResults, position)
+ assert.Equal(t, want, pinIDs{row.SideAMemberID, row.SideBMemberID, row.WinnerMemberID},
+ "position %d: the ids the write was meant to carry over are still there", position)
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/internal/engine/match_history.go b/internal/engine/match_history.go
index a7ae6a9d9..67051c2eb 100644
--- a/internal/engine/match_history.go
+++ b/internal/engine/match_history.go
@@ -33,6 +33,13 @@ const (
doorKachinukiAdvance = "kachinuki-advance"
doorKachinukiRemove = "kachinuki-remove-bout"
doorDownstreamReopen = "downstream-reopen"
+ // doorReseat names the line a match gets when a bracket door gave one of
+ // its sides another team and so took that side's representative pick
+ // (bracket_seat_audit.go). A downstream match the same write reopens
+ // names the pick on its doorDownstreamReopen line instead. The SPA words
+ // it in DOOR_WORDS: "Side given another team by a correction to an
+ // earlier match".
+ doorReseat = "reseat"
)
// clientDoors are the doors whose writer may legitimately state no groups:
@@ -200,6 +207,9 @@ func noteServerBoutChanges(result *state.MatchResult, before, stored []state.Sub
result.Changed = append(result.Changed, g)
}
}
+ // No clause for the representatives' pick groups: nothing the server does
+ // between the writer's rows and this call changes a pick (a kachinuki merge
+ // and the match-level padding keep the representative row as sent).
}
// serverNowMs is the stamp a direct writer gives the groups it changes: the
diff --git a/internal/engine/match_merge.go b/internal/engine/match_merge.go
index 4b1588f0d..48b9dcf35 100644
--- a/internal/engine/match_merge.go
+++ b/internal/engine/match_merge.go
@@ -125,6 +125,13 @@ func defaultChangedGroups(stored, incoming *state.MatchResult, nilSubsClear bool
for _, p := range positions {
out = append(out, state.BoutGroup(p))
}
+ // The representatives sit on the representative bout's row: a write that
+ // says nothing about that row says nothing about either side's pick.
+ if !slices.Contains(out, state.BoutGroup(state.DaihyosenSubPosition)) {
+ out = slices.DeleteFunc(out, func(g string) bool {
+ return g == state.GroupRepPickA || g == state.GroupRepPickB
+ })
+ }
return out
}
@@ -299,6 +306,13 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic
if holdReason == "" && finishAtomic {
holdReason = HoldReasonFinishAtomic
}
+ // What the write said about the representatives, before the loop below
+ // copies stored rows over its own.
+ payloadPickA, payloadPickB := incoming.RepPicks()
+ // A write that names the representative bout and carries no row removes
+ // it: the daihyosen DELETE, or a stale whole-match write that omits it.
+ removalWrite := inChanged[state.BoutGroup(state.DaihyosenSubPosition)] &&
+ state.DaihyosenSubIndex(incoming.SubResults) < 0 && state.DaihyosenSubIndex(stored.SubResults) >= 0
rep := &state.MergeReport{Stamp: stamp, Changed: changed, HoldReason: holdReason}
storedStamps := state.MaterializedGroupStamps(stored.GroupStamps, stored.ModifiedAt, state.SubPositions(stored.SubResults))
stamps := state.CloneGroupStamps(storedStamps)
@@ -438,6 +452,12 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic
}
}
+ // The representatives sit on the representative bout's row, so what
+ // became of the row is judged once every hold above has had its say.
+ if mh.settleOrphanPicks(payloadPickA, payloadPickB, removalWrite, stamp) {
+ recomputeModifiedAt = true
+ }
+
for _, g := range rep.Applied {
if !state.GroupDiffers(stored, incoming, g) {
rep.Unchanged = append(rep.Unchanged, g)
@@ -476,6 +496,13 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic
if slices.Contains(rep.Unchanged, state.GroupResult) {
rep.ResultChanged = false
}
+ // The winner's member id is the bout's, the representatives their own
+ // change: after both are settled, the id is derived again from the winner's
+ // name and the stored picks, so it is exactly the winning side's pick (or
+ // empty), whatever id the write carried.
+ if i := state.DaihyosenSubIndex(incoming.SubResults); i >= 0 {
+ incoming.SubResults[i].ReconcileWinnerMemberID()
+ }
if len(stamps) == 0 {
stamps = nil
}
@@ -679,11 +706,7 @@ func (h *mergeHold) holdGroups(reason string, groups ...string) bool {
}
reportHeld(h.rep, g, h.stored, h.incoming, h.rawValues[g])
state.CopyGroup(h.incoming, h.stored, g)
- if s, ok := h.storedStamps[g]; ok {
- h.stamps[g] = s
- } else {
- delete(h.stamps, g)
- }
+ h.restoreStamp(g)
}
if !heldSomething {
return false
@@ -738,6 +761,102 @@ func (h *mergeHold) holdDefaultWinScoring(decision string) {
h.rep.StandingDecision = decision
}
+// restoreStamp puts group's stamp back to the one the stored match had, or
+// takes it out again when the stored match never stamped it.
+func (h *mergeHold) restoreStamp(group string) {
+ if s, ok := h.storedStamps[group]; ok {
+ h.stamps[group] = s
+ } else {
+ delete(h.stamps, group)
+ }
+}
+
+// settleOrphanPicks is the representatives' rule, asked ONCE after every hold
+// has had its say (a hold can take the representative bout's row out of the
+// write, or put the stored one back): a pick lives on that row, so what became
+// of the row decides what became of each side's pick. payloadA and payloadB are
+// the ids the write carried, captured before the merge copied stored rows over
+// its own, and removalWrite says it named the bout and carried no row.
+//
+// - The row is gone and the write introduced a pick for a side (a row its
+// stamp could not bring back, or one a hold took out of the write: R4, a
+// decision that stands): the pick is held with its id, never applied onto
+// nothing, and the side keeps the stamp it had.
+// - The write removed the row and applied, and a pick stamped AFTER the
+// removal is stored (J): in stamp order the removal came first and the pick
+// landed on no bout, which is what the line above does when they arrive
+// that way round. The stored pick is moved to the history under its own
+// stamp with its id (a DisplacedChange, HoldReasonRepBoutRemoved), and the
+// side is dated by the removal, the last thing that happened to the row, so
+// both orders end in one state. It returns true then: the match no longer
+// holds the newer stamp.
+// - The write removed the row and a hold kept it (a point made after the
+// removal is stored): a removal is one action, so its picks stay with the
+// row that stays, held with the bout instead of clearing a pick the row
+// still carries.
+func (h *mergeHold) settleOrphanPicks(payloadA, payloadB string, removalWrite bool, stamp int64) bool {
+ hasRow := state.DaihyosenSubIndex(h.incoming.SubResults) >= 0
+ storedRow := state.DaihyosenSubIndex(h.stored.SubResults) >= 0
+ storedA, storedB := h.stored.RepPicks()
+ displaced := map[int64]map[string]json.RawMessage{}
+ for _, side := range []struct {
+ side domain.MatchSide
+ payload, stored string
+ }{{domain.MatchSideA, payloadA, storedA}, {domain.MatchSideB, payloadB, storedB}} {
+ g := state.RepPickGroup(side.side)
+ applied := slices.Index(h.rep.Applied, g)
+ switch {
+ case hasRow:
+ if !removalWrite || applied < 0 {
+ continue
+ }
+ h.rep.Applied = slices.Delete(h.rep.Applied, applied, applied+1)
+ // What the write said was an empty pick: held if the row still
+ // carries one, an echo if it does not.
+ reportHeld(h.rep, g, h.stored, &state.MatchResult{}, nil)
+ state.CopyGroup(h.incoming, h.stored, g)
+ h.restoreStamp(g)
+ case side.payload != "" && applied >= 0:
+ h.rep.Applied = slices.Delete(h.rep.Applied, applied, applied+1)
+ ghost := &state.MatchResult{SubResults: []state.SubMatchResult{{Position: state.DaihyosenSubPosition}}}
+ ghost.SetRepPick(side.side, side.payload)
+ reportHeld(h.rep, g, h.stored, ghost, nil)
+ h.restoreStamp(g)
+ case storedRow && side.stored != "" && slices.Contains(h.rep.Held, g):
+ at := h.stored.GroupStamp(g)
+ if displaced[at] == nil {
+ displaced[at] = map[string]json.RawMessage{}
+ }
+ displaced[at][g] = state.GroupValue(h.stored, g)
+ // The removal's own (empty) pick now applies, as it does when it
+ // arrives first, and the newer one is the history's.
+ i := slices.Index(h.rep.Held, g)
+ h.rep.Held = slices.Delete(h.rep.Held, i, i+1)
+ delete(h.rep.HeldValues, g)
+ if len(h.rep.HeldValues) == 0 {
+ h.rep.HeldValues = nil
+ }
+ if applied < 0 {
+ h.rep.Applied = append(h.rep.Applied, g)
+ }
+ if stamp > 0 {
+ h.stamps[g] = stamp
+ }
+ }
+ }
+ stamps := make([]int64, 0, len(displaced))
+ for at := range displaced {
+ stamps = append(stamps, at)
+ }
+ slices.Sort(stamps)
+ for _, at := range stamps {
+ h.rep.Displaced = append(h.rep.Displaced, state.DisplacedChange{
+ Stamp: at, Values: displaced[at], Reason: HoldReasonRepBoutRemoved,
+ })
+ }
+ return len(stamps) > 0
+}
+
// needsWinnerReason is the history reason of a change held because the
// match would be left with no winner it must have.
func needsWinnerReason(mc mergeCtx) string {
@@ -782,6 +901,13 @@ func defaultWinStandsReason(decision string) string {
// rule (R2's withdrawalOutranked) and never reaches this one.
const HoldReasonFinishAtomic = "a finish and the scoreline it stood on are kept together"
+// HoldReasonRepBoutRemoved is the history reason of a representative picked
+// after the representative bout was removed (stamp order: the removal first,
+// the pick onto a bout that no longer existed), which a removal arriving
+// second moves out of the match. It is no needs_winner reason: the answer
+// carries displacedGroups for it and no heldReason.
+const HoldReasonRepBoutRemoved = "the representative bout was removed"
+
// completesMatch reports whether the merged write leaves the match finished
// (an empty status completes a bracket match, effectiveBracketWriteStatus).
func completesMatch(m *state.MatchResult, mc mergeCtx) bool {
diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go
new file mode 100644
index 000000000..6b42f4daa
--- /dev/null
+++ b/internal/engine/match_merge_rep_picks_test.go
@@ -0,0 +1,629 @@
+package engine
+
+// bc-mrgc, operator ruling: "every change is dated and ordered; a change must
+// only ever apply what it changed." The two representatives a team match's
+// representative bout is fought by (SideAMemberID/SideBMemberID on the row at
+// position -1) are each their own dated change, state.GroupRepPickA and
+// state.GroupRepPickB, ordered apart from each other and from the bout's score
+// and result: a point never alters a pick, a pick never alters the point or the
+// other side's pick, and an older change of any is kept in the match's history.
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
+ "github.com/gitrgoliveira/bracket-creator/internal/state"
+)
+
+const (
+ repBoutGroup = "bout:-1"
+ // Each side's pick is its own group (side A is Aka, side B is Shiro).
+ repPickAName = "repPickA"
+ repPickBName = "repPickB"
+)
+
+// repRow is a representative-bout row as a board sends it: the TEAM names
+// (placeHt needs them), the sub-decision, and whatever ids / marks the board
+// knows about.
+func repRow(sideAID, sideBID string, ipponsA, ipponsB []string) state.SubMatchResult {
+ return state.SubMatchResult{
+ Position: state.DaihyosenSubPosition, SideA: wrTeamA, SideB: wrTeamB,
+ SideAMemberID: sideAID, SideBMemberID: sideBID,
+ IpponsA: ipponsA, IpponsB: ipponsB,
+ Decision: string(domain.DecisionDaihyosen),
+ }
+}
+
+// repWrite is a running write from a board that changed the named groups and
+// carries one representative row.
+func repWrite(h mmHome, at int64, row state.SubMatchResult, changed ...string) *state.MatchResult {
+ w := mmRunning(h, at, changed...)
+ w.SubResults = []state.SubMatchResult{row}
+ return w
+}
+
+// seedPick records Carol as the Ryu representative at mmT1 (the row is added
+// with its pick, as the picker does on a bout that already exists).
+func seedPick(t *testing.T, h mmHome) {
+ t.Helper()
+ require.NoError(t, h.write(repWrite(h, mmT1, repRow("carol", "", nil, nil), repBoutGroup, repPickAName)))
+ got := h.load(t)
+ require.Len(t, got.SubResults, 1)
+ require.Equal(t, "carol", got.SubResults[0].SideAMemberID, "setup: the pick is stored")
+}
+
+// repStamp is the stored stamp of group on the match under test, read through
+// GroupStamp (never the map keys: a side with no key reads the bout row's date).
+func repStamp(t *testing.T, h mmHome, group string) int64 {
+ t.Helper()
+ m := h.load(t)
+ return m.GroupStamp(group)
+}
+
+// Device 1 picks Carol; device 2, whose push has not arrived, taps a point on
+// the representative bout a moment later. Its write is newer and carries its
+// whole row view, which has no ids: the point applies and the pick stays.
+func TestMerge_RepPoint_KeepsAnotherDevicesPick(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+ seedPick(t, h)
+
+ require.NoError(t, h.write(repWrite(h, mmT1+400, repRow("", "", []string{"M"}, nil), repBoutGroup)))
+
+ row := h.load(t).SubResults[0]
+ assert.Equal(t, []string{"M"}, row.IpponsA, "the point applies")
+ assert.Equal(t, "carol", row.SideAMemberID, "the pick is not the point's to erase")
+ })
+}
+
+// The reverse: a pick made after a point, on a board that never saw the point.
+func TestMerge_RepPick_KeepsAnotherDevicesPoint(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+ require.NoError(t, h.write(repWrite(h, mmT1, repRow("", "", []string{"M"}, nil), repBoutGroup)))
+
+ require.NoError(t, h.write(repWrite(h, mmT2, repRow("carol", "", nil, nil), repPickAName)))
+
+ row := h.load(t).SubResults[0]
+ assert.Equal(t, "carol", row.SideAMemberID, "the pick applies")
+ assert.Equal(t, []string{"M"}, row.IpponsA, "the point is not the pick's to erase")
+ })
+}
+
+// A stale pick is held and kept in the history; a newer clear clears.
+func TestMerge_RepPick_StaleIsHeldAndANewerClearClears(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+ require.NoError(t, h.write(repWrite(h, mmT2, repRow("carol", "", nil, nil), repBoutGroup, repPickAName)))
+
+ err := h.write(repWrite(h, mmT1, repRow("dana", "", nil, nil), repPickAName))
+ require.ErrorIs(t, err, ErrMatchSuperseded, "its one change is outranked")
+ assert.Equal(t, []string{repPickAName}, HeldGroupsOf(err))
+ assert.Equal(t, "carol", h.load(t).SubResults[0].SideAMemberID, "the newer pick stands")
+ entries := h.history(t)
+ held := entries[len(entries)-1]
+ assert.Equal(t, state.HistoryOutcomeHeld, held.Outcomes[repPickAName])
+ assert.JSONEq(t, `{"sideAMemberId":"dana"}`, string(held.Held[repPickAName]),
+ "the older pick is kept, never discarded")
+
+ // A deliberate clear is a change to the pick: the row is sent without
+ // the id and the change is named.
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("", "", nil, nil), repPickAName)))
+ assert.Empty(t, h.load(t).SubResults[0].SideAMemberID, "a newer clear clears")
+ })
+}
+
+// A write that carries no bout list and names nothing (an older client) says
+// nothing about the picks.
+func TestMerge_RepPicks_SurviveAWriteThatNamesNothingAndSendsNoBouts(t *testing.T) {
+ h := mmTeam(t, true)
+ seedPick(t, h)
+
+ w := mmRunning(h, mmT2) // Changed nil, SubResults nil
+ w.IpponsA = []string{"K"}
+ require.NoError(t, h.write(w))
+
+ m := h.load(t)
+ require.Len(t, m.SubResults, 1)
+ assert.Equal(t, "carol", m.SubResults[0].SideAMemberID)
+ assert.Equal(t, []string{"K"}, m.IpponsA)
+}
+
+// A write that names nothing but carries the row (bulk-score, an older
+// client) is ordered like the others: picks it has no newer opinion on stay.
+func TestMerge_RepPicks_NamelessWriteIsOrderedByStamp(t *testing.T) {
+ h := mmTeam(t, true)
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("carol", "", nil, nil), repBoutGroup, repPickAName)))
+
+ stale := repWrite(h, mmT2, repRow("dana", "", []string{"M"}, nil))
+ stale.Changed = nil
+ stale.WriteDoor = DoorBulkScore
+ _ = h.write(stale)
+
+ assert.Equal(t, "carol", h.load(t).SubResults[0].SideAMemberID, "the older pick does not replace the newer one")
+}
+
+// The winner's member id belongs to the bout's result, but a stored one must
+// name one of the stored picks (or be empty): a pick that changes under it
+// re-derives it from the winning side.
+func TestMerge_RepPick_ReDerivesTheWinnerMemberID(t *testing.T) {
+ h := mmTeam(t, true)
+ won := repRow("carol", "", []string{"M"}, nil)
+ won.Winner, won.WinnerMemberID = wrTeamA, "carol"
+ require.NoError(t, h.write(repWrite(h, mmT1, won, repBoutGroup, repPickAName)))
+
+ require.NoError(t, h.write(repWrite(h, mmT2, repRow("dana", "", nil, nil), repPickAName)))
+
+ row := h.load(t).SubResults[0]
+ assert.Equal(t, "dana", row.SideAMemberID)
+ assert.Equal(t, wrTeamA, row.Winner, "the bout's result is the point's, untouched")
+ assert.Equal(t, "dana", row.WinnerMemberID, "re-derived from the winning side and the stored pick")
+
+ // The pick cleared under a recorded winner id leaves no dangling id.
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("", "", nil, nil), repPickAName)))
+ row = h.load(t).SubResults[0]
+ assert.Empty(t, row.SideAMemberID)
+ assert.Empty(t, row.WinnerMemberID, "a winner id naming no stored pick is empty")
+}
+
+// A winner id that names the LOSING side's pick is as wrong as one naming nobody:
+// the merge derives it from the winner's NAME and the stored picks alone, never
+// through the id it is checking (id-first attribution would credit the side the
+// id names). The real board stamps the winner and its id together, so a
+// disagreeing pair is a crafted or confused write.
+func TestMerge_RepBout_WinnerMemberIDFollowsTheWinnersName(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ t.Run("an id naming the other side's pick is replaced by the winning side's", func(t *testing.T) {
+ h := mmTeam(t, knockout)
+ won := repRow("x", "y", []string{"M"}, nil)
+ won.Winner, won.WinnerMemberID = wrTeamB, "y"
+ require.NoError(t, h.write(repWrite(h, mmT1, won, repBoutGroup, repPickAName, repPickBName)))
+ require.Equal(t, "y", h.load(t).SubResults[0].WinnerMemberID, "setup: team B won, its pick y is the winner id")
+
+ crafted := repRow("x", "y", []string{"M"}, nil)
+ crafted.Winner, crafted.WinnerMemberID = wrTeamB, "x"
+ require.NoError(t, h.write(repWrite(h, mmT2, crafted, repBoutGroup)))
+
+ row := h.load(t).SubResults[0]
+ assert.Equal(t, wrTeamB, row.Winner)
+ assert.Equal(t, "y", row.WinnerMemberID, "x is team A's pick: the winner id is the winning side's, y")
+ })
+
+ t.Run("an id naming a pick of the side that has none is cleared", func(t *testing.T) {
+ h := mmTeam(t, knockout)
+ won := repRow("", "y", []string{"M"}, nil)
+ won.Winner, won.WinnerMemberID = wrTeamA, "y"
+ require.NoError(t, h.write(repWrite(h, mmT1, won, repBoutGroup, repPickBName)))
+
+ row := h.load(t).SubResults[0]
+ assert.Equal(t, wrTeamA, row.Winner)
+ assert.Equal(t, "y", row.SideBMemberID, "setup: only team B has a pick")
+ assert.Empty(t, row.WinnerMemberID, "team A won and has no pick: y is the loser's, so there is no winner id")
+ })
+ })
+}
+
+// The winner's member id is derived state: the merge works it out again from
+// the winner's name and the stored picks after every write, and the client's
+// bout:-1 comparison leaves it out. So a write that names nothing (bulk-score,
+// an older client) and echoes the representative row with the same score but
+// a different or absent winner id has changed nothing: it must not date the
+// bout (which would fence out an older real change) or count as scoring.
+func TestMerge_RepBout_WinnerMemberIDAloneIsAnEcho(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ for _, tc := range []struct{ name, id string }{
+ {"absent", ""},
+ {"the other side's pick", "dana"},
+ {"an id nobody holds", "zzz"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ h := mmTeam(t, knockout)
+ won := repRow("carol", "dana", []string{"M"}, nil)
+ won.Winner = wrTeamA
+ require.NoError(t, h.write(repWrite(h, mmT1, won, repBoutGroup, repPickAName, repPickBName)))
+ seeded := h.load(t)
+ require.Len(t, seeded.SubResults, 1)
+ require.Equal(t, "carol", seeded.SubResults[0].WinnerMemberID, "setup: derived from the winner and the picks")
+ require.Equal(t, mmT1, seeded.GroupStamp(repBoutGroup), "setup: the bout is dated")
+ require.Equal(t, mmT1, seeded.ModifiedAt, "setup: the match is dated")
+
+ // The match-level winner id was backfilled after the seeding
+ // merge, so the echo carries it too: only the bout's winner
+ // member id differs from what is stored.
+ echo := repRow("carol", "dana", []string{"M"}, nil)
+ echo.Winner, echo.WinnerMemberID = wrTeamA, tc.id
+ w := repWrite(h, mmT2, echo)
+ w.Changed = nil
+ w.WriteDoor = DoorBulkScore
+ w.WinnerID = seeded.WinnerID
+ require.NoError(t, h.write(w))
+
+ got := h.load(t)
+ require.Len(t, got.SubResults, 1)
+ assert.Equal(t, mmT1, got.GroupStamp(repBoutGroup), "an id-only difference is an echo: the bout keeps its date")
+ assert.Equal(t, mmT1, got.ModifiedAt, "and so does the match")
+ assert.Equal(t, "carol", got.SubResults[0].WinnerMemberID, "re-derived from the winner and the picks")
+ })
+ }
+ })
+}
+
+// A pick has no bout to land on once the representative bout is removed: it
+// is held, in the history, not applied onto nothing.
+func TestMerge_RepPick_OnARemovedBoutIsHeld(t *testing.T) {
+ h := mmTeam(t, true)
+ seedPick(t, h)
+ remove := mmRunning(h, mmT2, repBoutGroup, repPickAName, repPickBName)
+ remove.WriteDoor = DoorDaihyosenDel
+ remove.SubResults = []state.SubMatchResult{}
+ require.NoError(t, h.write(remove))
+ require.Empty(t, h.load(t).SubResults)
+ removed := h.load(t)
+ assert.Equal(t, mmT2, removed.GroupStamp(repPickAName), "the removal dates side A's pick too")
+ assert.Equal(t, mmT2, removed.GroupStamp(repPickBName), "and side B's")
+
+ late := repWrite(h, mmT3, repRow("dana", "", nil, nil), repPickAName)
+ err := h.write(late)
+ require.ErrorIs(t, err, ErrMatchSuperseded)
+ assert.Equal(t, []string{repPickAName}, HeldGroupsOf(err))
+ assert.Empty(t, h.load(t).SubResults, "nothing is resurrected")
+
+ // Each side is held on its own, with only its own id kept in the history.
+ both := repWrite(h, mmT3+1, repRow("eve", "fay", nil, nil), repPickAName, repPickBName)
+ err = h.write(both)
+ require.ErrorIs(t, err, ErrMatchSuperseded)
+ assert.Equal(t, []string{repPickAName, repPickBName}, HeldGroupsOf(err))
+ entries := h.history(t)
+ held := entries[len(entries)-1]
+ assert.JSONEq(t, `{"sideAMemberId":"eve"}`, string(held.Held[repPickAName]))
+ assert.JSONEq(t, `{"sideBMemberId":"fay"}`, string(held.Held[repPickBName]))
+ still := h.load(t)
+ assert.Empty(t, still.SubResults, "still nothing is resurrected")
+ assert.Equal(t, mmT2, still.GroupStamp(repPickAName), "a held pick leaves the tombstone's date")
+ assert.Equal(t, mmT2, still.GroupStamp(repPickBName))
+}
+
+// A match written before the picks had a stamp of their own is ordered by the
+// stamp of its representative bout, as it always was.
+func TestMerge_RepPicks_LegacyStampFallsBackToTheBout(t *testing.T) {
+ h := mmTeam(t, true)
+ bracket, err := h.store.LoadBracket(h.compID)
+ require.NoError(t, err)
+ bm := &bracket.Rounds[0][0]
+ bm.SubResults = []state.SubMatchResult{repRow("carol", "gus", nil, nil)}
+ bm.ModifiedAt = mmT2
+ bm.GroupStamps = map[string]int64{repBoutGroup: mmT2} // a map written before the picks had an entry
+ require.NoError(t, h.store.SaveBracket(h.compID, bracket))
+
+ // Both sides read the row's date, until each is stamped itself.
+ require.Equal(t, mmT2, repStamp(t, h, repPickAName))
+ require.Equal(t, mmT2, repStamp(t, h, repPickBName))
+
+ err = h.write(repWrite(h, mmT1, repRow("dana", "gus", nil, nil), repPickAName))
+ require.ErrorIs(t, err, ErrMatchSuperseded, "older than the row it sits on")
+ assert.Equal(t, "carol", h.load(t).SubResults[0].SideAMemberID)
+ err = h.write(repWrite(h, mmT1, repRow("carol", "hal", nil, nil), repPickBName))
+ require.ErrorIs(t, err, ErrMatchSuperseded, "side B reads the row's date too")
+ assert.Equal(t, "gus", h.load(t).SubResults[0].SideBMemberID)
+
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("dana", "gus", nil, nil), repPickAName)))
+ got := h.load(t)
+ assert.Equal(t, "dana", got.SubResults[0].SideAMemberID)
+ assert.Equal(t, "gus", got.SubResults[0].SideBMemberID, "a pick the write did not name stays")
+ assert.Equal(t, mmT2, got.GroupStamp(repPickBName), "and keeps its date")
+}
+
+// A legacy match (no stamp map) that holds no representative bout row is not
+// given a pick stamp by an ordinary write: the materialization that runs before
+// the first group is stamped adds the keys only for a match that has the row to
+// date. Individual matches never have one.
+func TestMerge_LegacyMatchWithoutARepBoutGainsNoRepPicksStamp(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmIndividual(t, knockout)
+ require.Nil(t, h.load(t).GroupStamps, "precondition: a legacy match")
+ w := mmRunning(h, mmT1, state.GroupPoints)
+ w.IpponsA = []string{"M"}
+ require.NoError(t, h.write(w))
+ got := h.load(t)
+ assert.Equal(t, mmT1, got.GroupStamp(state.GroupPoints), "the group the write changed is stamped")
+ require.NotNil(t, got.GroupStamps)
+ assert.NotContains(t, got.GroupStamps, repPickAName)
+ assert.NotContains(t, got.GroupStamps, repPickBName)
+ })
+}
+
+// Two captains pick their representatives at about the same moment, each on
+// their own phone: device 1 picks Shiro's (side B), device 2, whose screen never
+// showed that pick, picks Aka's (side A) a moment later, carrying a row with no
+// side-B id. Each side's pick is its own dated change, so device 2's write
+// applies side A and leaves device 1's side B alone: both picks stand, each
+// dated by its own write. Both sides are asserted after every write.
+func TestMerge_RepPicks_TwoCaptainsBothLand(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+
+ // Device 1, at T1: the pick creates the row.
+ require.NoError(t, h.write(repWrite(h, mmT1, repRow("", "x", nil, nil), repBoutGroup, repPickBName)))
+ got := h.load(t)
+ require.Len(t, got.SubResults, 1)
+ require.Equal(t, "x", got.SubResults[0].SideBMemberID, "setup: device 1's pick is stored")
+ require.Empty(t, got.SubResults[0].SideAMemberID)
+
+ // Device 2, at T2 > T1: its row carries side A only.
+ err := h.write(repWrite(h, mmT2, repRow("y", "", nil, nil), repPickAName))
+
+ got = h.load(t)
+ require.Len(t, got.SubResults, 1)
+ assert.Equal(t, "y", got.SubResults[0].SideAMemberID, "device 2's pick applies")
+ assert.Equal(t, "x", got.SubResults[0].SideBMemberID, "and device 1's pick is not its to erase")
+ assert.NoError(t, err)
+ assert.Equal(t, mmT1, got.GroupStamp(repPickBName), "side B is still dated by device 1's write")
+ assert.Equal(t, mmT2, got.GroupStamp(repPickAName), "side A is dated by device 2's write")
+ })
+}
+
+// A stale pick on one side is held and kept in the history while a newer pick on
+// the other side, in the same write, applies. The seed order is deliberate: a
+// fresh match has no representative row, and the write that creates it dates
+// every side it does not name through the bout:-1 fallback, so side A is made
+// newer (T3) by its own write before the write under test (T2) arrives.
+func TestMerge_RepPicks_StaleSideHeldWhileTheOtherSideApplies(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+ require.NoError(t, h.write(repWrite(h, mmT1, repRow("", "b1", nil, nil), repBoutGroup, repPickBName)))
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("a1", "", nil, nil), repPickAName)))
+ seeded := h.load(t)
+ require.Equal(t, "a1", seeded.SubResults[0].SideAMemberID, "setup: side A picked at T3")
+ require.Equal(t, "b1", seeded.SubResults[0].SideBMemberID, "setup: side B picked at T1")
+
+ err := h.write(repWrite(h, mmT2, repRow("a2", "b2", nil, nil), repPickAName, repPickBName))
+
+ got := h.load(t)
+ require.Len(t, got.SubResults, 1)
+ assert.Equal(t, "a1", got.SubResults[0].SideAMemberID, "the newer side-A pick stands")
+ assert.Equal(t, "b2", got.SubResults[0].SideBMemberID, "the side-B pick, newer than what it replaces, applies")
+ assert.NoError(t, err, "applied in part: not superseded")
+ entries := h.history(t)
+ last := entries[len(entries)-1]
+ assert.Equal(t, state.HistoryOutcomeHeld, last.Outcomes[repPickAName])
+ assert.Equal(t, state.HistoryOutcomeApplied, last.Outcomes[repPickBName])
+ assert.JSONEq(t, `{"sideAMemberId":"a2"}`, string(last.Held[repPickAName]), "the stale pick is kept, never discarded")
+ assert.NotContains(t, last.Held, repPickBName)
+ assert.Equal(t, mmT3, got.GroupStamp(repPickAName))
+ assert.Equal(t, mmT2, got.GroupStamp(repPickBName))
+ })
+}
+
+// repRemoval is the representative bout's removal as the daihyosen DELETE
+// door builds it: the bout, the verdict it carried, overtime and both
+// representatives, and no row.
+func repRemoval(h mmHome, at int64) *state.MatchResult {
+ remove := mmRunning(h, at, repBoutGroup, state.GroupResult, state.GroupEncho, repPickAName, repPickBName)
+ remove.WriteDoor = DoorDaihyosenDel
+ remove.SubResults = []state.SubMatchResult{}
+ return remove
+}
+
+// historyOf is the entries of one door, in the order they were written.
+func historyOf(entries []state.MatchHistoryEntry, door string) []state.MatchHistoryEntry {
+ var out []state.MatchHistoryEntry
+ for _, e := range entries {
+ if e.Door == door {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+// J. A removal that is OLDER than a representative picked after it: in stamp
+// order the bout was removed first and the pick came after, onto a bout that
+// no longer existed. Arriving second, the pick is held (the no-row hold, see
+// TestMerge_RepPick_OnARemovedBoutIsHeld). Arriving first, it must end in the
+// same place: no row, the pick kept in the history under its own stamp with its
+// id, and the pick dated by the removal, which is the last thing that happened
+// to the row. Before this the removal held the pick with ITS empty value, the
+// row went with the removal, and the newer pick was recorded nowhere.
+func TestMerge_RepBoutRemove_OlderThanAStoredPick_MovesThePickToTheHistory(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+ seedPick(t, h)
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("dana", "", nil, nil), repPickAName)))
+ require.Equal(t, "dana", h.load(t).SubResults[0].SideAMemberID, "setup: the newer pick is stored")
+
+ remove := repRemoval(h, mmT2)
+ require.NoError(t, h.write(remove), "the removal applies: it is newer than the bout")
+
+ got := h.load(t)
+ assert.Empty(t, got.SubResults, "the row is gone")
+ assert.Equal(t, mmT2, got.GroupStamp(repPickAName), "the removal dates the picks, as it does when it comes first")
+ assert.Equal(t, mmT2, got.GroupStamp(repPickBName))
+ assert.Equal(t, mmT2, got.ModifiedAt, "the newest stamp the match still holds")
+
+ rep := remove.Merge
+ require.NotNil(t, rep)
+ assert.Equal(t, []string{repPickAName}, rep.DisplacedGroups(), "the answer names the pick moved to the history")
+ assert.Empty(t, rep.HeldGroups(), "nothing of the removal was held")
+ assert.Empty(t, rep.HeldReason(), "no needs_winner: the pick lost its bout, not its winner")
+ assert.False(t, rep.NeedsWinner)
+
+ entries := h.history(t)
+ own := historyOf(entries, DoorDaihyosenDel)
+ require.Len(t, own, 1)
+ assert.Equal(t, state.HistoryOutcomeApplied, own[0].Outcomes[repPickAName], "the removal's own entry applies the pick's clear")
+ assert.NotContains(t, own[0].Held, repPickAName, "and holds no empty value in its name")
+
+ moved := historyOf(entries, DoorDisplaced)
+ require.Len(t, moved, 1)
+ assert.Equal(t, mmT3, moved[0].Stamp, "at the pick's own stamp")
+ assert.Equal(t, state.HistoryOutcomeHeld, moved[0].Outcomes[repPickAName])
+ assert.JSONEq(t, `{"sideAMemberId":"dana"}`, string(moved[0].Held[repPickAName]), "with its id")
+ assert.Equal(t, HoldReasonRepBoutRemoved, moved[0].Reason)
+ })
+}
+
+// J, both arrival orders: (remove at T2, pick at T3) ends in one stored state
+// and one history content however they arrive.
+func TestMerge_RepBoutRemove_BothArrivalOrdersConverge(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ type outcome struct {
+ Rows []state.SubMatchResult
+ StampA int64
+ StampB int64
+ Modified int64
+ HeldPicks []string
+ }
+ run := func(removeFirst bool) outcome {
+ h := mmTeam(t, knockout)
+ seedPick(t, h)
+ pick := repWrite(h, mmT3, repRow("dana", "", nil, nil), repPickAName)
+ remove := repRemoval(h, mmT2)
+ if removeFirst {
+ require.NoError(t, h.write(remove))
+ require.ErrorIs(t, h.write(pick), ErrMatchSuperseded, "the pick lands on no bout")
+ } else {
+ require.NoError(t, h.write(pick))
+ require.NoError(t, h.write(remove))
+ }
+ got := h.load(t)
+ out := outcome{
+ Rows: got.SubResults, StampA: got.GroupStamp(repPickAName), StampB: got.GroupStamp(repPickBName),
+ Modified: got.ModifiedAt,
+ }
+ for _, e := range h.history(t) {
+ if v, ok := e.Held[repPickAName]; ok {
+ out.HeldPicks = append(out.HeldPicks, string(v))
+ }
+ }
+ return out
+ }
+ inOrder, reversed := run(true), run(false)
+ assert.Empty(t, inOrder.Rows)
+ assert.Equal(t, []string{`{"sideAMemberId":"dana"}`}, inOrder.HeldPicks, "the pick is in the history")
+ assert.Equal(t, inOrder, reversed, "rows, stamps and the picks kept in the history match")
+ })
+}
+
+// J with both sides: picks stamped alike share one history entry, each at its
+// own stamp otherwise, oldest first; each side is dated by the removal.
+func TestMerge_RepBoutRemove_OlderThanBothPicks_OneEntryPerStamp(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+ seedPick(t, h)
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("dana", "gus", nil, nil), repPickAName, repPickBName)))
+ require.NoError(t, h.write(repWrite(h, mmT4, repRow("dana", "hal", nil, nil), repPickBName)))
+
+ remove := repRemoval(h, mmT2)
+ require.NoError(t, h.write(remove))
+
+ assert.Equal(t, []string{repPickAName, repPickBName}, remove.Merge.DisplacedGroups())
+ moved := historyOf(h.history(t), DoorDisplaced)
+ require.Len(t, moved, 2, "side A is the pick at T3, side B the one at T4")
+ assert.Equal(t, mmT3, moved[0].Stamp)
+ assert.JSONEq(t, `{"sideAMemberId":"dana"}`, string(moved[0].Held[repPickAName]))
+ assert.NotContains(t, moved[0].Held, repPickBName, "side B's pick was replaced at T4")
+ assert.Equal(t, mmT4, moved[1].Stamp)
+ assert.JSONEq(t, `{"sideBMemberId":"hal"}`, string(moved[1].Held[repPickBName]))
+ got := h.load(t)
+ assert.Empty(t, got.SubResults)
+ assert.Equal(t, mmT2, got.GroupStamp(repPickAName))
+ assert.Equal(t, mmT2, got.GroupStamp(repPickBName))
+ assert.Equal(t, mmT2, got.ModifiedAt)
+
+ // Both picks made in one write share its stamp: one entry.
+ h2 := mmTeam(t, knockout)
+ seedPick(t, h2)
+ require.NoError(t, h2.write(repWrite(h2, mmT3, repRow("dana", "gus", nil, nil), repPickAName, repPickBName)))
+ require.NoError(t, h2.write(repRemoval(h2, mmT2)))
+ one := historyOf(h2.history(t), DoorDisplaced)
+ require.Len(t, one, 1)
+ assert.Equal(t, mmT3, one[0].Stamp)
+ assert.Equal(t, []string{repPickAName, repPickBName}, one[0].Changed)
+ })
+}
+
+// A removal refused for the BOUT (a point on it, made after the removal, is
+// stored) is refused whole for the representatives: a pick stays on the row that
+// stays. Before this the pick's older stamp let the removal's empty pick apply, so
+// its history entry called the pick cleared and its stamp moved, while the row
+// kept the pick.
+func TestMerge_RepBoutRemove_RefusedForTheBout_HoldsItsPicksWithIt(t *testing.T) {
+ bothBranches(t, func(t *testing.T, knockout bool) {
+ h := mmTeam(t, knockout)
+ seedPick(t, h) // carol at T1
+ require.NoError(t, h.write(repWrite(h, mmT3, repRow("", "", []string{"M"}, nil), repBoutGroup)))
+
+ remove := repRemoval(h, mmT2)
+ err := h.write(remove)
+ require.ErrorIs(t, err, ErrMatchSuperseded, "nothing of the removal applies")
+ assert.Contains(t, HeldGroupsOf(err), repBoutGroup)
+ assert.Contains(t, HeldGroupsOf(err), repPickAName, "the pick is held with the bout")
+
+ got := h.load(t)
+ require.Len(t, got.SubResults, 1, "the row stays")
+ assert.Equal(t, "carol", got.SubResults[0].SideAMemberID, "with its pick")
+ assert.Equal(t, []string{"M"}, got.SubResults[0].IpponsA, "and its point")
+ assert.Equal(t, mmT1, remove.GroupStamps[repPickAName], "the pick keeps its date: the removal did not touch it")
+
+ entries := h.history(t)
+ last := entries[len(entries)-1]
+ assert.Equal(t, state.HistoryOutcomeHeld, last.Outcomes[repPickAName])
+ assert.JSONEq(t, `{"sideAMemberId":""}`, string(last.Held[repPickAName]), "the clear that was not applied")
+ assert.Equal(t, state.HistoryOutcomeHeld, last.Outcomes[repBoutGroup])
+ })
+}
+
+// K. A pick whose bout a LATER hold took out of the write has no bout to land
+// on either. A running write made after a knockout's finish names the bout and a
+// pick and ties the scoreline: R4 holds the bout (the stored match has no
+// representative row to put it on), and the pick the row carried must go with
+// it, held with its id, not left "applied" onto nothing with its stamp moved.
+func TestMerge_RepPick_OnARowAHoldTookOut_IsHeldWithItsValue(t *testing.T) {
+ finished := func(t *testing.T) mmHome {
+ h := mmTeam(t, true)
+ done := mmRunning(h, mmT1)
+ done.Changed = nil
+ done.Status = state.MatchStatusCompleted
+ done.IpponsA, done.IpponsB = []string{"M"}, []string{}
+ done.Winner = wrTeamA
+ require.NoError(t, h.write(done))
+ return h
+ }
+ tying := func(h mmHome, changed ...string) *state.MatchResult {
+ w := repWrite(h, mmT2, repRow("eve", "", nil, nil), append([]string{repBoutGroup, repPickAName, state.GroupPoints}, changed...)...)
+ w.IpponsA, w.IpponsB = []string{"M"}, []string{"K"}
+ return w
+ }
+
+ t.Run("the whole write is held", func(t *testing.T) {
+ h := finished(t)
+ late := tying(h)
+ err := h.write(late)
+ require.ErrorIs(t, err, ErrMatchSuperseded)
+ assert.ElementsMatch(t, []string{state.GroupPoints, repBoutGroup, repPickAName}, HeldGroupsOf(err))
+ assert.NotContains(t, late.Merge.Applied, repPickAName, "never applied onto nothing")
+ assert.Equal(t, HoldReasonKnockoutNeedsWinner, late.Merge.HoldReason)
+
+ last := h.history(t)
+ last0 := last[len(last)-1]
+ assert.Equal(t, state.HistoryOutcomeHeld, last0.Outcomes[repPickAName])
+ assert.JSONEq(t, `{"sideAMemberId":"eve"}`, string(last0.Held[repPickAName]), "the pick's id is kept")
+ got := h.load(t)
+ assert.Empty(t, got.SubResults)
+ assert.Zero(t, got.GroupStamps[repPickAName], "and its stamp is the stored one")
+ })
+ t.Run("a write applied in part moves no stamp of the pick", func(t *testing.T) {
+ h := finished(t)
+ late := tying(h, state.GroupEncho)
+ late.Encho = &state.EnchoMetadata{PeriodCount: 1}
+ require.NoError(t, h.write(late), "the overtime applies")
+ assert.NotContains(t, late.Merge.Applied, repPickAName)
+ assert.Contains(t, late.Merge.Held, repPickAName)
+ assert.JSONEq(t, `{"sideAMemberId":"eve"}`, string(late.Merge.HeldValues[repPickAName]))
+ got := h.load(t)
+ assert.Empty(t, got.SubResults)
+ assert.NotContains(t, got.GroupStamps, repPickAName, "the stored match never stamped the pick")
+ })
+}
diff --git a/internal/engine/match_merge_test.go b/internal/engine/match_merge_test.go
index 2a188e561..d530b6520 100644
--- a/internal/engine/match_merge_test.go
+++ b/internal/engine/match_merge_test.go
@@ -910,7 +910,7 @@ func TestMerge_FinishIsAtomicWithTheScorelineItStoodOn(t *testing.T) {
// A verdict-only correction (an operator's hantei call): Tora
// actually won. Needs no CorrectionReason here -- that gate is the
- // handler's (applyCorrectionReasonUnderTx), not the engine's.
+ // handler's (applyCorrectionReason), not the engine's.
correction := mmRunning(h, mmT3, state.GroupResult)
correction.Status = state.MatchStatusCompleted
correction.Winner, correction.WinnerID = wrTeamB, wrTeamBID
diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go
index bf35754d0..2e720117a 100644
--- a/internal/engine/pool_requalify.go
+++ b/internal/engine/pool_requalify.go
@@ -291,11 +291,16 @@ func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *sta
// 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
+ // The picks the repaint takes from the knockout matches it re-seats
+ // without reopening, for their own history lines (bracket_seat_audit.go).
+ // The write itself is a pool match, so no bracket match is excluded.
+ var clears []repPickClear
err := tx.UpdateBracket(compID, func(b *state.Bracket) error {
if b == nil {
return errMatchNotFound
}
- reopened = nil
+ reopened, clears = nil, nil
+ before := repPickSnapshot(b, "")
for _, id := range plan.affected {
for ri := range b.Rounds {
for mi := range b.Rounds[ri] {
@@ -311,12 +316,17 @@ func (e *Engine) applyRequalification(tx state.StoreTx, compID, reason string, p
}
}
e.resolveSlots(b, plan.resolver)
+ // The repaint may have given a side another team and taken its pick
+ // with it; a reopened match names the group on its own line.
+ clears = repPickClears(before, b)
+ markRepPicksCleared(reopened, clears)
return nil
})
if err != nil {
return nil, fmt.Errorf("requalification: update bracket for %s: %w", compID, err)
}
e.restoreForceReopened(tx, compID, reopened)
+ e.recordRepPickClears(tx, compID, clears, reopened)
return reopened, nil
}
diff --git a/internal/engine/replace_participant.go b/internal/engine/replace_participant.go
index ad86aaa60..04c80f50a 100644
--- a/internal/engine/replace_participant.go
+++ b/internal/engine/replace_participant.go
@@ -3,6 +3,7 @@ package engine
import (
"fmt"
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
"github.com/gitrgoliveira/bracket-creator/internal/helper"
"github.com/gitrgoliveira/bracket-creator/internal/state"
@@ -164,13 +165,20 @@ func (e *Engine) ReplaceParticipantInDraw(
// apply (matchesParticipant), now extended to bracket.json now that
// it carries ids too.
bracketChanged := false
- forEachBracketSideWithID(bracket, func(name, id *string) {
+ forEachBracketSlot(bracket, func(m *state.BracketMatch, slot domain.MatchSide, name, id *string) {
if !matchesParticipant(*id, pid) {
return
}
bracketFound = true
- if *name != newName {
- *name = newName
+ if slot == domain.MatchSideNone {
+ // The Winner is not a side: written directly.
+ if *name != newName {
+ *name = newName
+ bracketChanged = true
+ }
+ return
+ }
+ if renameBracketSide(m, slot, newName, *id) {
bracketChanged = true
}
})
@@ -207,7 +215,7 @@ func (e *Engine) ReplaceParticipantInDraw(
// manual check, while the silent-corruption direction costs someone
// else's match history.
bracketNames := make(map[string]bool)
- forEachBracketSideWithID(bracket, func(name, id *string) {
+ forEachBracketSlot(bracket, func(_ *state.BracketMatch, _ domain.MatchSide, name, id *string) {
if *id == "" && *name != "" {
bracketNames[*name] = true
}
@@ -220,7 +228,7 @@ func (e *Engine) ReplaceParticipantInDraw(
}
bracketNameAmbiguous = amb
}
- forEachBracketSideWithID(bracket, func(name, id *string) {
+ forEachBracketSlot(bracket, func(m *state.BracketMatch, slot domain.MatchSide, name, id *string) {
if *id != "" {
// Carries an id that is NOT pid's (pass 1 already renamed
// every pid match): a different competitor, full stop --
@@ -235,6 +243,13 @@ func (e *Engine) ReplaceParticipantInDraw(
if !bracketNameAmbiguous {
*name = newName
bracketChanged = true
+ // The side keeps its team and only has a new name, so the
+ // representative bout row's name follows it and no pick goes
+ // (seatBracketSide would read an id-less old name to a new
+ // one as another team).
+ if row := state.DaihyosenSubIndex(m.SubResults); row >= 0 && slot != domain.MatchSideNone {
+ nameRepBoutRow(m, row, slot, newName, oldName, false)
+ }
}
})
if bracketNameAmbiguous && bracketFound {
@@ -317,28 +332,55 @@ func matchesParticipant(rowID, pid string) bool {
return rowID != "" && pid != "" && rowID == pid
}
-// forEachBracketSideWithID calls fn once for each of a bracket's three
-// (name, id) side pairs -- SideA/SideAID, SideB/SideBID, Winner/WinnerID --
-// across every round, plus the ThirdPlaceMatch sibling's when present. Both
-// pointers ALIAS the stored match (indexed slice access, never a
-// range-copy), so a caller mutating through them edits the bracket in
-// place. Shared by ReplaceParticipantInDraw's id-based rename pass, its
-// id-less name-collection pass, and its name-based fallback rename pass,
-// which would otherwise hand-copy the same enumeration three times
-// (bc-brid; this replaced the pre-bc-brid, name-only forEachBracketSide,
-// whose two callers both needed the id half once bracket.json grew one).
-func forEachBracketSideWithID(b *state.Bracket, fn func(name, id *string)) {
+// renameBracketSide gives side of m, which carries the renamed participant's
+// id, the participant's new name. It goes through seatBracketSide, so the
+// representative bout row's name (and its Winner, when the row or the match named
+// the side by the old name) follows the match's: the same id is the same team, so no pick
+// is cleared. It reports whether anything was out of step, the match's name or
+// the row's, so a bracket already in step is not saved again.
+func renameBracketSide(m *state.BracketMatch, side domain.MatchSide, newName, id string) bool {
+ name, _ := slotFields(m, side)
+ inStep := *name == newName
+ if row := state.DaihyosenSubIndex(m.SubResults); row >= 0 {
+ rowName := m.SubResults[row].SideA
+ if side == domain.MatchSideB {
+ rowName = m.SubResults[row].SideB
+ }
+ inStep = inStep && rowName == newName
+ }
+ if inStep {
+ return false
+ }
+ seatBracketSide(m, side, newName, id)
+ return true
+}
+
+// forEachBracketSlot calls fn once for each of a bracket's three (name, id)
+// slots -- SideA/SideAID, SideB/SideBID, Winner/WinnerID -- across every round,
+// plus the ThirdPlaceMatch sibling's when present, with the slot's match and
+// which of its slots it is: domain.MatchSideA, domain.MatchSideB, or
+// domain.MatchSideNone for the Winner (not a side). Both pointers ALIAS the
+// stored match (indexed slice access, never a range-copy), so a caller mutating
+// through them edits the bracket in place. Shared by ReplaceParticipantInDraw's
+// id-based rename pass, its id-less name-collection pass, and its name-based
+// fallback rename pass, which would otherwise hand-copy the same enumeration
+// three times (bc-brid; this replaced the pre-bc-brid, name-only
+// forEachBracketSide, whose two callers both needed the id half once
+// bracket.json grew one). The passes that write a side through seatBracketSide,
+// or keep the representative bout row in step with one, need the match and the
+// slot; the collection pass ignores both.
+func forEachBracketSlot(b *state.Bracket, fn func(m *state.BracketMatch, slot domain.MatchSide, name, id *string)) {
+ visit := func(m *state.BracketMatch) {
+ fn(m, domain.MatchSideA, &m.SideA, &m.SideAID)
+ fn(m, domain.MatchSideB, &m.SideB, &m.SideBID)
+ fn(m, domain.MatchSideNone, &m.Winner, &m.WinnerID)
+ }
for i := range b.Rounds {
for j := range b.Rounds[i] {
- m := &b.Rounds[i][j]
- fn(&m.SideA, &m.SideAID)
- fn(&m.SideB, &m.SideBID)
- fn(&m.Winner, &m.WinnerID)
+ visit(&b.Rounds[i][j])
}
}
if bm := b.ThirdPlaceMatch; bm != nil {
- fn(&bm.SideA, &bm.SideAID)
- fn(&bm.SideB, &bm.SideBID)
- fn(&bm.Winner, &bm.WinnerID)
+ visit(bm)
}
}
diff --git a/internal/engine/representative_export.go b/internal/engine/representative_export.go
new file mode 100644
index 000000000..8bb3820f0
--- /dev/null
+++ b/internal/engine/representative_export.go
@@ -0,0 +1,110 @@
+package engine
+
+// representative_export.go converts a fixed-order team competition's
+// representative bouts into the helper-layer KachinukiMatchDetail shape the
+// "Representative Bouts" sheet is drawn from. The sheet exists because the
+// main sheets' team block lists the numbered bouts only (it skips position -1,
+// so its height, page breaks and IV/PW ranges stay TeamBoutRows' alone), which
+// left a representative bout, and the representatives, out of every
+// workbook. The renderer is the Kachinuki Detail sheet's
+// (internal/helper/excel_kachinuki.go), and the matches are walked by the same
+// detailDraw.sections, so the two sheets order a draw alike.
+
+import (
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
+ "github.com/gitrgoliveira/bracket-creator/internal/helper"
+ "github.com/gitrgoliveira/bracket-creator/internal/state"
+)
+
+// RepresentativeBoutMatches returns one section per match of a fixed-order
+// team competition that holds a representative bout, or an empty slice for
+// any other competition. Exported, like KachinukiDetailMatches, so the
+// "Download results" path (internal/export.BuildResultsWorkbook) emits the
+// sheet without duplicating the collection logic ExportCompetitionXlsx uses.
+func (e *Engine) RepresentativeBoutMatches(id string) ([]helper.KachinukiMatchDetail, error) {
+ comp, err := e.store.LoadCompetition(id)
+ if err != nil {
+ return nil, err
+ }
+ return e.collectRepresentativeBouts(id, comp)
+}
+
+// collectRepresentativeBouts returns the Representative Bouts sections of a
+// fixed-order team competition's draw, nil for any other competition (a
+// kachinuki one has no representative bout: AddDaihyosen refuses it), in the
+// order the Kachinuki Detail sheet lists a draw. A match without the bout has
+// no section. It is read-only and, like the Kachinuki Detail read, tolerant of
+// a missing or unreadable team-members.yaml, roster or bracket.json: the
+// representatives then print as the bout label alone.
+func (e *Engine) collectRepresentativeBouts(compID string, comp *state.Competition) ([]helper.KachinukiMatchDetail, error) {
+ if !comp.IsTeam() || comp.IsKachinuki() {
+ return nil, nil
+ }
+ draw, err := e.loadDetailDraw("engine.collectRepresentativeBouts", compID)
+ if err != nil {
+ return nil, err
+ }
+ teamNumbers := e.buildKachinukiTeamNumbers(compID, comp)
+ squads := e.buildKachinukiSquads(compID)
+ return draw.sections(comp, func(m *state.MatchResult, label string) (helper.KachinukiMatchDetail, bool) {
+ return buildRepresentativeBoutDetail(m, label, teamNumbers, squads)
+ }), nil
+}
+
+// buildRepresentativeBoutDetail converts m's representative bout (the row at
+// state.DaihyosenSubPosition) into a one-bout section titled label, or reports
+// false when m holds none. The bout's score, result marks and centre come from
+// boutFigures, the Kachinuki Detail sheet's own, so the two sheets cannot
+// disagree on a bout; the centre reads (DH) through domain.MiddleMark.
+//
+// The row keeps the TEAM names in SideA/SideB by rule (the result mark is
+// placed on the winner's side by comparing against them), so its fighters come
+// from the member ids alone, via representativeOf. A representative has no
+// lineup position, so none is printed.
+func buildRepresentativeBoutDetail(m *state.MatchResult, label string, teamNumbers map[string]string, squads map[string][]domain.TeamMember) (helper.KachinukiMatchDetail, bool) {
+ i := state.DaihyosenSubIndex(m.SubResults)
+ if i < 0 {
+ return helper.KachinukiMatchDetail{}, false
+ }
+ sub := m.SubResults[i]
+ scoreA, scoreB, markA, markB, middle := boutFigures(m, sub)
+ nameA, labelA := representativeOf(teamNumbers, squads, m.SideAID, sub.SideAMemberID)
+ nameB, labelB := representativeOf(teamNumbers, squads, m.SideBID, sub.SideBMemberID)
+ return helper.KachinukiMatchDetail{
+ Label: label,
+ SideATeam: m.SideA,
+ SideBTeam: m.SideB,
+ Bouts: []helper.KachinukiBout{{
+ Position: sub.Position,
+ SideAName: nameA,
+ SideALabel: labelA,
+ ScoreA: scoreA,
+ SideBName: nameB,
+ SideBLabel: labelB,
+ ScoreB: scoreB,
+ Middle: middle,
+ MarkA: markA,
+ MarkB: markB,
+ }},
+ }, true
+}
+
+// representativeOf names the representative a side picked: the current name
+// and "T1.3"-style label of the member memberID in teamID's team members.
+// Both are empty for a side that picked nobody (no member id) or whose pick
+// does not resolve to a member of that team (a member since removed, a team
+// without an id, no team-members file): unlike a numbered bout's row, the
+// representative's row stores the team's name where a fighter's would be, so
+// the stored name is never a fallback here, and the bout prints its label
+// alone, as the viewer shows "-".
+func representativeOf(teamNumbers map[string]string, squads map[string][]domain.TeamMember, teamID, memberID string) (name, label string) {
+ if teamID == "" || memberID == "" {
+ return "", ""
+ }
+ for _, member := range squads[teamID] {
+ if member.ID == memberID {
+ return member.Name, resolveKachinukiMemberLabel(teamNumbers, squads, teamID, memberID)
+ }
+ }
+ return "", ""
+}
diff --git a/internal/engine/representative_export_test.go b/internal/engine/representative_export_test.go
new file mode 100644
index 000000000..4f796107c
--- /dev/null
+++ b/internal/engine/representative_export_test.go
@@ -0,0 +1,356 @@
+package engine
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gitrgoliveira/bracket-creator/internal/domain"
+ "github.com/gitrgoliveira/bracket-creator/internal/helper"
+ "github.com/gitrgoliveira/bracket-creator/internal/state"
+)
+
+// representativeTeams is a fixed-order team knockout's two teams with three
+// named members each; the draw puts Red first, so its members are T1.1-T1.3
+// and White's T2.1-T2.3.
+type representativeTeams struct {
+ redID, whiteID string
+ red, white []domain.TeamMember
+}
+
+// setupRepresentativeComp saves a fixed-order team knockout with two teams and
+// their members, and returns the engine over it. Deliberately NOT
+// setupKachinukiComp: this fixture's point is the non-kachinuki team type.
+func setupRepresentativeComp(t *testing.T, compID string) (*Engine, *state.Store, representativeTeams) {
+ t.Helper()
+ eng, store, _ := setupTestEngine(t)
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID,
+ Format: state.CompFormatKnockout,
+ Status: state.CompStatusKnockout,
+ Kind: "team",
+ TeamSize: 3,
+ TeamMatchType: state.TeamMatchTypeFixed,
+ NumberPrefix: "T",
+ }))
+
+ teams := representativeTeams{redID: helper.NewUUID4(), whiteID: helper.NewUUID4()}
+ require.NoError(t, store.SaveParticipants(compID, []domain.Player{
+ {ID: teams.redID, Name: "RedTeam", Dojo: "DojoR"},
+ {ID: teams.whiteID, Name: "WhiteTeam", Dojo: "DojoW"},
+ }))
+ add := func(teamID string, names ...string) []domain.TeamMember {
+ out := make([]domain.TeamMember, 0, len(names))
+ for _, n := range names {
+ m, err := store.AddTeamMember(compID, teamID, n)
+ require.NoError(t, err)
+ out = append(out, m)
+ }
+ return out
+ }
+ teams.red = add(teams.redID, "Alice", "Aiko", "Akira")
+ teams.white = add(teams.whiteID, "Bob", "Bea", "Ben")
+ require.Equal(t, 3, teams.red[2].Index, "Akira is Red's third member")
+ require.Equal(t, 2, teams.white[1].Index, "Bea is White's second member")
+ return eng, store, teams
+}
+
+// representativeRow is the representative bout a hantei decided for Red: the
+// row names the TEAMS (by rule), the picked members ride in the member ids.
+func representativeRow(teams representativeTeams) state.SubMatchResult {
+ return state.SubMatchResult{
+ Position: state.DaihyosenSubPosition,
+ SideA: "RedTeam",
+ SideB: "WhiteTeam",
+ SideAMemberID: teams.red[2].ID,
+ SideBMemberID: teams.white[1].ID,
+ Winner: "RedTeam",
+ Decision: string(domain.DecisionDaihyosen),
+ IpponsA: []string{domain.HanteiMark},
+ }
+}
+
+// bracketMatchWith is one completed knockout semi-final between Red and White
+// holding subs.
+func bracketMatchWith(teams representativeTeams, subs ...state.SubMatchResult) state.BracketMatch {
+ return state.BracketMatch{
+ ID: "SF1",
+ MatchNumber: 1,
+ DisplayRound: 1,
+ SideA: "RedTeam",
+ SideAID: teams.redID,
+ SideB: "WhiteTeam",
+ SideBID: teams.whiteID,
+ Winner: "RedTeam",
+ Status: state.MatchStatusCompleted,
+ SubResults: subs,
+ }
+}
+
+func saveBracketOf(t *testing.T, store *state.Store, compID string, teams representativeTeams, matches ...state.BracketMatch) {
+ t.Helper()
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{
+ DrawOrder: []string{teams.redID, teams.whiteID},
+ Rounds: [][]state.BracketMatch{matches},
+ }))
+}
+
+// numberedBout is a tied numbered bout, which the sheet must not list.
+func numberedBout(teams representativeTeams) state.SubMatchResult {
+ return state.SubMatchResult{
+ Position: 1, SideA: "Alice", SideAMemberID: teams.red[0].ID,
+ SideB: "Bob", SideBMemberID: teams.white[0].ID, Decision: "hikiwake",
+ }
+}
+
+func TestRepresentativeBoutMatches_ListsTheRepresentativeBout(t *testing.T) {
+ compID := "representative-lists-the-bout"
+ eng, store, teams := setupRepresentativeComp(t, compID)
+ saveBracketOf(t, store, compID, teams, bracketMatchWith(teams, numberedBout(teams), representativeRow(teams)))
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ require.Len(t, out, 1, "one section for the one match holding a representative bout")
+
+ section := out[0]
+ assert.Equal(t, helper.EliminationMatchTitle(1, 1), section.Label, "titled as the Elimination Matches sheet prints the match")
+ assert.Equal(t, "RedTeam", section.SideATeam)
+ assert.Equal(t, "WhiteTeam", section.SideBTeam)
+ require.Len(t, section.Bouts, 1, "the representative bout alone: the numbered bouts are on the main sheets")
+
+ bout := section.Bouts[0]
+ assert.Equal(t, state.DaihyosenSubPosition, bout.Position)
+ assert.Equal(t, "Akira", bout.SideAName, "Red's representative, not the team")
+ assert.Equal(t, "T1.3", bout.SideALabel)
+ assert.Equal(t, "Bea", bout.SideBName)
+ assert.Equal(t, "T2.2", bout.SideBLabel)
+ assert.Empty(t, bout.SideAPos, "a representative holds no lineup position")
+ assert.Empty(t, bout.SideBPos)
+ assert.Equal(t, "(DH)", bout.Middle, "the centre is the closed-set daihyosen mark, domain.MiddleMark's")
+ assert.Equal(t, "Ht", bout.MarkA, "the hantei mark rides beside the winner's side")
+ assert.Empty(t, bout.MarkB)
+ assert.Empty(t, bout.ScoreA, "the hantei mark is not a point")
+ assert.Empty(t, bout.ScoreB)
+ assert.Zero(t, section.BlankBoutRows)
+}
+
+// A side whose representative was not picked prints no fighter: the row stores
+// the TEAM's name where a fighter's would be, and the viewer shows "-" there,
+// so the export must not print the team as a person.
+func TestRepresentativeBoutMatches_UnpickedSidePrintsNoName(t *testing.T) {
+ cases := []struct {
+ name string
+ sideB func(teams representativeTeams) string
+ }{
+ {"no pick", func(representativeTeams) string { return "" }},
+ {"an id that resolves to no member", func(representativeTeams) string { return "a-removed-member" }},
+ {"a member of the other team", func(teams representativeTeams) string { return teams.red[0].ID }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ compID := "representative-unpicked-" + strings.ReplaceAll(tc.name, " ", "-")
+ eng, store, teams := setupRepresentativeComp(t, compID)
+ row := representativeRow(teams)
+ row.SideBMemberID = tc.sideB(teams)
+ saveBracketOf(t, store, compID, teams, bracketMatchWith(teams, row))
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ bout := out[0].Bouts[0]
+ assert.Empty(t, bout.SideBName, "White's side picked nobody that resolves")
+ assert.Empty(t, bout.SideBLabel)
+ assert.Equal(t, "Akira", bout.SideAName, "the other side's pick is unaffected")
+ assert.Equal(t, "T1.3", bout.SideALabel)
+ })
+ }
+}
+
+// With no team-members file at all every pick is unresolvable.
+func TestRepresentativeBoutMatches_NoMembersFilePrintsTheBoutLabelAlone(t *testing.T) {
+ compID := "representative-no-members"
+ eng, store, _ := setupTestEngine(t)
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Format: state.CompFormatKnockout, Status: state.CompStatusKnockout,
+ Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed, NumberPrefix: "T",
+ }))
+ teams := representativeTeams{redID: helper.NewUUID4(), whiteID: helper.NewUUID4()}
+ teams.red = []domain.TeamMember{{ID: "m-red"}, {ID: "m-red-2"}, {ID: "m-red-3"}}
+ teams.white = []domain.TeamMember{{ID: "m-white"}, {ID: "m-white-2"}}
+ saveBracketOf(t, store, compID, teams, bracketMatchWith(teams, representativeRow(teams)))
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ bout := out[0].Bouts[0]
+ assert.Empty(t, bout.SideAName)
+ assert.Empty(t, bout.SideALabel)
+ assert.Empty(t, bout.SideBName)
+ assert.Empty(t, bout.SideBLabel)
+ assert.Equal(t, "(DH)", bout.Middle, "the bout is still listed, with its centre")
+}
+
+func TestRepresentativeBoutMatches_NoneWithoutARepresentativeBout(t *testing.T) {
+ t.Run("a match with numbered bouts only", func(t *testing.T) {
+ compID := "representative-none-numbered"
+ eng, store, teams := setupRepresentativeComp(t, compID)
+ saveBracketOf(t, store, compID, teams, bracketMatchWith(teams, numberedBout(teams)))
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+ t.Run("a draw with no matches fought", func(t *testing.T) {
+ compID := "representative-none-undrawn"
+ eng, _, _ := setupRepresentativeComp(t, compID)
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+ t.Run("an individual competition", func(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ compID := "representative-none-individual"
+ createTestCompetition(t, store, compID, "mixed", 3)
+ saveTestParticipants(t, store, compID, []string{"Alice", "Bob", "Charlie"})
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+}
+
+// A kachinuki encounter has no representative bout (AddDaihyosen refuses it),
+// so a stray position -1 row there is not listed; the Kachinuki Detail sheet
+// owns that competition's bouts.
+func TestRepresentativeBoutMatches_NoneForKachinuki(t *testing.T) {
+ compID := "representative-none-kachinuki"
+ eng, store, _ := setupKachinukiComp(t, compID, 3, func(c *state.Competition) {
+ c.Format = state.CompFormatKnockout
+ c.Status = state.CompStatusKnockout
+ c.NumberPrefix = "T"
+ })
+ teams := representativeTeams{redID: helper.NewUUID4(), whiteID: helper.NewUUID4()}
+ teams.red = []domain.TeamMember{{ID: "m1"}, {ID: "m2"}, {ID: "m3"}}
+ teams.white = []domain.TeamMember{{ID: "w1"}, {ID: "w2"}}
+ saveBracketOf(t, store, compID, teams, bracketMatchWith(teams, representativeRow(teams)))
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ assert.Empty(t, out)
+}
+
+func TestRepresentativeBoutMatches_UnknownOrInvalidCompetition(t *testing.T) {
+ eng, _, _ := setupTestEngine(t)
+
+ out, err := eng.RepresentativeBoutMatches("does-not-exist")
+ assert.NoError(t, err, "an unknown competition lists nothing, as the Kachinuki Detail read does")
+ assert.Nil(t, out)
+
+ out, err = eng.RepresentativeBoutMatches("../evil")
+ require.Error(t, err)
+ assert.Nil(t, out)
+ assert.Contains(t, err.Error(), "invalid competition ID")
+}
+
+// Pool rows come first (a row the Pool Matches grid has no block for is titled
+// by its operator label), then the bracket in match-number order, not storage
+// order, then the 3rd-place match: the Kachinuki Detail sheet's own order.
+func TestRepresentativeBoutMatches_ListedInTheKachinukiDetailOrder(t *testing.T) {
+ compID := "representative-order"
+ eng, store, teams := setupRepresentativeComp(t, compID)
+ comp, err := store.LoadCompetition(compID)
+ require.NoError(t, err)
+
+ poolRow := state.MatchResult{
+ ID: "Pool A-0", SideA: "RedTeam", SideAID: teams.redID, SideB: "WhiteTeam", SideBID: teams.whiteID,
+ Status: state.MatchStatusCompleted, SubResults: []state.SubMatchResult{representativeRow(teams)},
+ }
+ require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{poolRow}))
+
+ second := bracketMatchWith(teams, representativeRow(teams))
+ second.ID, second.MatchNumber = "SF2", 2
+ first := bracketMatchWith(teams, representativeRow(teams))
+ third := bracketMatchWith(teams, representativeRow(teams))
+ third.ID = "BR"
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{
+ DrawOrder: []string{teams.redID, teams.whiteID},
+ Rounds: [][]state.BracketMatch{{second, first}},
+ ThirdPlaceMatch: &third,
+ }))
+
+ out, err := eng.RepresentativeBoutMatches(compID)
+ require.NoError(t, err)
+ labels := make([]string, len(out))
+ for i, s := range out {
+ labels[i] = s.Label
+ }
+ assert.Equal(t, []string{
+ OperatorMatchLabel(comp, nil, "Pool A-0"),
+ helper.EliminationMatchTitle(1, 1),
+ helper.EliminationMatchTitle(1, 2),
+ helper.ThirdPlaceLabel,
+ }, labels)
+}
+
+// The stored-draw export carries the sheet too (the results workbook's twin is
+// in internal/export): a real drawn team knockout, one semi-final decided by a
+// representative bout.
+func TestExportCompetitionXlsx_RepresentativeBoutsSheet(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ compID := "export-representative-bouts"
+ createTestCompetition(t, store, compID, state.CompFormatKnockout, 0, func(c *state.Competition) {
+ c.Kind = "team"
+ c.TeamSize = 3
+ c.TeamMatchType = state.TeamMatchTypeFixed
+ c.NumberPrefix = "T"
+ })
+ saveTestParticipants(t, store, compID, []string{"Ryu", "Tora", "Kame", "Taka"})
+ require.NoError(t, eng.StartCompetition(compID))
+
+ t.Run("none before a representative bout exists", func(t *testing.T) {
+ f := openExportedWorkbook(t, eng, compID)
+ assert.NotContains(t, f.GetSheetList(), helper.SheetRepresentativeBouts)
+ })
+
+ bracket, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ semi := bracket.Rounds[0][0]
+ require.NotEmpty(t, semi.SideAID)
+ require.NotEmpty(t, semi.SideBID)
+ a, err := store.AddTeamMember(compID, semi.SideAID, "Hiro")
+ require.NoError(t, err)
+ b, err := store.AddTeamMember(compID, semi.SideBID, "Sayaka")
+ require.NoError(t, err)
+ found, err := store.UpdateBracketMatchByID(compID, semi.ID, func(bm *state.BracketMatch) {
+ bm.Status = state.MatchStatusCompleted
+ bm.Winner = bm.SideA
+ bm.SubResults = []state.SubMatchResult{{
+ Position: state.DaihyosenSubPosition, SideA: bm.SideA, SideB: bm.SideB,
+ SideAMemberID: a.ID, SideBMemberID: b.ID, Winner: bm.SideA,
+ Decision: string(domain.DecisionDaihyosen), IpponsA: []string{domain.HanteiMark},
+ }}
+ })
+ require.NoError(t, err)
+ require.True(t, found)
+
+ f := openExportedWorkbook(t, eng, compID)
+ require.Contains(t, f.GetSheetList(), helper.SheetRepresentativeBouts)
+ assert.NotContains(t, f.GetSheetList(), helper.SheetKachinukiDetail, "a fixed-order competition has no Kachinuki Detail sheet")
+
+ rows, err := f.GetRows(helper.SheetRepresentativeBouts)
+ require.NoError(t, err)
+ var flat []string
+ for _, row := range rows {
+ flat = append(flat, strings.Join(row, "|"))
+ }
+ text := strings.Join(flat, "\n")
+ assert.Contains(t, text, "(Representative bout)")
+ assert.Contains(t, text, "DH ", "the bout's number cell reads DH")
+ assert.Contains(t, text, "Hiro")
+ assert.Contains(t, text, "Sayaka")
+ assert.Contains(t, text, "(DH)")
+ assert.Contains(t, text, "Ht")
+}
diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go
index a28186367..bda032785 100644
--- a/internal/engine/scoring.go
+++ b/internal/engine/scoring.go
@@ -541,6 +541,12 @@ func preserveSubHantei(stored, incoming []state.SubMatchResult) {
// guards it): the mark and the winner it names move as one atomic
// unit, never separately.
in.Winner = prior.Winner
+ // Neither id is trusted, the stored one included: the representatives are
+ // their own change, so even the stored id may disagree with the picks this
+ // row now holds. The reconcile derives the id again from the winner's NAME
+ // and those picks, so it is exactly the winning side's pick or empty
+ // (SubMatchResult.ReconcileWinnerMemberID).
+ in.ReconcileWinnerMemberID()
}
// preserveDaihyosenOutcome is the call every forward SubResults replacement
@@ -636,6 +642,13 @@ type ReopenedMatch struct {
// Court is the shiaijo the match is on ("" when it has none), so a
// refusal can tell the operator where the match is being fought.
Court string
+ // RepPicksCleared lists the pick groups (state.RepPickGroup) of the sides
+ // the same write that reopened the match gave another team, which took
+ // that side's representative with it (seatBracketSide); nil when none
+ // went. Its reopen history line then names those groups beside the
+ // verdict's, and only those. markRepPicksCleared fills it from the diff the
+ // door takes (repPickClears), so no second copy of the picks rides here.
+ RepPicksCleared []string
}
// bracketMatchRef is the ONE way a bracket match becomes a ReopenedMatch, so
@@ -2387,18 +2400,24 @@ 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.
+//
+// The matches the write re-seats without reopening get their own history line
+// beside the reopen lines (recordRepPickClears), in the same transaction; a
+// restore (matchWriteRestore) is not a door and records none.
func (e *Engine) recordBracketMatchResult(h state.StoreTx, compId string, matchId string, result *state.MatchResult, policy matchWritePolicy, force bool, comp *state.Competition) ([]ReopenedMatch, bool, error) {
var (
reopened []ReopenedMatch
inherited bool
+ clears []repPickClear
)
err := h.UpdateBracket(compId, func(bracket *state.Bracket) error {
var ierr error
- reopened, inherited, ierr = e.applyBracketResultIn(bracket, compId, matchId, result, policy, force, comp)
+ reopened, inherited, clears, ierr = e.applyBracketResultIn(bracket, compId, matchId, result, policy, force, comp)
return ierr
})
if err == nil {
e.restoreForceReopened(h, compId, reopened)
+ e.recordRepPickClears(h, compId, clears, reopened)
}
return reopened, inherited && err == nil, err
}
@@ -2640,9 +2659,9 @@ func applyBracketMatchResult(bm *state.BracketMatch, result *state.MatchResult,
// correction and its 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, comp *state.Competition) ([]ReopenedMatch, bool, error) {
+func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID string, result *state.MatchResult, policy matchWritePolicy, force bool, comp *state.Competition) ([]ReopenedMatch, bool, []repPickClear, error) {
if bracket == nil {
- return nil, false, notFoundErrorf("bracket not found for competition %s", compID)
+ return nil, false, nil, notFoundErrorf("bracket not found for competition %s", compID)
}
mc := mergeCtx{comp: comp, knockout: true}
for rIdx := range bracket.Rounds {
@@ -2656,7 +2675,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st
// and a payload that omits its sides would otherwise read as one
// with no sides at all (bc-mrgc review S6).
if sidesBeforeMerge(bm, result, policy) {
- return nil, false, ErrMatchSideMismatch
+ return nil, false, nil, ErrMatchSideMismatch
}
// The merge (bc-mrgc), the SAME owner the pool branch calls, and
// FIRST: everything below judges the merged match. A write whose
@@ -2666,7 +2685,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st
// against a result it is not changing (bc-cse finding 2).
// Returning an error makes UpdateBracket skip the save.
if mergeMatchWrite(bracketMatchAsResult(bm), result, policy, mc).Superseded() {
- return nil, false, ErrMatchSuperseded
+ return nil, false, nil, ErrMatchSuperseded
}
// A bout-row correction over a recorded withdrawal keeps the
// ruling (bc-tmfn). It must run BEFORE the downstream guard below,
@@ -2678,7 +2697,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st
// The guard runs forced too: force gets past a played later
// match, never a running one (bc-rfsw).
if err := guardDownstreamKnockoutCorrection(bracket, rIdx, mIdx, bm, result, policy, force); err != nil {
- return nil, false, err
+ return nil, false, nil, err
}
// Captured BEFORE the write, so the force branch below can tell a
// correction that actually changes the winner from one that does
@@ -2686,8 +2705,19 @@ 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.
priorWinner, priorWinnerID := propagatedWinnerOf(bracket, rIdx, mIdx, bm)
+ // The representatives every other match holds before the
+ // propagation below can re-seat a side and take one away
+ // (bracket_seat_audit.go): a played downstream match the write
+ // reopens names its lost pick on its own reopen line, one it
+ // leaves alone but re-seats has the door record a reseat line. A
+ // restore (the K3 rollback replaying a prior) is not a door and
+ // records nothing, so it takes no snapshot.
+ var before map[string][2]string
+ if policy == matchWriteForward {
+ before = repPickSnapshot(bracket, matchID)
+ }
if _, err := applyBracketMatchResult(bm, result, policy); err != nil {
- return nil, false, err
+ return nil, false, nil, err
}
// Propagate only a genuinely completed result. A "running" update is
// for live-status display, so the next round's SideA/SideB must stay
@@ -2704,27 +2734,30 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st
reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, bm.ID)
}
}
- return reopened, inherited, nil
+ clears := repPickClears(before, bracket)
+ markRepPicksCleared(reopened, clears)
+ return reopened, inherited, clears, nil
}
}
// The bronze (3rd-place) knockout lives in Bracket.ThirdPlaceMatch, NOT in
// Rounds, so the scan above never finds it. There is no propagation out of
// bronze: it has no downstream match, so the downstream-correction guard
- // does not apply here either.
+ // does not apply here either, and no other match can lose a pick to this
+ // write (so, unlike the round branch, no snapshot of the picks is taken).
if bracket.ThirdPlaceMatch != nil && bracket.ThirdPlaceMatch.ID == matchID {
if sidesBeforeMerge(bracket.ThirdPlaceMatch, result, policy) {
- return nil, false, ErrMatchSideMismatch
+ return nil, false, nil, ErrMatchSideMismatch
}
if mergeMatchWrite(bracketMatchAsResult(bracket.ThirdPlaceMatch), result, policy, mc).Superseded() {
- return nil, false, ErrMatchSuperseded
+ return nil, false, nil, ErrMatchSuperseded
}
inherited := preserveWithdrawalRuling(rulingOfBracketMatch(bracket.ThirdPlaceMatch), result, policy)
if _, err := applyBracketMatchResult(bracket.ThirdPlaceMatch, result, policy); err != nil {
- return nil, false, err
+ return nil, false, nil, err
}
- return nil, inherited, nil
+ return nil, inherited, nil, nil
}
- return nil, false, notFoundErrorf("bracket match %s not found", matchID)
+ return nil, false, nil, notFoundErrorf("bracket match %s not found", matchID)
}
// bracketMatchCarriesOwnResult reports whether bm holds a result someone
@@ -2838,11 +2871,17 @@ func winnerActuallyChanged(priorWinner, priorWinnerID string, bm *state.BracketM
// (operator ruling 2026-09-19: "no changes in the queue necessary if the
// matches were already played"). It carries its own audit note rather than
// owing one -- see downstreamReopenReason.
+//
+// It leaves the representatives alone and names none as cleared: the caller,
+// which holds the door's snapshot (repPickSnapshot), marks the reopened
+// matches from the diff once every re-seat of the write is done
+// (markRepPicksCleared).
func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correctedID string) []ReopenedMatch {
d := propagatedDownstreamOf(bracket, rIdx, mIdx)
var reopened []ReopenedMatch
for _, m := range d.played() {
- reopened = append(reopened, reopenDisplacedBracketMatch(m, downstreamReopenReason(correctedID)))
+ ref := reopenDisplacedBracketMatch(m, downstreamReopenReason(correctedID))
+ reopened = append(reopened, ref)
// 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
@@ -2902,6 +2941,9 @@ func reopenDisplacedBracketMatch(m *state.BracketMatch, reason string) ReopenedM
}
ref := bracketMatchRef(m)
ref.PriorDecision = priorDecision
+ // The reopen leaves the picks as they were; what the re-seat of a side
+ // does to them is judged by the door's repPickClears diff, which
+ // markRepPicksCleared copies onto the reopen line.
return ref
}
@@ -2909,9 +2951,10 @@ func reopenDisplacedBracketMatch(m *state.BracketMatch, reason string) ReopenedM
// winner and a hantei mark, and keeps the points struck in it. The row
// settles the encounter on its own: the next write copies its winner to the
// match (deriveDaihyosenWinner), matching it through the row's own side
-// names. After a correction those still name the competitor the correction
-// took out, so the one put in their place would inherit a win they never
-// fought. Only the verdict goes, exactly as at match level.
+// names. The row's names follow the match's (seatBracketSide), but the winner
+// it records was fought by the OLD pairing, so the one put in their place would
+// inherit a win they never fought. Only the verdict goes, exactly as at match
+// level.
func clearDaihyosenVerdict(subs []state.SubMatchResult) {
for i := range subs {
if subs[i].Position != state.DaihyosenSubPosition {
@@ -2938,7 +2981,12 @@ func (e *Engine) restoreForceReopened(h state.StoreTx, compID string, reopened [
reopened[i].Restored = e.restoreIfWithdrawalRemoved(h, compID, reopened[i].ID, reopened[i].PriorDecision, "", nil)
// The reopen changed the match outside the merge, so it records its
// own history entry (bc-mrgc).
- e.recordDirectHistory(h, compID, reopened[i].ID, doorDownstreamReopen, serverNowMs(), reopenedBracketGroups...)
+ groups := reopenedBracketGroups
+ if len(reopened[i].RepPicksCleared) > 0 {
+ // A copy: reopenedBracketGroups is shared.
+ groups = append(append([]string(nil), groups...), reopened[i].RepPicksCleared...)
+ }
+ e.recordDirectHistory(h, compID, reopened[i].ID, doorDownstreamReopen, serverNowMs(), groups...)
}
}
@@ -2979,8 +3027,8 @@ func requeueBracketMatch(m *state.BracketMatch, stamp int64) {
m.CorrectionReason = ""
// Mirror of the pool branch (mp-gmcg review): clear the reopen-pending
// audit debt so a reopened-then-requeued bracket match can be replayed
- // and finalized without applyCorrectionReasonUnderTx demanding a reason
- // for a result the requeue already discarded.
+ // and finalized without the handler's applyCorrectionReason demanding a
+ // reason for a result the requeue already discarded.
m.ReopenPending = false
// Revert fence (mp-y3nk): stamp now() so any pre-revert offline write
// (T_stale < T_revert) cannot put the verdict back on replay. Using 0
@@ -3081,7 +3129,7 @@ func propagatedWinnerOf(bracket *state.Bracket, rIdx, mIdx int, bm *state.Bracke
return "", ""
}
slot, slotID := next.SideB, next.SideBID
- if mIdx%2 == 0 {
+ if feedsSide(mIdx) == domain.MatchSideA {
slot, slotID = next.SideA, next.SideAID
}
if isUnresolvedBracketSide(slot) {
@@ -3136,7 +3184,7 @@ func newDownstreamKnockoutPlayedError(bm *state.BracketMatch, blocking []*state.
// placeholder rather than a competitor.
func displacedCompetitor(bm, blocking *state.BracketMatch, mIdx int) string {
slot := blocking.SideB
- if mIdx%2 == 0 {
+ if feedsSide(mIdx) == domain.MatchSideA {
slot = blocking.SideA
}
if slot != "" && !helper.IsWinnerOfPlaceholder(slot) {
@@ -3222,13 +3270,10 @@ func (e *Engine) propagateBracketWinner(bracket *state.Bracket, rIdx, mIdx int)
nextMatchIdx := mIdx / 2
nextM := &bracket.Rounds[rIdx+1][nextMatchIdx]
- if mIdx%2 == 0 {
- nextM.SideA = m.Winner
- nextM.SideAID = m.WinnerID
- } else {
- nextM.SideB = m.Winner
- nextM.SideBID = m.WinnerID
- }
+ // seatBracketSide, not a bare assignment: a side given another team takes
+ // its representative-bout pick away with it.
+ side := feedsSide(mIdx)
+ seatBracketSide(nextM, side, m.Winner, m.WinnerID)
// Feed the loser of a SEMIFINAL into the bronze (3rd-place) knockout match.
// The semifinal round index is len(Rounds)-2 (the round that feeds the
@@ -3259,13 +3304,7 @@ func (e *Engine) propagateBracketWinner(bracket *state.Bracket, rIdx, mIdx int)
// semifinal overwrites the correct bronze slot in place. A
// fill-first-empty scheme would leave a stale loser pinned once
// both slots are populated and a semifinal is later re-scored.
- if mIdx%2 == 0 {
- bronze.SideA = loser
- bronze.SideAID = loserID
- } else {
- bronze.SideB = loser
- bronze.SideBID = loserID
- }
+ seatBracketSide(bronze, side, loser, loserID)
}
}
@@ -3276,8 +3315,7 @@ func (e *Engine) propagateBracketWinner(bracket *state.Bracket, rIdx, mIdx int)
if r >= 0 && r < len(bracket.Rounds) && m >= 0 && m < len(bracket.Rounds[r]) {
srcM := bracket.Rounds[r][m]
if srcM.Status == state.MatchStatusCompleted {
- nextM.SideA = srcM.Winner
- nextM.SideAID = srcM.WinnerID
+ seatBracketSide(nextM, domain.MatchSideA, srcM.Winner, srcM.WinnerID)
}
}
}
@@ -3286,8 +3324,7 @@ func (e *Engine) propagateBracketWinner(bracket *state.Bracket, rIdx, mIdx int)
if r >= 0 && r < len(bracket.Rounds) && m >= 0 && m < len(bracket.Rounds[r]) {
srcM := bracket.Rounds[r][m]
if srcM.Status == state.MatchStatusCompleted {
- nextM.SideB = srcM.Winner
- nextM.SideBID = srcM.WinnerID
+ seatBracketSide(nextM, domain.MatchSideB, srcM.Winner, srcM.WinnerID)
}
}
}
@@ -3421,6 +3458,9 @@ func guardOverrideDownstreamKnockoutCorrection(bracket *state.Bracket, rIdx, mId
func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName string, modifiedAt int64, opts ...ForceOptions) (bool, error) {
fo := firstForceOptions(opts)
var reopened []ReopenedMatch
+ // The picks the override's propagation takes from matches it re-seats
+ // without reopening, for their own history lines (bracket_seat_audit.go).
+ var clears []repPickClear
// The write and its history entry are one transaction (bc-mrgc): a held
// (errLWWDropped) assertion makes no write at all, so it is recorded
// outside the transaction below, but an APPLIED override must not land
@@ -3457,12 +3497,13 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName
// that names the winner already recorded must not requeue
// the next round.
priorWinner, priorWinnerID := propagatedWinnerOf(bracket, rIdx, mIdx, m)
+ before := repPickSnapshot(bracket, matchId)
setBracketOverrideWinner(m, winnerName)
m.IsOverridden = true
m.Status = state.MatchStatusCompleted
// An override ends the match, so a reopened match closed out
// this way must not keep ReopenPending set: it bypasses
- // applyCorrectionReasonUnderTx/dischargeReopenPendingUnderTx,
+ // applyCorrectionReason/dischargeReopenPendingUnderTx,
// which clear it on every other way of ending a match.
m.ReopenPending = false
m.StampGroups(modifiedAt, state.GroupResult)
@@ -3474,12 +3515,15 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName
if fo.Force && winnerActuallyChanged(priorWinner, priorWinnerID, m) {
reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, m.ID)
}
+ clears = repPickClears(before, bracket)
+ markRepPicksCleared(reopened, clears)
return nil
}
}
}
// The bronze (3rd-place) knockout lives outside Rounds; handle it
- // here. Bronze has no downstream match, so no propagation is needed.
+ // here. Bronze has no downstream match, so no propagation is needed
+ // and no other match can lose a pick to this write: no snapshot.
if bracket.ThirdPlaceMatch != nil && bracket.ThirdPlaceMatch.ID == matchId {
bm := bracket.ThirdPlaceMatch
if !bracketMatchPlayable(bm) {
@@ -3517,6 +3561,10 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName
// would see a reopened match with no history line, or a bar the
// restore had not yet lifted/could wrongly lift).
e.restoreForceReopened(tx, compId, reopened)
+ // And a line for each downstream match the propagation re-seated
+ // without reopening, in the same transaction and beside the reopen
+ // lines (a reopened match's own line names its pick instead).
+ e.recordRepPickClears(tx, compId, clears, reopened)
return nil
})
if held {
diff --git a/internal/engine/scoring_test.go b/internal/engine/scoring_test.go
index 9fea23350..0842abdc1 100644
--- a/internal/engine/scoring_test.go
+++ b/internal/engine/scoring_test.go
@@ -1678,6 +1678,80 @@ func TestRecordBracketMatchResult_DaihyosenWinnerDerived(t *testing.T) {
assert.Equal(t, "TeamB", saved.Rounds[1][0].SideA, "TeamB must propagate to next round")
}
+// bc-dhrp: the representative bout names each side's member (the picker's pick)
+// beside the TEAM names it keeps. The member ids change nothing about the
+// encounter: the team that wins the bout still advances, and a hantei mark
+// on the winner's side is kept in the row as recorded.
+func TestRecordBracketMatchResult_DaihyosenWithRepresentativeIDs(t *testing.T) {
+ twoRounds := func() *state.Bracket {
+ return &state.Bracket{
+ Rounds: [][]state.BracketMatch{
+ {{ID: "r0m0", SideA: "TeamA", SideB: "TeamB", Status: state.MatchStatusScheduled}},
+ {{ID: "r1m0", SideA: "", SideB: "TeamC", Status: state.MatchStatusScheduled}},
+ },
+ }
+ }
+ repRow := func(ipponsA, ipponsB []string) state.SubMatchResult {
+ return state.SubMatchResult{
+ Position: -1, SideA: "TeamA", SideB: "TeamB",
+ IpponsA: ipponsA, IpponsB: ipponsB,
+ SideAMemberID: "rep-a", SideBMemberID: "rep-b", WinnerMemberID: "rep-b",
+ Decision: "daihyosen",
+ }
+ }
+
+ t.Run("a point decides the team the representative won for", func(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ compID := "dh-rep-ids-point"
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: "DH Rep IDs", Format: state.CompFormatKnockout,
+ Status: state.CompStatusKnockout, TeamSize: 3,
+ }))
+ require.NoError(t, store.SaveBracket(compID, twoRounds()))
+
+ row := repRow(nil, []string{"M"})
+ row.Winner = "TeamB"
+ _, err := eng.RecordMatchResultWithIneligibility(compID, "r0m0", &state.MatchResult{
+ ID: "r0m0", SideA: "TeamA", SideB: "TeamB", Status: state.MatchStatusCompleted,
+ SubResults: []state.SubMatchResult{row},
+ })
+ require.NoError(t, err)
+
+ saved, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ assert.Equal(t, "TeamB", saved.Rounds[0][0].Winner)
+ assert.Equal(t, "TeamB", saved.Rounds[1][0].SideA, "the winning team advances")
+ assert.Equal(t, "rep-b", saved.Rounds[0][0].SubResults[0].SideBMemberID, "the pick is kept on the row")
+ })
+
+ t.Run("a hantei mark on the winner's side is kept in the row", func(t *testing.T) {
+ eng, store, _ := setupTestEngine(t)
+ compID := "dh-rep-ids-hantei"
+ require.NoError(t, store.SaveCompetition(&state.Competition{
+ ID: compID, Name: "DH Rep IDs", Format: state.CompFormatKnockout,
+ Status: state.CompStatusKnockout, TeamSize: 3,
+ }))
+ require.NoError(t, store.SaveBracket(compID, twoRounds()))
+
+ // A tied scoreline (1-1) decided by the judges' mark on side B.
+ row := repRow([]string{"M"}, []string{"K", "Ht"})
+ row.Winner = "TeamB"
+ _, err := eng.RecordMatchResultWithIneligibility(compID, "r0m0", &state.MatchResult{
+ ID: "r0m0", SideA: "TeamA", SideB: "TeamB", Status: state.MatchStatusCompleted,
+ SubResults: []state.SubMatchResult{row},
+ })
+ require.NoError(t, err)
+
+ saved, err := store.LoadBracket(compID)
+ require.NoError(t, err)
+ assert.Equal(t, "TeamB", saved.Rounds[0][0].Winner)
+ assert.Equal(t, "TeamB", saved.Rounds[1][0].SideA)
+ got := saved.Rounds[0][0].SubResults[0]
+ assert.Equal(t, []string{"K", "Ht"}, got.IpponsB, "the mark stays where the judges put it")
+ assert.True(t, got.HanteiDecided())
+ })
+}
+
// TestPreviewBracket_RejectsAllMutations verifies that all bracket mutation
// paths (scoring, override, court/time reassignment) return an error when
// bracket.Preview is true (mp-9dz). The UI disables scoring for preview
diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go
index d29ddc1f8..9695a7828 100644
--- a/internal/engine/scoring_tx.go
+++ b/internal/engine/scoring_tx.go
@@ -72,17 +72,24 @@ import (
// T156.
//
// opts (bc-kcdg) is variadic ForceOptions purely to keep every pre-existing
-// call site source-compatible; see ForceOptions' doc comment. Known gap: a
-// forced write that force-reopens downstream matches and is THEN rolled back
-// 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. 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.
+// call site source-compatible; see ForceOptions' doc comment.
+//
+// K3 runs in two halves. The pre-write half (refuseConcurrentWithdrawal)
+// resolves the loser from the stored pairing exactly as the post-write half
+// (recordIneligibilityFromDecision) does, so a withdrawal the post-write check
+// would refuse is refused BEFORE anything is written: no downstream match is
+// reopened or re-seated and no history line is recorded. The post-write arm and
+// its rollback stay as a backstop, reached only if the two halves ever
+// disagree (a merge that works the winner out again differently from the
+// payload). That rollback (rollbackMatchResultTx replays `prior` through this
+// same match id) restores the corrected match ONLY: the downstream matches
+// forceReopenDownstreamChain reopened or propagateBracketWinner re-seated stay
+// as written, and so do the knockout matches a forced POOL correction reopens
+// (requalifyAfterPoolWrite; the rollback restores the pool match, not the
+// bracket). A whole-bracket restore is deliberately not attempted: the
+// eligibility restores those reopens made (restoreForceReopened) are
+// competitor-status writes made before K3, and a bracket-only restore would put
+// a downstream match back to a verdict whose competitor stays restored.
func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, matchID string, result *state.MatchResult, opts ...ForceOptions) (*domain.CompetitorStatus, error) {
fo := firstForceOptions(opts)
result.ID = matchID
@@ -430,16 +437,32 @@ func keepQueuedScore(prior, result *state.MatchResult) {
// AlreadyIneligibleError when a different match has already made them
// ineligible and the decision is a kiken (checkConcurrentIneligibility, the
// check recordDecisionTx makes before its own write; a fusenpai there chains
-// onto the existing bar instead, alreadyBarredRefusal). 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.
+// onto the existing bar instead, alreadyBarredRefusal). The loser is read off a
+// scratch copy with the stored pairing folded in the way the write folds it, so
+// it is the loser the post-write check would name: the sides' NAMES first (what
+// reconcileSides gives an empty payload name inside the write), then the ids
+// (backfillMatchIdentity). Both matter. A hand-built /score or bulk payload
+// that names a winner but no sides, no winnerSide and no winnerId carries the
+// loser only as "the side the winner is not", which resolveWinnerIDFromSides can
+// read only against side names; a probe with the names still empty found no
+// loser here, so the write went on, re-seated the next round's side (its
+// representative pick cleared, its stamp moved), and the post-write check then
+// refused it with a rollback that restores the written match row alone (the
+// SPA's /decision sets winnerSide and its /score sends the sides, so only a
+// payload built by hand reaches this). 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 probe.SideA == "" {
+ probe.SideA = prior.SideA
+ }
+ if probe.SideB == "" {
+ probe.SideB = prior.SideB
+ }
if err := backfillMatchIdentity(&probe, prior, matchWriteForward); err != nil {
return nil
}
diff --git a/internal/engine/sub_hantei_preserve_test.go b/internal/engine/sub_hantei_preserve_test.go
index 56d319413..31e691a63 100644
--- a/internal/engine/sub_hantei_preserve_test.go
+++ b/internal/engine/sub_hantei_preserve_test.go
@@ -951,3 +951,45 @@ func TestPreserveSubHantei_ZeroZeroWithdrawalIsNotSilence(t *testing.T) {
assert.Equal(t, []string{"K"}, incoming[0].IpponsB)
})
}
+
+// The winner's member id is derived again from the restored winner. The
+// restored hantei winner is the stored one, so an incoming winner id must not
+// outlive it: an empty one would lose the id, and a stale one naming the
+// other side's pick would credit the wrong side, id-first, were it not derived
+// again from the winner's name by ReconcileWinnerMemberID.
+func TestPreserveSubHanteiRestoresTheWinnersMemberIDWithTheWinner(t *testing.T) {
+ dh := state.DaihyosenSubPosition
+ stored := func() []state.SubMatchResult {
+ return []state.SubMatchResult{{
+ Position: dh, SideA: "Kyoto", SideB: "Osaka",
+ SideAMemberID: "member-a", SideBMemberID: "member-b",
+ IpponsA: []string{domain.HanteiMark}, IpponsB: []string{},
+ Winner: "Kyoto", WinnerMemberID: "member-a", Decision: "daihyosen",
+ }}
+ }
+ for name, incomingWinnerID := range map[string]string{
+ "an empty incoming id restores the stored one": "",
+ "a stale id naming the other side's pick is replaced by the stored one": "member-b",
+ } {
+ t.Run(name, func(t *testing.T) {
+ incoming := []state.SubMatchResult{{
+ Position: dh, SideA: "Kyoto", SideB: "Osaka",
+ SideAMemberID: "member-a", SideBMemberID: "member-b",
+ WinnerMemberID: incomingWinnerID, Decision: "daihyosen",
+ }}
+ preserveSubHantei(stored(), incoming)
+ require.True(t, incoming[0].HanteiDecided())
+ assert.Equal(t, "Kyoto", incoming[0].Winner)
+ assert.Equal(t, "member-a", incoming[0].WinnerMemberID)
+ })
+ }
+
+ t.Run("a stored id naming a pick the merge replaced is derived again", func(t *testing.T) {
+ incoming := []state.SubMatchResult{{
+ Position: dh, SideA: "Kyoto", SideB: "Osaka",
+ SideAMemberID: "member-a2", SideBMemberID: "member-b", Decision: "daihyosen",
+ }}
+ preserveSubHantei(stored(), incoming)
+ assert.Equal(t, "member-a2", incoming[0].WinnerMemberID, "the winner id names a standing pick")
+ })
+}
diff --git a/internal/engine/workbook.go b/internal/engine/workbook.go
index f6765bb24..114563178 100644
--- a/internal/engine/workbook.go
+++ b/internal/engine/workbook.go
@@ -41,7 +41,8 @@ import (
// 6. Names to Print sheet, one per shiaijo (helper.CreateNamesWithPoolToPrint,
// or helper.CreateNamesToPrint over namesToPrintPlayers -- same branch as
// step 1)
-// 7. Kachinuki Detail sheet (helper.WriteKachinukiDetailSheet)
+// 7. Kachinuki Detail sheet (helper.WriteKachinukiDetailSheet) and
+// Representative Bouts sheet (helper.WriteRepresentativeBoutsSheet)
//
// Every caller-specific extra rides OUTSIDE this function, called by the
// caller before or after: the stored-draw export's Tags sheet (needs the
@@ -51,8 +52,8 @@ import (
// Elimination Matches sheets, and it is safe for them to run AFTER this
// function returns rather than interleaved where they used to sit: nothing
// this function does past PrintPoolMatches (step 3) and
-// PrintEliminationWithBronze (step 4) touches either sheet again. Steps 5-7 write to the Tree, Names to Print and
-// Kachinuki Detail sheets, none of which any overlay reads or writes.
+// PrintEliminationWithBronze (step 4) touches either sheet again. Steps 5-7 write to the Tree, Names to Print,
+// Kachinuki Detail and Representative Bouts sheets, none of which any overlay reads or writes.
//
// Parameters are explicit values, not a *state.Store: every strict/best-
// effort load already happened in the caller (mp-yuy8 criterion 6) and must
@@ -73,6 +74,12 @@ import (
// prints a knockout-only competition's skeleton, there is no stored
// bracket for that read to list, and the sections come from the rounds
// step 4 printed instead (helper.BlankKachinukiSections).
+// - representativeBouts is the caller's own read of the representative bouts
+// (collectRepresentativeBouts; RepresentativeBoutMatches takes only the
+// id), for the same reason: step 7 writes them, whichever way they were
+// read. It is empty for every competition but a fixed-order team one whose
+// matches hold such a bout, and the blank template /create draws has no
+// stored match to hold one, so its sheets are unchanged.
//
// courts is the caller's own CompetitionCourts(comp, tourn) result rather
// than a tournament parameter this function would resolve itself, because
@@ -92,8 +99,8 @@ import (
// pools) -- bc-pnum A8's single guarded branch for a knockout-only
// competition (never has a pools.csv, so nothing above would otherwise
// populate the Data / Names-to-Print sheets at all): non-nil only for that
-// one shape, nil for every other competition. Unlike draw and
-// kachinukiMatches (see above), both callers derived this identically from
+// one shape, nil for every other competition. Unlike draw,
+// kachinukiMatches and representativeBouts (see above), both callers derived this identically from
// arguments this function already takes (comp, pools), so there was nothing
// caller-specific left to preserve by keeping it a parameter -- deriving it
// here instead is what makes it impossible for the two callers' Data /
@@ -121,6 +128,7 @@ func (e *Engine) RenderCompetitionWorkbook(
courtOfPool map[string]string,
draw *helper.KnockoutDraw,
kachinukiMatches []helper.KachinukiMatchDetail,
+ representativeBouts []helper.KachinukiMatchDetail,
) ([][]int, []helper.Player, error) {
namesToPrintPlayers, err := e.KnockoutNamesToPrint(comp, pools, bracket)
if err != nil {
@@ -284,14 +292,19 @@ func (e *Engine) RenderCompetitionWorkbook(
helper.CreateNamesWithPoolToPrint(f, pools, comp.EffectiveWithZekkenName(), courts, courtOfPool, playerCoords, comp.EffectiveNumberPrefix())
}
- // 7. Kachinuki Detail sheet (T195-T203, CHK037). Opt-in: only emitted
- // when the competition runs the kachinuki team-match format and its
- // draw has matches, or before the draw the skeleton step 4 printed.
- // The renderer is a no-op for empty input, so this is safe for every
- // other team format.
+ // 7. Kachinuki Detail sheet (T195-T203, CHK037) and Representative Bouts
+ // sheet. Opt-in: the first is only emitted when the competition runs
+ // the kachinuki team-match format and its draw has matches, or before
+ // the draw the skeleton step 4 printed; the second only when a
+ // fixed-order team competition's draw holds a representative bout.
+ // Each renderer is a no-op for empty input, so this is safe for every
+ // other competition.
if err := helper.WriteKachinukiDetailSheet(f, kachinukiMatches); err != nil {
return nil, nil, err
}
+ if err := helper.WriteRepresentativeBoutsSheet(f, representativeBouts); err != nil {
+ return nil, nil, err
+ }
// namesToPrintPlayers is returned as a second value so callers that also
// need it for their own extras (ExportCompetitionXlsx's Tags sheet)
diff --git a/internal/export/builder.go b/internal/export/builder.go
index 4b177ca2b..66f717ab8 100644
--- a/internal/export/builder.go
+++ b/internal/export/builder.go
@@ -135,6 +135,14 @@ func BuildResultsWorkbook(store *state.Store, eng *engine.Engine, compID string)
return nil, fmt.Errorf("export: collect kachinuki detail: %w", err)
}
+ // Representative Bouts sheet's bouts: the same opt-in as the stored-draw
+ // export's, a fixed-order team competition's representative bouts, which
+ // no other sheet of this workbook lists.
+ representativeBouts, err := eng.RepresentativeBoutMatches(compID)
+ if err != nil {
+ return nil, fmt.Errorf("export: collect representative bouts: %w", err)
+ }
+
f, err := excel.NewFileFromScratch()
if err != nil {
return nil, fmt.Errorf("export: create workbook: %w", err)
@@ -144,7 +152,8 @@ func BuildResultsWorkbook(store *state.Store, eng *engine.Engine, compID string)
}()
// The shared sheet pipeline (mp-yuy8): Data, Pool Draw, Pool Matches,
- // knockout, Tree cleanup, Names to Print, Kachinuki Detail -- identical
+ // knockout, Tree cleanup, Names to Print, Kachinuki Detail, Representative
+ // Bouts -- identical
// steps and order to Engine.ExportCompetitionXlsx. poolsByCourt is the one
// artifact the overlays below need (PrintPoolMatches's pool-index grouping
// per shiaijo band); everything else PrintPoolMatches/AddPoolDataToSheet
@@ -161,7 +170,7 @@ func BuildResultsWorkbook(store *state.Store, eng *engine.Engine, compID string)
// The second return value (the knockout-only numbered roster) is the
// stored-draw export's own extra (its Tags sheet); this results
// export has no such extra and discards it.
- poolsByCourt, _, err := eng.RenderCompetitionWorkbook(f, comp, pools, bracket, courts, courtOfPool, draw, kachinukiMatches)
+ poolsByCourt, _, err := eng.RenderCompetitionWorkbook(f, comp, pools, bracket, courts, courtOfPool, draw, kachinukiMatches, representativeBouts)
if err != nil {
return nil, fmt.Errorf("export: %w", err)
}
diff --git a/internal/export/builder_test.go b/internal/export/builder_test.go
index 89d52e2e7..cdd61e637 100644
--- a/internal/export/builder_test.go
+++ b/internal/export/builder_test.go
@@ -3969,6 +3969,119 @@ func TestBuildResultsWorkbook_FixedFormatNoKachinukiSheet(t *testing.T) {
"fixed-format comps must not emit a Kachinuki Detail sheet")
}
+// saveFixedTeamBracket turns compID (as testSetup saved it) into a fixed-order
+// team knockout of two teams, Red and White, with one named member each, and
+// stores a completed bracket of their one match: a tied numbered bout, and,
+// when withRepresentativeBout, the representative bout (Red's Hiro against
+// White's Sayaka) that a hantei decided for Red. The competition's Status is
+// the knockout one because the competitor numbers (the "T1.1" labels) come
+// from the draw only once it is.
+func saveFixedTeamBracket(t *testing.T, store *state.Store, compID string, withRepresentativeBout bool) {
+ t.Helper()
+ comp, err := store.LoadCompetition(compID)
+ require.NoError(t, err)
+ comp.Format = state.CompFormatKnockout
+ comp.Status = state.CompStatusKnockout
+ comp.Kind = "team"
+ comp.TeamMatchType = state.TeamMatchTypeFixed
+ comp.TeamSize = 3
+ comp.NumberPrefix = "T"
+ require.NoError(t, store.SaveCompetition(comp))
+
+ redID, whiteID := helper.NewUUID4(), helper.NewUUID4()
+ require.NoError(t, store.SaveParticipants(compID, []domain.Player{
+ {ID: redID, Name: "RedTeam", Dojo: "DojoR"},
+ {ID: whiteID, Name: "WhiteTeam", Dojo: "DojoW"},
+ }))
+ hiro, err := store.AddTeamMember(compID, redID, "Hiro")
+ require.NoError(t, err)
+ sayaka, err := store.AddTeamMember(compID, whiteID, "Sayaka")
+ require.NoError(t, err)
+
+ subs := []state.SubMatchResult{
+ {Position: 1, SideA: "Hiro", SideAMemberID: hiro.ID, SideB: "Sayaka", SideBMemberID: sayaka.ID, Decision: "hikiwake"},
+ }
+ if withRepresentativeBout {
+ subs = append(subs, state.SubMatchResult{
+ Position: state.DaihyosenSubPosition, SideA: "RedTeam", SideB: "WhiteTeam",
+ SideAMemberID: hiro.ID, SideBMemberID: sayaka.ID, Winner: "RedTeam",
+ Decision: string(domain.DecisionDaihyosen), IpponsA: []string{domain.HanteiMark},
+ })
+ }
+ require.NoError(t, store.SavePools(compID, []helper.Pool{}))
+ require.NoError(t, store.SavePoolMatches(compID, nil))
+ require.NoError(t, store.SaveBracket(compID, &state.Bracket{
+ DrawOrder: []string{redID, whiteID},
+ Rounds: [][]state.BracketMatch{{{
+ ID: "R1M0", MatchNumber: 1, DisplayRound: 1,
+ SideA: "RedTeam", SideAID: redID, SideB: "WhiteTeam", SideBID: whiteID,
+ Winner: "RedTeam", Status: state.MatchStatusCompleted, SubResults: subs,
+ }}},
+ }))
+}
+
+// TestBuildResultsWorkbook_RepresentativeBoutsSheetIffARepresentativeBoutExists
+// pins the results workbook's Representative Bouts sheet (the main sheets'
+// team block lists numbered bouts only, so a representative bout and its
+// representatives reached no sheet): present, editable, naming both
+// representatives, DH as the bout number and (DH) in the centre, exactly when a
+// match of the fixed-order team competition holds a position -1 row, and never
+// beside a Kachinuki Detail sheet.
+func TestBuildResultsWorkbook_RepresentativeBoutsSheetIffARepresentativeBoutExists(t *testing.T) {
+ t.Parallel()
+
+ build := func(t *testing.T, withRepresentativeBout bool) *excelize.File {
+ t.Helper()
+ dir, store, eng, compID := testSetup(t)
+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
+
+ saveFixedTeamBracket(t, store, compID, withRepresentativeBout)
+
+ data, err := BuildResultsWorkbook(store, eng, compID)
+ require.NoError(t, err)
+ f, err := excelize.OpenReader(bytes.NewReader(data))
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = f.Close() })
+ return f
+ }
+
+ t.Run("with a representative bout", func(t *testing.T) {
+ t.Parallel()
+ f := build(t, true)
+
+ require.Contains(t, f.GetSheetList(), helper.SheetRepresentativeBouts)
+ assert.NotContains(t, f.GetSheetList(), helper.SheetKachinukiDetail,
+ "a fixed-order competition never gets a Kachinuki Detail sheet")
+ protection, err := f.GetSheetProtection(helper.SheetRepresentativeBouts)
+ require.NoError(t, err)
+ assert.Equal(t, excelize.SheetProtectionOptions{}, protection, "the sheet holds no formula to protect")
+
+ rows, err := f.GetRows(helper.SheetRepresentativeBouts)
+ require.NoError(t, err)
+ var cells []string
+ for _, row := range rows {
+ cells = append(cells, row...)
+ }
+ flat := strings.Join(cells, "|")
+ assert.Contains(t, flat, "(Representative bout)", "the section is titled")
+ assert.Contains(t, flat, "Hiro", "Aka's representative")
+ assert.Contains(t, flat, "Sayaka", "Shiro's representative")
+ assert.Contains(t, flat, helper.RepresentativeBoutRowLabel+" T1.1 Hiro", "DH is the bout's number, then the representative")
+ assert.Contains(t, cells, "(DH)", "the centre carries the representative-bout mark")
+ assert.Contains(t, cells, "Ht", "the hantei mark rides beside the winner")
+ assert.NotContains(t, flat, "-1", "the stored position is never printed")
+ })
+
+ t.Run("without one", func(t *testing.T) {
+ t.Parallel()
+ f := build(t, false)
+
+ assert.NotContains(t, f.GetSheetList(), helper.SheetRepresentativeBouts,
+ "no representative bout, no sheet: the main sheets' workbook is unchanged")
+ assert.NotContains(t, f.GetSheetList(), helper.SheetKachinukiDetail)
+ })
+}
+
// readCourtBandLetters reads a court-banded sheet's shiaijo band headers back
// out of the rendered workbook, in column order, and asserts each band sits on
// the court grid and is non-empty. bctest.ReadCourtBands does the reading (the
diff --git a/internal/export/pipeline_parity_test.go b/internal/export/pipeline_parity_test.go
index c1e7f9cbb..f1874d458 100644
--- a/internal/export/pipeline_parity_test.go
+++ b/internal/export/pipeline_parity_test.go
@@ -33,7 +33,7 @@ var engineOnlySheets = []string{helper.SheetTags}
// TestExportPipelineSheetParity guards the two workbook builders that both
// walk the "data / Pool Draw / Pool Matches / Elimination Matches / Tree N /
-// Names to Print X / Kachinuki Detail / ..." sheet pipeline for one
+// Names to Print X / Kachinuki Detail / Representative Bouts / ..." sheet pipeline for one
// competition: Engine.ExportCompetitionXlsx (internal/engine/export.go, the
// stored-draw export) and BuildResultsWorkbook (internal/export/builder.go,
// the results export). Before mp-yuy8 the two functions were hand-maintained
@@ -163,6 +163,22 @@ func TestExportPipelineSheetParity(t *testing.T) {
},
mustAppearInBoth: []string{helper.SheetKachinukiDetail},
},
+ {
+ // The Representative Bouts sheet's twin of the case above: a
+ // fixed-order team knockout whose match was settled by a
+ // representative bout. Engine.collectRepresentativeBouts (the
+ // stored-draw export) and Engine.RepresentativeBoutMatches (the
+ // results export) are two reads feeding one renderer, and a
+ // sheet one of them forgot is the R8 bug again. The fixture is
+ // builder_test.go's saveFixedTeamBracket, a hand-crafted bracket
+ // on the same Knockout-with-no-pools footing as the case above.
+ name: "fixed_team_bracket_representative_bout",
+ configure: func(t *testing.T, store *state.Store, eng *engine.Engine, compID string) {
+ t.Helper()
+ saveFixedTeamBracket(t, store, compID, true)
+ },
+ mustAppearInBoth: []string{helper.SheetRepresentativeBouts},
+ },
{
// A pure knockout competition (no pools) drives a different
// leaf-order path (isPureKnockout / knockoutLeaves /
diff --git a/internal/helper/constants.go b/internal/helper/constants.go
index 60312929f..2c14f9135 100644
--- a/internal/helper/constants.go
+++ b/internal/helper/constants.go
@@ -172,6 +172,10 @@ const EliminationMatchTitleFormat = "Round %d - Match %d"
// SheetKachinukiDetail is opt-in: only emitted when a competition has
// teamMatchType=kachinuki and a draw with matches in it. See
// excel_kachinuki.go (T199–T203).
+//
+// SheetRepresentativeBouts is opt-in too: only emitted when a fixed-order team
+// competition's results hold at least one representative bout. It is drawn by
+// the same renderer as the Kachinuki Detail sheet.
const (
SheetData = "data"
SheetTimeEstimator = "Time Estimator"
@@ -182,4 +186,12 @@ const (
SheetTags = "Tags"
SheetTree = "Tree"
SheetKachinukiDetail = "Kachinuki Detail"
+
+ SheetRepresentativeBouts = "Representative Bouts"
)
+
+// RepresentativeBoutRowLabel is what a representative bout's row prints where a
+// numbered bout prints its number: the bout is the extra one appended to a
+// tied knockout encounter (position -1), not one of the numbered bouts, so a
+// number would name a bout the team match does not have.
+const RepresentativeBoutRowLabel = "DH"
diff --git a/internal/helper/excel.go b/internal/helper/excel.go
index fd3700d6a..64717b99a 100644
--- a/internal/helper/excel.go
+++ b/internal/helper/excel.go
@@ -2176,9 +2176,10 @@ func ProtectSheets(f *excelize.File, sheetNames []string) {
func ProtectAllSheets(f *excelize.File) {
for _, name := range f.GetSheetList() {
// Data, Time Estimator, and Pool Draw remain fully editable, and so
- // does Kachinuki Detail: it holds no formula to protect, and its
- // empty bout rows are there to be filled in.
- if name == SheetData || name == SheetTimeEstimator || name == SheetPoolDraw || name == SheetKachinukiDetail {
+ // do Kachinuki Detail and Representative Bouts: they hold no formula
+ // to protect, and the empty bout rows of the first are there to be
+ // filled in.
+ if name == SheetData || name == SheetTimeEstimator || name == SheetPoolDraw || name == SheetKachinukiDetail || name == SheetRepresentativeBouts {
continue
}
ProtectSheets(f, []string{name})
diff --git a/internal/helper/excel_kachinuki.go b/internal/helper/excel_kachinuki.go
index 48f50af95..9a91fb94e 100644
--- a/internal/helper/excel_kachinuki.go
+++ b/internal/helper/excel_kachinuki.go
@@ -1,7 +1,13 @@
package helper
// excel_kachinuki.go renders the "Kachinuki Detail" sheet, one section per
-// kachinuki ("winner-stays-on") team match with full bout-by-bout detail.
+// kachinuki ("winner-stays-on") team match with full bout-by-bout detail, and,
+// with the same renderer, the "Representative Bouts" sheet of a fixed-order team
+// competition: one section per match that holds a representative bout, listing
+// that bout alone (its row prints RepresentativeBoutRowLabel where a numbered
+// bout prints its number, and its fighters are the representatives each team
+// picked). Everything below describes the Kachinuki Detail sheet; the other
+// differs only in its sheet name, its section suffix and that label.
//
// Each section is drawn like a team match block on the Elimination Matches
// sheet (operator decision 2026-10-02): the same seven columns and widths, the
@@ -28,7 +34,8 @@ package helper
// domain.SideMarksAB) go in the side's outer score cell, the cell the results
// overlay fills on the match sheets, and the centre carries the bout's one
// closed-set middle mark (domain.MiddleMark: X or (E); kachinuki has no
-// daihyosen, so (DH) never appears here) or stays empty, as there. The
+// daihyosen, so (DH) appears only on the Representative Bouts sheet) or stays
+// empty, as there. The
// match's own Pool or Elimination Matches row carries the result, so a
// section has no Winner or Decision column (operator decision 2026-09-27)
// and no tally: it ends at its last bout row (operator decision 2026-10-02).
@@ -52,7 +59,9 @@ import (
// WhiteLeft takes the pair in side order and places it in sheet columns, so
// nothing upstream of the sheet writer needs to know about column layout.
type KachinukiBout struct {
- Position int // 1-based bout index within the team match
+ // Position is the 1-based bout index within the team match, or -1 for a
+ // representative bout, which prints RepresentativeBoutRowLabel instead.
+ Position int
SideAName string // player name for Side A
// SideALabel is the squad member's "T10.1"-style label (bc-pnum: "make
// a team member's label available to the public surfaces"), composed
@@ -73,8 +82,9 @@ type KachinukiBout struct {
ScoreB string
// Middle is the ONE mark the bout's centre cell may carry
// (domain.MiddleMark): "" (the cell stays empty), "X"
- // (hikiwake), or "(E)" (overtime). Kachinuki has no daihyosen, so
- // "(DH)" never appears here.
+ // (hikiwake), "(E)" (overtime) or, on a representative bout's row only,
+ // "(DH)". Kachinuki has no daihyosen, so "(DH)" never appears on the
+ // Kachinuki Detail sheet.
Middle string
// MarkA/MarkB are the per-side RESULT marks (domain.SideMarksAB) in
// SIDE order: Ht (hantei), Kiken (withdrawer) or Fus. (no-show / a
@@ -187,6 +197,22 @@ const kachinukiFighterColWidth = 38
// created, the caller is responsible for checking the input length AND
// the renderer guards against accidental emission of an empty sheet.
func WriteKachinukiDetailSheet(f *excelize.File, matches []KachinukiMatchDetail) error {
+ return writeBoutDetailSheet(f, SheetKachinukiDetail, "Kachinuki", matches)
+}
+
+// WriteRepresentativeBoutsSheet creates the SheetRepresentativeBouts sheet and
+// writes one section per match, each listing that match's representative bout
+// (the extra bout that settles a tied knockout encounter of a fixed-order team
+// competition; the caller builds each section's Bouts from it alone). It is the
+// Kachinuki Detail sheet's renderer under another name and section suffix, so
+// the two read alike, and like it creates no sheet for empty input.
+func WriteRepresentativeBoutsSheet(f *excelize.File, matches []KachinukiMatchDetail) error {
+ return writeBoutDetailSheet(f, SheetRepresentativeBouts, "Representative bout", matches)
+}
+
+// writeBoutDetailSheet is the one renderer of a bout-detail sheet: sheet names
+// it and titleSuffix closes each section's title, "
diff --git a/web-mobile/js/admin_schedule.jsx b/web-mobile/js/admin_schedule.jsx
index 0b24f1fe5..c5769f1b6 100644
--- a/web-mobile/js/admin_schedule.jsx
+++ b/web-mobile/js/admin_schedule.jsx
@@ -21,7 +21,7 @@ import {
} from './admin_schedule_pacing.jsx';
import { MatchLineupPanel } from './admin_schedule_lineup.jsx';
import { AdminExport } from './admin_schedule_export.jsx';
-import { startPatch, AdminScoreEditor, AdminScoreEditorPage } from './admin_schedule_score_editor.jsx';
+import { AdminScoreEditor, AdminScoreEditorPage } from './admin_schedule_score_editor.jsx';
import { AdminSchedulePage } from './admin_schedule_page.jsx';
// ES surface for the vitest suite: every symbol the unit tests import is
@@ -39,7 +39,6 @@ window.AdminScoreEditorPage = AdminScoreEditorPage;
window.AdminScoreEditor = AdminScoreEditor;
window.AdminExport = AdminExport;
window.filterMatchesByCourt = filterMatchesByCourt;
-window.startPatch = startPatch;
// Reused by the shiaijo operator console so team-match lineups can be set
// without leaving that page (mp-c2yr).
window.MatchLineupPanel = MatchLineupPanel;
diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx
index 58dca3ddf..87e7c8438 100644
--- a/web-mobile/js/admin_schedule_score_editor.jsx
+++ b/web-mobile/js/admin_schedule_score_editor.jsx
@@ -1,14 +1,17 @@
// Score editor components extracted from admin_schedule.jsx (mp-d7tl).
-// startPatch, ScoreEditCourtBtn (local), AdminScoreEditor, AdminScoreEditorPage.
+// ScoreEditCourtBtn (local), AdminScoreEditor, AdminScoreEditorPage.
-import { writeDidNotLand, writeKeepsEditorOpen } from './write_result.jsx';
+import { writeDidNotLand, writeKeepsEditorOpen, startWhileStartingMessage, startWasBlockedByStartMessage, wasToasted } 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, teamNameMark } from './match_scoreboard.jsx';
import { sameCompetitor } from './competitor_identity.jsx';
-import { scoreRowMatchLabel } from './pool_ids.jsx';
+// The withdrawn team's mark, imported from its leaf rather than read off
+// `window.teamMatchMarks` (a missing global painted no mark and no error).
+import { teamMatchMarks } from './side_marks.jsx';
+import { scoreRowMatchLabel, scoreRowMatchName } 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';
@@ -22,7 +25,11 @@ import { isBarredMatch, sideBarredByDecision, involvesCompetitor } from './ineli
// matters for this file's own render suite.
import { BarredMatchNotice } from './barred_match_notice.jsx';
import { matchShowsScore } from './match_shows_score.jsx';
-import { GROUP_RESULT } from './match_groups.jsx';
+// PR #463 round 13 (gap C): how a Start is written (startPatch), what its
+// answer means (classifyStartOutcome, startFailureMessage) and the one-start-at-
+// a-time guard are start_match.jsx's, shared with the court console, which had
+// all three while this page swallowed a refused start.
+import { startPatch, classifyStartOutcome, startFailureMessage, createStartGuard, startRefusalStands } from './start_match.jsx';
const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA } = React;
@@ -49,7 +56,7 @@ const getScoreBtnClass = window.getScoreBtnClass;
export { scoreRowMatchLabel };
// ---------- Score editor ----------
-export function AdminScoreEditorPage({ tournament, onBack, onEditScore, onMoveCourt, onLogout, onViewerMode, password }) {
+export function AdminScoreEditorPage({ tournament, onBack, onEditScore, onMoveCourt, onLogout, onViewerMode, password, showToast }) {
return (
Update scores or correct past matches across the tournament. Changes propagate through the bracket.
-
+
);
@@ -84,25 +91,6 @@ function ScoreEditCourtBtn({ m, courts, onMoveCourt }) {
);
}
-// Module-level factory so admin_shiaijo.jsx can consume it via window.startPatch.
-// startOnly (bc-sbq): this write only starts the match, so the server keeps the
-// score the match already holds (a match sent back to the queue keeps one);
-// toBackendMatchResult leaves the empty scoreline below off the wire. The
-// editors' own Start sends their board unflagged, so an operator who cleared
-// every mark still clears it.
-//
-// bc-mrgc: a start changes the status and nothing else, so it names the result
-// group alone (match_groups.jsx): the server keeps every other group of the
-// match as stored, the score a send-back kept included.
-export function startPatch() {
- return {
- startOnly: true,
- changed: [GROUP_RESULT],
- status: "running", winner: null, ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0,
- score: { type: "ippon", winnerPts: 0, loserPts: 0, ippons: [], fouls: { a: 0, b: 0 }, live: true, corrected: false },
- };
-}
-
export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCompId, password, showToast }) {
const [filter, setFilter] = useStateA("");
const [compFilter, setCompFilter] = useStateA(restrictToCompId || "all");
@@ -152,6 +140,121 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom
const openMatch = openKey ? allMatches.find((m) => scoreKeyOf(m) === openKey) || null : null;
const lineupMatch = lineupKey ? allMatches.find((m) => scoreKeyOf(m) === lineupKey) || null : null;
+ // PR #463 round 13 (gap C, bc-aadv): the automatic start the server REFUSED
+ // (Finish + Start Next, or the start after a decision), kept as
+ // { key, msg, waitingOn } so the refused match's list row can say why for as
+ // long as it is true. The court console keeps the same on its Up next card
+ // (admin_shiaijo.jsx startError); this page has no Up next card, so the ROW is
+ // the persistent surface. It is not the editor: the modal covers the list, and
+ // the editor is the operator's scoring surface, not this page's to put a
+ // start notice into. The toast says it at the moment it happens; the row keeps
+ // saying it after the toast is gone and after the editor is closed.
+ // `waitingOn` is the key of the start that was still out when this one was
+ // refused for it, so the sentence is rewritten in the past tense when that
+ // start lands (startWasBlockedByStartMessage) and kept.
+ const [startRefusal, setStartRefusal] = useStateA(null);
+ // One start at a time (start_match.jsx createStartGuard), created once.
+ const startGuardRef = useRefA(null);
+ if (!startGuardRef.current) startGuardRef.current = createStartGuard();
+ // The notice is read from the LIVE row, never from a snapshot of it: it shows
+ // only while the refused match is still scheduled, so it cannot outlive the
+ // start that finally went through or a result recorded another way.
+ const startRefusalKey = startRefusal ? startRefusal.key : null;
+ const startRefusalRow = startRefusalKey ? allMatches.find((m) => scoreKeyOf(m) === startRefusalKey) : null;
+ const startRefusalHolds = startRefusalStands({ row: startRefusalRow, listHoldsMatches: allMatches.length > 0 });
+ // It is dropped when the refused match leaves `scheduled` or a list that holds
+ // matches no longer holds it (start_match.jsx startRefusalStands, the rule the
+ // court console asks too), or when the operator opens a DIFFERENT match, which
+ // only this page does. Closing the editor is neither: the row behind it is
+ // where the notice stays visible. A refusal is about one match at one moment,
+ // so a stale one never revives when that match is later sent back to the queue.
+ useEffectA(() => {
+ if (!startRefusalKey) return;
+ const openedAnother = openKey != null && openKey !== startRefusalKey;
+ if (openedAnother || !startRefusalHolds) setStartRefusal(null);
+ }, [openKey, startRefusalKey, startRefusalHolds]);
+ const startNoticeFor = (m) => (startRefusal && startRefusal.key === scoreKeyOf(m) && m.status === "scheduled" ? startRefusal.msg : null);
+
+ // A refused start: land the operator on the refused match in pre-match (the
+ // court console does, bc-aadv: they see WHICH match and why), keep the notice
+ // on its row and toast it. openKey is set first so the effect above, which
+ // drops a refusal when ANOTHER match opens, never sees the new refusal against
+ // the old openKey.
+ //
+ // `toasted` is true for a thrown refusal editMatchScore (admin.jsx) already
+ // toasted: the toast is a single slot (app.jsx), so saying it again would only
+ // replace it and restart its timer. The row notice is set either way. A
+ // refusal only this page sees (a clock_skew or held-group superseded answer, "still being started")
+ // carries no mark and is toasted here, once.
+ const refuseStart = (next, msg, waitingOn = null, toasted = false) => {
+ if (mountedRef.current) {
+ setOpenKey(scoreKeyOf(next));
+ setStartRefusal({ key: scoreKeyOf(next), msg, waitingOn });
+ }
+ if (showToast && !toasted) showToast(msg, "error");
+ };
+
+ // The ONE door both automatic starts go through: (a) refuses while another
+ // start is out, (b) reads the answer, (c) reports a refusal (refuseStart).
+ // Resolves true when the start went ahead (landed or queued), false otherwise;
+ // it never throws. A SUPERSEDED start that held its result group
+ // (classifyStartOutcome: nothing written, a different result is stored) is a
+ // REFUSED start like a clock_skew one: refuseStart stores its sentence on the refused match's row,
+ // toasts it once and opens that match, and the answer is false, so a caller
+ // does not move on as if the match had started. That replaces the refusal that
+ // waited on this start (`startRefusal` is a single slot and refuseStart clears
+ // `waitingOn`), which is why the finally below has nothing left to rewrite into
+ // a past-tense "was being started" for it: the same mechanism a thrown refusal
+ // relies on. `waitingOn` is this page's record of "a refusal is waiting on that
+ // start"; a start that wrote nothing replaces the refusal that carried it. A
+ // superseded start that held NOTHING is an echo of a start that landed (the match
+ // IS running): it answers ok, so it takes the landed path, the waiting refusal
+ // is rewritten in the past tense as for any start that went out, and the caller
+ // moves on.
+ // Every start-gating rule is the server's (StartMatchTx), so a thrown 409 only
+ // needs reporting.
+ const startNext = async (next) => {
+ const key = scoreKeyOf(next);
+ const guard = startGuardRef.current;
+ const began = guard.begin(key, next);
+ if (!began.started) {
+ // A repeat for the match already being started is silent: it is on its way.
+ if (!began.repeat) {
+ refuseStart(next, startWhileStartingMessage({ label: scoreRowMatchName(began.blocker.match) }), began.blocker.key);
+ }
+ return false;
+ }
+ if (mountedRef.current) setStartRefusal(null);
+ try {
+ const outcome = classifyStartOutcome(await onEditScore(next.compId, next.id, startPatch(), next));
+ if (outcome.ok) return true;
+ refuseStart(next, outcome.msg);
+ return false;
+ } catch (e) {
+ refuseStart(next, startFailureMessage(e), null, wasToasted(e));
+ return false;
+ } finally {
+ guard.end(key);
+ // A "still being started" notice names THIS start. When the start ends, the
+ // present-tense sentence stops being true, but the match the operator asked
+ // for was never started and nothing else says so, so the refusal is
+ // REWRITTEN in the past tense and kept on the refused match (bound to it like
+ // any stored refusal here: it goes when that match leaves scheduled or the
+ // operator opens another). The refused match is not started on its own: the
+ // court now has a running bout, and one start at a time is the rule.
+ // That holds only for a start that WENT OUT (landed or queued). One that
+ // wrote nothing (a thrown refusal, a clock_skew or a held-group superseded answer) has
+ // stored its own refusal through refuseStart, which replaced the waiting one
+ // and cleared `waitingOn`, so this finds nothing to rewrite: the court is
+ // free and "was being started" would be false.
+ if (mountedRef.current) {
+ setStartRefusal((prev) => (prev && prev.waitingOn === key
+ ? { key: prev.key, msg: startWasBlockedByStartMessage({ label: scoreRowMatchName(next) }), waitingOn: null }
+ : prev));
+ }
+ }
+ };
+
const f = filter.trim().toLowerCase();
const filtered = allMatches.filter((m) => {
if (restrictToCompId && m.compId !== restrictToCompId) return false;
@@ -231,11 +334,11 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom
// 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
+ // (teamMatchMarks, side_marks.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: "" };
+ const { shiro: teamShiroMark, aka: teamAkaMark } = teamMatchMarks(m);
// Show the live ippon score for a running bout too (not just completed)
// so the list reflects scoring in progress; "vs" only before it starts.
// matchShowsScore is the one gate: a match sent back to the queue
@@ -252,6 +355,8 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom
// boutMiddle placeholder (normally "vs"). Live techniques show once present.
const seScore = showScore ? window.matchScoreStr(m) : "";
const matchNo = scoreRowMatchLabel(m);
+ // Why the automatic start of THIS match was refused (startNext), live.
+ const startNotice = startNoticeFor(m);
return (
)}
+ {/* PR #463 round 13: a Start the server refused for this match
+ (Finish + Start Next, or the start after a decision), in the
+ same full-width slot. role="status", not "alert": the toast is
+ the assertive signal at the moment it happens; this stays
+ where the operator can read it again. */}
+ {startNotice && (
+
+
{startNotice}
+
+ )}
);
})}
@@ -393,13 +508,10 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom
? sameCourt.slice(openIdx + 1).find(m => m.compId === openMatch.compId && m.status !== 'completed' && !isBarredMatch(m) && !involvesCompetitor(m, withdrawn)) || null
: null;
const nextActiveMatch = nextActiveFrom();
- // Minimal "start" patch (status → running, empty score). Mirrors the
- // modal's own buildPatch("running") for an unscored match and works for
- // both individual and team matches (subResults is omitted, which the
- // serializer treats as "no bouts scored yet"). The server routes this
- // through eng.StartMatchTx, so all start-gating (eligibility 409,
- // ≥players checks) still runs: a 409 throws and is caught below.
- // Defined at module level as window.startPatch for reuse across admin_*.jsx.
+ // The automatic start (startNext, above) writes start_match.jsx's
+ // startPatch: the server routes it through eng.StartMatchTx, so all
+ // start-gating (eligibility 409, ≥players checks) still runs, and a
+ // refusal is reported by startNext.
return (
{
@@ -496,11 +606,12 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom
// no next match does.
const next = nextActiveFrom(sideBarredByDecision(result, openMatch));
if (!next) { if (mountedRef.current) setOpenKey(null); return; }
+ // A refused start lands the operator on that match with the
+ // reason, as the court console does (startNext); a start that goes
+ // ahead opens it.
if (next.status === "scheduled") {
- try {
- await onEditScore(next.compId, next.id, startPatch(), next);
- if (mountedRef.current) setOpenKey(scoreKeyOf(next));
- } catch (_startErr) { /* gate rejected the start; leave the operator where they are */ }
+ const started = await startNext(next);
+ if (started && mountedRef.current) setOpenKey(scoreKeyOf(next));
}
} : null}
password={password}
diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx
index 9a497be10..5390386fa 100644
--- a/web-mobile/js/admin_scoring_autosave.jsx
+++ b/web-mobile/js/admin_scoring_autosave.jsx
@@ -43,7 +43,7 @@ export function useChangedGroups(match) {
const finished = !!(match && match.status === "completed");
// The editor moving to another match starts over from that match.
if (!stateRef.current || stateRef.current.key !== keyOf(match)) {
- stateRef.current = { key: keyOf(match), seed: matchWire(match), agreed: {}, last: null, finished };
+ stateRef.current = { key: keyOf(match), seed: matchWire(match), agreed: {}, last: {}, finished };
}
// A reopen is a write the server makes, not one the editor builds, so no build
// moves the result the editor agrees with off the finished one. Once the server
@@ -56,19 +56,44 @@ export function useChangedGroups(match) {
// named no result for it: the server kept the match as it was, with no error.
if (!finished && stateRef.current.finished) stateRef.current.agreed[GROUP_RESULT] = matchWire(match);
stateRef.current.finished = finished;
- return (patch) => {
+ // `agreed[g]` is the server value the editor last AGREED with for g; `last[g]` is
+ // the value it last built or took for g. Both are kept per group: a group a build
+ // did not state says nothing about it, and a group the editor took from the server
+ // is, from then on, the value its next edit is judged against.
+ const claim = (patch) => {
if (!patch) return patch;
const m = matchRef.current;
const st = stateRef.current;
const next = toBackendMatchResult(patch, m);
const current = matchWire(m);
- for (const g of statedGroupsOf(next)) {
+ const stated = statedGroupsOf(next);
+ for (const g of stated) {
if (groupMatches(g, next, current, next)) st.agreed[g] = current;
}
- const changed = changedGroups(next, (g) => st.agreed[g] || st.seed, st.last);
- st.last = next;
+ const changed = changedGroups(next, (g) => st.agreed[g] || st.seed, (g) => st.last[g]);
+ for (const g of stated) st.last[g] = next;
return { ...patch, changed };
};
+ // claim.agree(group): the editor ADOPTED the server's value for `group`, so it
+ // agrees with the match as it now stands and that is the value it last had for the
+ // group. The hook cannot see an adopt, and the baseline moves only at a build where
+ // the editor's own state equals the prop (above), so without this an edit that puts
+ // the group back to the value the editor mounted with was compared against that
+ // mount-time seed and not named (the server kept the other device's value), and an
+ // edit of something else was compared against the value of the group the editor last
+ // BUILT, found it different from the adopted one, and re-sent the adopted value under
+ // a newer stamp. The caller states which group it took, from inside the adopt's
+ // `apply` (a skipped adopt, an editor holding an edit over a newer value, agrees
+ // nothing), and only for the part it took, never from the latest prop alone (an edit
+ // the editor holds over a newer prop must stay a change). The property rides on the
+ // function so the editors that only claim are unchanged.
+ claim.agree = (group) => {
+ const st = stateRef.current;
+ const adopted = matchWire(matchRef.current);
+ st.agreed[group] = adopted;
+ st.last[group] = adopted;
+ };
+ return claim;
}
// ---------------------------------------------------------------------------
diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx
index ac110dbb1..b6c6d87e1 100644
--- a/web-mobile/js/admin_scoring_engi.jsx
+++ b/web-mobile/js/admin_scoring_engi.jsx
@@ -24,6 +24,7 @@ const { useState: useStateE, useEffect: useEffectE, useRef: useRefE } = React;
import { ReasonPrompt, CORRECTION_PRESETS, useAdoptFromServer, HeldWriteDiscard, HeldWriteNotice, useClearPendingWhenNothingHeld } from './admin_scoring_shared.jsx';
import { SyncStatusPill, useDebouncedRunningWrite, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx';
import { MatchHistoryDisclosure } from './match_history_view.jsx';
+import { GROUP_FLAGS } from './match_groups.jsx';
import { useEscapeToClose, confirmDialog } from './ui.jsx';
// NumberedName: single owner of the number-chip-on-the-outer-side rule.
import { NumberedName } from './numbered_name.jsx';
@@ -179,7 +180,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext
// for as long as the match is open.
useAdoptFromServer({
signature: `${initialFlagsA}:${initialFlagsB}`,
- apply: () => { setFlagsA(initialFlagsA); setFlagsB(initialFlagsB); },
+ apply: () => { setFlagsA(initialFlagsA); setFlagsB(initialFlagsB); claimChanged.agree(GROUP_FLAGS); },
keepLocalEdits: true,
isDirty,
});
diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx
index 6cead828e..ac4d7acc0 100644
--- a/web-mobile/js/admin_scoring_individual.jsx
+++ b/web-mobile/js/admin_scoring_individual.jsx
@@ -58,6 +58,7 @@ import { isBarredMatch } from './ineligible_match.jsx';
import { SyncStatusPill, useDebouncedRunningWrite, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx';
import { MatchHistoryDisclosure } from './match_history_view.jsx';
+import { GROUP_POINTS, GROUP_RESULT, GROUP_ENCHO, GROUP_REP } from './match_groups.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
@@ -65,6 +66,11 @@ import { MatchHistoryDisclosure } from './match_history_view.jsx';
import { TeamScoreEditorModal, isKoTieBlocked } from './admin_scoring_team.jsx';
import { EngiScoreEditorModal } from './admin_scoring_engi.jsx';
+// A representative pick the operator made that the server does not hold (yet). An empty
+// pick never counts: the server keeps a stored name over "", so it would read unsaved
+// forever. The pick adopts below and the claimed change group both ask this.
+const repPickHeld = (pick, served) => pick !== "" && pick !== (served || "");
+
export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, onAfterDecision, started = false, prevMatch, nextMatch, onPrev, onNext, password, selfReport, teamMembers, variant = "modal", canClose = true }) {
// bc-strt: a match whose start has landed is RUNNING, even while the host's
// list still says scheduled (it refetches a moment after each save). The
@@ -145,6 +151,13 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on
const [aFouls, setAFouls] = useStateA(initialAFouls);
const [bFouls, setBFouls] = useStateA(initialBFouls);
const [enchoPeriodCount, setEnchoPeriodCount] = useStateA(initialEnchoPeriods);
+ // The points group (the ippons and the outstanding fouls) as this editor holds it
+ // against as the server does. An adopt that does not take the points agrees them
+ // only while this is false (see the hantei adopt below).
+ const pointsEdited = !window.arraysEqual(aPts, initialAPts) ||
+ !window.arraysEqual(bPts, initialBPts) ||
+ aFouls !== initialAFouls ||
+ bFouls !== initialBFouls;
// The verdict the SERVER holds right now. A match has ONE result and every
// surface asking for it must show the same one, and this editor is such a
// surface: while it is open, the viewer card, the bracket, the TV board and
@@ -172,7 +185,19 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on
const [hanteiPick, setHanteiPick] = useStateA(recordedHtKey);
useAdoptFromServer({
signature: JSON.stringify([hanteiRecorded, recordedHtKey]),
- apply: () => { setDecidedByHantei(hanteiRecorded); setHanteiPick(recordedHtKey); },
+ apply: () => {
+ setDecidedByHantei(hanteiRecorded);
+ setHanteiPick(recordedHtKey);
+ // The verdict rides in the ippons (the Ht mark on the winner's side) and in the
+ // result (the winner), so adopting it is agreeing with both groups: cancelling
+ // it here must then be named, and the points are not re-sent as they stood. The
+ // adopt takes the verdict and not the points, so it agrees the points only while
+ // the editor holds the server's: with a point of ours still being saved, agreeing
+ // would put both baselines on a scoreline without it, and taking it back after
+ // its save landed would read as unchanged.
+ if (!pointsEdited) claimChanged.agree(GROUP_POINTS);
+ claimChanged.agree(GROUP_RESULT);
+ },
});
// Which side ("a"/"b"/"") holds a RECORDED hantei verdict, for the display
// chip in the slot grid. Gated on the SERVER's verdict — not the local armed
@@ -222,6 +247,20 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on
// = Aka (sideA), repPlayerB = Shiro (sideB). Only rendered when m.repIsTeam.
const [repPlayerA, setRepPlayerA] = useStateA(m.repPlayerA || "");
const [repPlayerB, setRepPlayerB] = useStateA(m.repPlayerB || "");
+ // The two picks are ONE change group (GROUP_REP), and claim.agree moves both
+ // baselines to the server's value, so a side's adopt agrees the group only when BOTH
+ // sides then hold what the server holds: a pick of ours still being saved on the
+ // other side must stay a change, or its save landing would make putting it back look
+ // unchanged and the server would keep the other value. `repAfter` is each side's
+ // pick as this commit's adopts leave it: the render's own picks (refreshed by the
+ // effect below, which runs before the adopts), then the served pick of each side
+ // whose adopt has run, so the second adopt of one update sees the first.
+ const repAfter = useRefA({ a: "", b: "" });
+ useEffectA(() => { repAfter.current = { a: repPlayerA, b: repPlayerB }; });
+ const adoptRepPick = (side, served) => {
+ repAfter.current[side] = served;
+ if (!repPickHeld(repAfter.current.a, m.repPlayerA) && !repPickHeld(repAfter.current.b, m.repPlayerB)) claimChanged.agree(GROUP_REP);
+ };
// Follow a pick made on another device, same rule as every other channel.
//
// The narrow reading first, because it bounds what this is worth: an UNSET
@@ -240,15 +279,15 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on
// the server keeps a name over "", so it would read unsaved forever.
useAdoptFromServer({
signature: m.repPlayerA || "",
- apply: () => setRepPlayerA(m.repPlayerA || ""),
+ apply: () => { setRepPlayerA(m.repPlayerA || ""); adoptRepPick("a", m.repPlayerA || ""); },
keepLocalEdits: true,
- isDirty: repPlayerA !== "" && repPlayerA !== (m.repPlayerA || ""),
+ isDirty: repPickHeld(repPlayerA, m.repPlayerA),
});
useAdoptFromServer({
signature: m.repPlayerB || "",
- apply: () => setRepPlayerB(m.repPlayerB || ""),
+ apply: () => { setRepPlayerB(m.repPlayerB || ""); adoptRepPick("b", m.repPlayerB || ""); },
keepLocalEdits: true,
- isDirty: repPlayerB !== "" && repPlayerB !== (m.repPlayerB || ""),
+ isDirty: repPickHeld(repPlayerB, m.repPlayerB),
});
// doSubmit's setSubmitting(false) in finally fires post-await; if the
// parent unmounts the modal during the in-flight save (e.g.
@@ -630,6 +669,11 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on
setBFouls(initialBFouls);
setEnchoPeriodCount(initialEnchoPeriods);
setIsDrawToggled(initialIsDrawToggled);
+ // The scoreline, the overtime count and the draw (the result's hikiwake) are taken
+ // from the server together, so the editor agrees with all three groups.
+ claimChanged.agree(GROUP_POINTS);
+ claimChanged.agree(GROUP_ENCHO);
+ claimChanged.agree(GROUP_RESULT);
};
// Arranged as [left, right]: left is always SHIRO (White), right is always AKA (Red).
@@ -793,10 +837,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on
// changes?" on an editor nobody touched trains operators to dismiss the one
// prompt that protects real work.
const isDirty =
- !window.arraysEqual(aPts, initialAPts) ||
- !window.arraysEqual(bPts, initialBPts) ||
- aFouls !== initialAFouls ||
- bFouls !== initialBFouls ||
+ pointsEdited ||
isDrawToggled !== initialIsDrawToggled ||
enchoPeriodCount !== initialEnchoPeriods ||
decidedByHantei !== hanteiRecorded ||
diff --git a/web-mobile/js/admin_scoring_shared.jsx b/web-mobile/js/admin_scoring_shared.jsx
index a5902d149..e32d2a46f 100644
--- a/web-mobile/js/admin_scoring_shared.jsx
+++ b/web-mobile/js/admin_scoring_shared.jsx
@@ -1024,7 +1024,7 @@ function lineupListPlacement({ pin, foot, clip }, bar) {
return { up, maxHeight };
}
-function LineupNameInput({ value, roster, onSelect, onListPick, disabled, ariaLabel, color, clearable, inputId }) {
+function LineupNameInput({ value, roster, onSelect, onListPick, disabled, ariaLabel, color, clearable, inputId, placeholder = "Add player…" }) {
const [query, setQuery] = useStateA("");
const [open, setOpen] = useStateA(false);
const [active, setActive] = useStateA(-1); // -1 = no explicit selection yet
@@ -1184,7 +1184,7 @@ function LineupNameInput({ value, roster, onSelect, onListPick, disabled, ariaLa
({ ...prev, [memberIdKey]: (member && member.id) || "" }), { repPickSide: side });
+}
+
+// The representative row (position -1) follows the server per PART, not per row
+// (bc-mrgc: the server orders a representative's picks apart from the bout row).
+// A part is the score (everything on the row but the picks) or one side's pick
+// (`aMemberIdOverride` / `bMemberIdOverride`). A pick the operator makes here is an
+// edit of that side's pick alone, so another device's point on the bout is still
+// shown, and a point struck here does not hold another device's pick.
+const REP_PICK_KEYS = ["aMemberIdOverride", "bMemberIdOverride"];
+
+// withoutRepPicks: the row's score part, as a string to compare.
+function withoutRepPicks(row) {
+ const rest = { ...row };
+ for (const key of REP_PICK_KEYS) delete rest[key];
+ return JSON.stringify(rest);
+}
+
+// rowTakenFromServer: does the adopt take the server's copy of a bout row (true), or keep
+// the operator's (false)? The ONE rule, asked both by the updater that rebuilds the board
+// and by the pass that tells useChangedGroups which rows were taken (a row taken is a row
+// the editor then agrees with; a row kept stays the operator's edit), so the two cannot
+// drift. `editedAt` is when the operator last edited the row here and `modifiedAt` the
+// match's stamp, both in the server's clock frame. With no local row there is nothing of
+// the operator's to keep. A snapshot written BEFORE the operator's last edit to the row
+// cannot know that edit, so the row stays theirs whatever it now equals (bc-kclr: striking
+// a point and taking it back returns the row to the value the server showed before the
+// strike, which would otherwise read as untouched). Otherwise a row that still equals what
+// the server last said (`prior`) has nothing of theirs in it.
+function rowTakenFromServer(local, prior, editedAt, modifiedAt) {
+ if (!local) return true;
+ if (editedAt !== undefined && (modifiedAt || 0) < editedAt) return false;
+ return !!prior && JSON.stringify(local) === JSON.stringify(prior);
+}
+
+// repRowTakes: which parts of the representative row the adopt takes from the server
+// (true) and which stay the operator's (false), each by the rule a whole row follows:
+// a snapshot written BEFORE the part's last edit here cannot know that edit (`stamps`,
+// in the server's clock frame, against the match's `modifiedAt`), and a part that
+// still equals what the server last said (`prior`) has nothing of the operator's in
+// it. `stamps` is { score, a, b }; a part never edited here has none. With no prior
+// row nothing is known to be untouched, so every part stays.
+function repRowTakes(local, prior, stamps, modifiedAt) {
+ const held = (stamp) => stamp !== undefined && (modifiedAt || 0) < stamp;
+ const pick = (side, key) => !held(stamps[side]) && !!prior && (local[key] || "") === (prior[key] || "");
+ return {
+ score: !held(stamps.score) && !!prior && withoutRepPicks(local) === withoutRepPicks(prior),
+ a: pick("a", REP_PICK_KEYS[0]),
+ b: pick("b", REP_PICK_KEYS[1]),
+ };
+}
+
+// composeRepRow: the representative row after the adopt, each part from the server's
+// row where `takes` says so and from the operator's otherwise. Either row keeps its
+// identity when every part comes from it, so an untouched board is not rebuilt.
+function composeRepRow(local, served, takes) {
+ if (takes.score && takes.a && takes.b) return served;
+ if (!takes.score && !takes.a && !takes.b) return local;
+ return {
+ ...(takes.score ? served : local),
+ [REP_PICK_KEYS[0]]: (takes.a ? served : local)[REP_PICK_KEYS[0]],
+ [REP_PICK_KEYS[1]]: (takes.b ? served : local)[REP_PICK_KEYS[1]],
+ };
+}
+
// fusenshoSideFromSub: which side ("a" / "b" / "") a persisted fusensho sub-bout
// was awarded to, for re-seeding the local editor state on a reopen or remount.
// The winner is stored as the bout competitor's OWN name — for a KACHINUKI bout
@@ -961,7 +1042,6 @@ function useMatchLineups(compId, matchId) {
sync.writing[side] += 1;
const epoch = sync.epoch[side];
return {
- sideChanged: sync.watchSide(side),
isRead: () => isRead(side),
read: (teamId) => read(side, teamId),
held: () => sync.held[side],
@@ -1133,12 +1213,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// sub-match" (handlers_daihyosen.go) but is NOT an individual victory:
// it breaks an IV/PW tie. Render it as a trailing scoreable row,
// exclude it from the IV/PW tally, and let its winner decide the
- // encounter. The "daihyosen" slot sentinel maps to DAIHYOSEN_POSITION in
+ // encounter. The REPRESENTATIVE_KEY slot maps to DAIHYOSEN_POSITION in
// buildPatch. It is the ONLY team sub-bout that may carry encho/hantei
// (validation.go validateSubBout).
const existingDaihyosen = (m.subResults || []).find(s => s.position === DAIHYOSEN_POSITION);
const hasDaihyosen = !!existingDaihyosen;
- const positions = hasDaihyosen ? [...numberedPositions, "daihyosen"] : numberedPositions;
+ const positions = hasDaihyosen ? [...numberedPositions, REPRESENTATIVE_KEY] : numberedPositions;
const daihyosenIdx = hasDaihyosen ? numberedPositions.length : -1;
// FR-033: encho counter for team matches (overtime period count rides
// alongside the score on the wire: same shape as ScoreEditorModal).
@@ -1163,7 +1243,13 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// does (its adopt below).
useAdoptFromServer({
signature: initialEnchoPeriods,
- apply: () => setEnchoPeriodCount(initialEnchoPeriods),
+ apply: () => {
+ setEnchoPeriodCount(initialEnchoPeriods);
+ claimChanged.agree(GROUP_ENCHO);
+ // With a representative bout the count is read off its row and written on it, so
+ // taking the count is agreeing with that row too.
+ agreeRepBoutIfNotHeld();
+ },
keepLocalEdits: true,
isDirty: enchoPeriodCount !== initialEnchoPeriods,
});
@@ -1264,6 +1350,29 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// given another team). A pick writes for the team the side has when it goes on, and
// stops when the side was given another team since it began (watchLineupSide).
const sideSeen = useRefA(null);
+ // The number of each side's latest representative pick (a list pick, a clear, or a
+ // typed name). A typed name still resolving when a newer pick is made on its side lands
+ // nothing (submitRepresentative), whichever answers first: the newer pick wins.
+ const repPickSeq = useRefA({ a: 0, b: 0 });
+ const newRepPick = (side) => ++repPickSeq.current[side];
+ // The representative bout leaving the sheet supersedes every pick typed on it, whichever
+ // device removed it (this one's own remove bumps both sides too: runRepBoutChange). A name
+ // still being resolved then lands nothing: no pick, no dirty mark, no autosave, and no row
+ // rebuilt by the updateSub of the render the name was typed in, which a bout added again
+ // would adopt over the server's fresh row. A layout effect, so the bump is in place at the
+ // commit that drops the row, before any answer resumed after it. Only the true-to-false
+ // edge counts: an added bout, or none on mount, supersedes nothing.
+ const hadDaihyosen = useRefA(hasDaihyosen);
+ useLayoutEffectA(() => {
+ if (hadDaihyosen.current && !hasDaihyosen) { newRepPick("a"); newRepPick("b"); }
+ hadDaihyosen.current = hasDaihyosen;
+ }, [hasDaihyosen]);
+ // How many typed representatives are still being named (submitRepresentative). A typed
+ // name is resolved by a member POST that may already have created the member when it
+ // answers, and the pick lands only while the sheet is mounted, so Finish, Save and
+ // Close wait for it. A counter, because the two sides can overlap.
+ const [repResolving, setRepResolving] = useStateA(0);
+ const repTyping = repResolving > 0;
const showMembers = (side, list) => {
membersShown.current[side] = list;
(side === "a" ? setSquadA : setSquadB)(list);
@@ -1375,6 +1484,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
apply: () => {
setDaihyosenHanteiArmed(daihyosenHanteiRecorded);
setDaihyosenHantei(recordedDaihyosenSide);
+ // The verdict is the Ht mark on the representative bout's row and the winner the
+ // match gets from it, so adopting it is agreeing with both groups.
+ agreeRepBoutIfNotHeld();
+ claimChanged.agree(GROUP_RESULT);
},
keepLocalEdits: true,
isDirty: daihyosenHantei !== recordedDaihyosenSide,
@@ -1399,6 +1512,13 @@ 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.
+ //
+ // Only the VERDICT terms are shared. The two ROW halves differ on purpose:
+ // isDirty compares the whole board, so an unsaved representative pick is
+ // still unsaved work, while daihyosenTouched compares the row's score part
+ // through withoutRepPicks. A pick is its own change group (repPickA /
+ // repPickB, ordered by the server apart from the bout), so it must never
+ // un-silence the bout's scoreline.
const daihyosenVerdictDirty =
enchoPeriodCount !== initialEnchoPeriods ||
daihyosenHantei !== recordedDaihyosenSide ||
@@ -1582,6 +1702,22 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
const giveSideAnotherTeam = (side) => {
dropSideMembers(side);
dropLineup(side);
+ newRepPick(side);
+ // The representative the side picked is a member of the team it no longer has, so the
+ // pick is dropped and the sheet stops sending that member's id. Only a pick the row
+ // holds is dropped, and it is dropped the server-following way (setSubs, not
+ // updateSub): no operator-edit stamp, no dirty mark, no autosave, and an armed Finish
+ // stays armed, since the operator changed nothing. A pick the server holds arrives
+ // cleared by the push that seats the new team, in the same update, so the board
+ // already agrees. A pick made HERE and not yet written is one the server never
+ // received and cannot clear, so this is the only place it goes. Either way the side's
+ // pick-part edit stamp goes with it: whatever was edited was about the old team, and
+ // the adopt takes the server's value for the side again.
+ lastRepPickEditRef.current[side] = undefined;
+ if (daihyosenIdx >= 0 && subs[daihyosenIdx]?.[`${side}MemberIdOverride`]) {
+ const memberKey = `${side}MemberIdOverride`;
+ setSubs(prev => reconcileRowsToPositions(prev, serverSubs).map((s, i) => i === daihyosenIdx ? { ...s, [memberKey]: "" } : s));
+ }
};
useEffectA(() => {
const was = sideSeen.current;
@@ -1850,6 +1986,24 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
throw new Error(LINEUP_NOT_READ_NOTICE);
};
+ // Posts a lineup notice under one bout row's key (lineupNoticeKey), unless the sheet
+ // has closed. Every pick path that reports into a row goes through it.
+ const lineupNotifier = (noticeKey) => (tone, text) => {
+ if (mountedRef.current) setLineupNotice({ key: noticeKey, tone, text });
+ };
+ // Asked where a pick waits on the server, as it began: true, with SIDE_TEAM_CHANGED_NOTICE
+ // posted under the row, once the side has been given another team since (watchLineupSide).
+ // Nothing is written for a team the side no longer carries.
+ const sideChangedGuard = (side, noticeKey) => {
+ const sideChanged = watchLineupSide(side);
+ const notify = lineupNotifier(noticeKey);
+ return () => {
+ if (!sideChanged()) return false;
+ notify("error", SIDE_TEAM_CHANGED_NOTICE);
+ return true;
+ };
+ };
+
// Submit an inline position change: resolves/mints that position's member id
// (see buildInlineLineupWrite), checks the lineup held for the side is not given
// the member twice, then PUTs the one position it changes, named in `changed`
@@ -1860,10 +2014,11 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// undefined for a typed/"+ Add" name, and buildInlineLineupWrite writes
// by id rather than resolving by name when it is present.
const submitInlineLineup = async ({ side, noticeKey, posKey, value, member }) => {
- const notify = (tone, text) => { if (mountedRef.current) setLineupNotice({ key: noticeKey, tone, text }); };
+ const notify = lineupNotifier(noticeKey);
setLineupNotice(null);
// The name is typed for the team that plays this side as the pick begins.
const write = beginLineupWrite(side);
+ const sideChanged = sideChangedGuard(side, noticeKey);
setInlineLineupSaving(true);
try {
// A typed name is resolved against the side's members, so they must have been
@@ -1882,11 +2037,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// another team's; again once it has been, which can take as long as the wait; and
// again once the member writes are done. Those stay: they are what was typed for
// the old team, and its own members.
- const sideChanged = () => {
- if (!write.sideChanged()) return false;
- notify("error", SIDE_TEAM_CHANGED_NOTICE);
- return true;
- };
if (sideChanged()) return;
// The team as the sheet knows it now, not as it was when the pick began: a side
// that carried only a name then can have been named by the roster meanwhile (the
@@ -1944,6 +2094,58 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
}
};
+ // The representative typed on a daihyosen row (bc-dhrp). The name goes through the
+ // SAME resolver a numbered row's typed name goes through (resolveTypedMemberName,
+ // lineup_resolver.jsx: a name the team already has, else the blank member the pick
+ // holds, else a new member), under the guards submitInlineLineup keeps: the members
+ // are read first (membersWait), a side given another team is not written for, the
+ // members the resolver answers are shown, and a members list that could not be read
+ // is warned about in the row. No lineup is written: the REPRESENTATIVE_KEY slot is no
+ // lineup position. Only the resulting member id is stored on the row.
+ const resolveRepresentative = async ({ side, noticeKey, idx, value, priorId, seq }) => {
+ const notify = lineupNotifier(noticeKey);
+ const sideChanged = sideChangedGuard(side, noticeKey);
+ // A newer pick on this side (a list pick, a clear or another typed name) makes this one
+ // stale: it applies nothing and says nothing (the newer pick wins). The members the
+ // resolver wrote on the server are still merged, since they exist whatever pick follows.
+ const superseded = () => repPickSeq.current[side] !== seq;
+ let membersUnavailable = squadUnavailable;
+ const waiting = membersWait.current[side].pending();
+ if (waiting && !(await waiting)) membersUnavailable = true;
+ // An editor closed while the members were read (the court console moving on re-keys it)
+ // names no one: the resolver would POST a member nobody picked, and the pick lands nowhere.
+ if (!mountedRef.current || sideChanged() || superseded()) return;
+ const teamId = sideSeen.current[side].team;
+ const typed = await resolveTypedMemberName(m.compId, teamId, REPRESENTATIVE_KEY, value, membersShown.current[side], password, { [REPRESENTATIVE_KEY]: priorId });
+ if (!mountedRef.current || sideChanged()) return;
+ if (typed.resolverMissing) {
+ if (!superseded()) notify("error", `"${value}" could not be named: the team members are not loaded. Try again in a moment.`);
+ return;
+ }
+ wroteSideMembers(side, typed.squad);
+ if (superseded()) return;
+ if (!typed.memberId) {
+ const failure = typed.failures[0];
+ notify("error", `"${value}" was not picked as the representative. ${memberRefusalNote({ code: failure?.code, reason: failure?.reason }, "Pick one from the list instead.")}`);
+ return;
+ }
+ const member = typed.squad.find(mem => mem && mem.id === typed.memberId) || { id: typed.memberId };
+ pickDaihyosenRepresentative({ idx, side, updateSub }, member);
+ // No lineup is saved here, so the lineup's own warning ("Lineup saved, but...")
+ // would be false: say what an unread member list can actually have done.
+ if (membersUnavailable) notify("warn", `"${value}" was picked, but the team member list could not be read first, so they may have been added as a new team member. ${selfReport ? "Ask the tournament organizer to check the team." : "Check the team on the Lineups page."}`);
+ };
+ // A typed representative is named by resolveRepresentative, counted in repResolving for as
+ // long as it is out (Finish, Save and Close wait on it: see repResolving above).
+ const submitRepresentative = async (pick) => {
+ setRepResolving(n => n + 1);
+ try {
+ await resolveRepresentative(pick);
+ } finally {
+ if (mountedRef.current) setRepResolving(n => n - 1);
+ }
+ };
+
// Shared factory (admin_scoring_shared.jsx): same handler as ScoreEditorModal.
// Item 7: fusenpai routes through onAfterDecision (host-supplied) to advance
// the court, same as ScoreEditorModal. Kiken follows the same rule now too
@@ -2013,6 +2215,13 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// persisted sub so re-opens keep the overtime marker. The daihyosen
// row's encho is handled separately (daihyosenEnchoFields).
encho: idx === daihyosenIdx ? 0 : (existing?.encho?.periodCount || 0),
+ // bc-dhrp: the representative bout's picks start from the stored row, so a
+ // saved pick is the editor's own value and round-trips. Numbered rows carry
+ // no such keys.
+ ...(idx === daihyosenIdx ? {
+ aMemberIdOverride: existing?.sideAMemberId || "",
+ bMemberIdOverride: existing?.sideBMemberId || "",
+ } : {}),
};
};
// The LIVE baseline: what the SERVER currently holds for every visible
@@ -2032,6 +2241,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// RENDER-SYNCHRONOUS because this very render already indexes subs for the
// new positions — an effect would run after the crash.
const subs = reconcileRowsToPositions(subsRaw, serverSubs);
+ // The representative bout's row is one group (bout:-1) for the score, the overtime
+ // count and the Ht verdict. Taking the count or the verdict from the server agrees with
+ // that row, unless this board holds an unsaved edit of the row's score: the row follows
+ // the server per part (repRowTakes), and those adopts take no score. The per-row adopt
+ // below agrees with the row when it takes the score itself. Called from the adopts
+ // declared above, at effect time, when this render's boards are all declared.
+ const agreeRepBoutIfNotHeld = () => {
+ if (daihyosenIdx < 0) return;
+ const shown = subs[daihyosenIdx];
+ const served = serverSubs[daihyosenIdx];
+ if (shown && served && withoutRepPicks(shown) === withoutRepPicks(served)) claimChanged.agree(boutGroup(DAIHYOSEN_POSITION));
+ };
// Two kinds of write reach `subs`, and one arming rule has to tell them
// apart: the OPERATOR editing (which disarms Finish/End and closes an open
// reason prompt, because what they were about to confirm has changed under
@@ -2047,6 +2268,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// the server's clock frame, server_clock.jsx), read by the per-row adopt
// below.
const lastRowEditRef = useRefA(new Map());
+ // The same, per SIDE, for the representative row's picks: a pick is an edit of that
+ // side's pick alone, so it stamps here and not on the row (updateSub's repPickSide).
+ // The row's own entry in lastRowEditRef is then the score part's.
+ const lastRepPickEditRef = useRefA({});
// C1: updateSub is the single choke-point for all sub-bout state
// mutations. Calling markScoringDirty() here captures every edit
// (pts add/remove, fouls, fusensho, draw) without repetition.
@@ -2060,8 +2285,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// shows from the refused tap until the operator next edits any bout row, so
// re-entering the points that caused the refusal does not bring it back
// untapped. The server re-seed never comes through here (it uses setSubs).
- const updateSub = (idx, fn) => {
- lastRowEditRef.current.set(subs[idx]._pos, serverNowMs());
+ // `opts.repPickSide` ("a" / "b") says the edit is that side's representative pick on the
+ // representative row: it stamps the pick part, and every other edit of the row the score
+ // part, because the adopt follows the server for each part on its own.
+ const updateSub = (idx, fn, opts) => {
+ if (idx === daihyosenIdx && opts?.repPickSide) lastRepPickEditRef.current[opts.repPickSide] = serverNowMs();
+ else lastRowEditRef.current.set(subs[idx]._pos, serverNowMs());
setSubsByOperator(prev => {
const rows = reconcileRowsToPositions(prev, serverSubs);
return rows.map((s, i) => i === idx ? fn(s) : s);
@@ -2219,7 +2448,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
match: m,
enabled: !isKachinuki,
held: pendingWrite,
- onUndo: () => { setSubs(serverSubs); setFinishRefused(false); },
+ onUndo: () => {
+ setSubs(serverSubs);
+ setFinishRefused(false);
+ // The whole board is the server's again, so the editor agrees with every row
+ // and with both representative picks, which ride the board as their own
+ // groups: left unagreed, a pick built into an earlier write that has not
+ // echoed back would still be what the next build is compared against, and
+ // that build would name the group again under a newer stamp.
+ for (const ss of serverSubs) claimChanged.agree(boutGroup(ss._pos));
+ claimChanged.agree(GROUP_REP_PICK_A);
+ claimChanged.agree(GROUP_REP_PICK_B);
+ },
onReset: () => setFinishRefused(false),
});
const withdrawalWinner = rulingShown ? ({ a: "b", b: "a" }[withdrawnKeyOf(m)] || null) : null;
@@ -2346,7 +2586,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// kachinukiCurrentBoutPlayed and kachinukiCurBoutPos below are pure
// functions of it.
const kachinukiCurBoutIdx = kachinukiBoutMode
- ? (() => { const cur = visiblePositions.find(p => p !== "daihyosen"); return cur != null ? positions.indexOf(cur) : -1; })()
+ ? (() => { const cur = visiblePositions.find(p => p !== REPRESENTATIVE_KEY); return cur != null ? positions.indexOf(cur) : -1; })()
: -1;
// UX guard: Record bout with nothing entered for the current bout would
// submit a silent no-op (known quirk Q4: the glossary term inside the
@@ -2683,6 +2923,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
const sideIdentity = (key) => {
const name = override[`${key}Name`] || "";
const pickedId = override[`${key}MemberIdOverride`] || "";
+ // bc-dhrp: the representative bout's identity is the operator's pick alone.
+ // Falling through would hand back the STORED id, or the team name, once the
+ // pick was cleared, and the row would show or write it again.
+ if (isDaihyoRow) return { name: "", memberId: pickedId };
if (pickedId) return { name, memberId: pickedId };
const lineup = key === "a" ? lineupA : lineupB;
const existingName = key === "a" ? existing?.sideA : existing?.sideB;
@@ -2716,6 +2960,21 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
return member ? squadMemberLabel(teamNumber, member.index) : "";
};
+ // The History disclosure's member resolver (match_history_view.jsx pickText): a
+ // representative pick is stored as a member id, and this sheet holds both teams'
+ // members, so the history can name the representative. The member's name, else its
+ // label (squadMemberLabel, as the rows show); null for an id the side's team does not
+ // hold now (a correction seated another team); undefined while the team's members have
+ // not been read, when the history says only that the side was picked. "a" is AKA,
+ // squadA, m.sideA.
+ const historyMemberLabel = (side, memberId) => {
+ const squad = side === "a" ? squadA : squadB;
+ if (!Array.isArray(squad) || squad.length === 0) return undefined;
+ const member = squad.find(mem => mem && mem.id === memberId);
+ if (!member) return null;
+ return member.name || squadLabelFor(side, memberId);
+ };
+
// slotLabelFor: the fallback label for a NUMBERED fixed-order position
// whose fighter has not resolved to a squad member yet (squadLabelFor
// above returns "" in that case). bc-dnst (operator ruling 2026-09-15):
@@ -2942,11 +3201,17 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// replay saving an unrelated bout) erased that verdict on its next save,
// indistinguishable on the wire from an operator actually cancelling it.
// A row is genuinely silent only when BOTH halves hold: the operator has
- // not moved it away from what the SERVER holds (including the hantei
+ // not moved its SCORE part away from what the SERVER holds (the row
+ // without the two representative picks, `withoutRepPicks`, plus the hantei
// arm/pick and the daihyosen encho counter) AND nothing about it is known
// locally either (no recorded verdict, no scored points/fouls/draw, no
- // overtime). An editor that mounted AFTER a verdict/score existed is not
- // silent even if untouched - it is re-stating what it was shown, which is
+ // overtime). A representative pick is not part of that score part: it is
+ // its own change group (repPickA / repPickB, ordered by the server apart
+ // from the bout), so a pick on a bout nobody has scored must leave the
+ // scoreline unstated, or the write would stamp the bout and a device that
+ // had not yet seen a point struck elsewhere would erase it. An editor that
+ // mounted AFTER a verdict/score existed is not silent even if untouched -
+ // it is re-stating what it was shown, which is
// the existing, correct behaviour (daihyosenEnchoFields below re-derives
// and re-sends it). The first half compares against the live server
// baseline, not a mount-time snapshot, so it now agrees with
@@ -2960,7 +3225,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// pre-gate explicit-[] behaviour (not a regression this change
// introduces), not a gap this gate closes.
const daihyosenTouched = hasDaihyosen && (
- JSON.stringify(subs[daihyosenIdx]) !== JSON.stringify(serverSubs[daihyosenIdx]) ||
+ withoutRepPicks(subs[daihyosenIdx]) !== withoutRepPicks(serverSubs[daihyosenIdx]) ||
daihyosenVerdictDirty
);
const daihyosenKnownLocally = hasDaihyosen && (
@@ -3005,22 +3270,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// apart. Consumed by state.SubBoutWinnerSide for IV. Empty when the
// row's own id is unknown (a typed-name override, or a bout the server
// has not paired yet), which leaves the server's name derivation to
- // answer exactly as before. Kachinuki only: a fixed-format bout row
- // names the TEAMS, which are unique by rule.
+ // answer exactly as before. Kachinuki and the representative bout only: a
+ // fixed-format bout row names the TEAMS, which are unique by rule.
let winnerMemberId = "", sideAMemberId = "", sideBMemberId = "";
+ // playerNamesForBout is a pure lookup, read once for the two rows that carry
+ // fighters: a kachinuki bout's fighters, and the representative's own pick.
+ const fighters = isKachinuki || isDaihyo ? playerNamesForBout(idx) : null;
if (isKachinuki && !isDaihyo) {
- const { aName, bName, aMemberId, bMemberId } = playerNamesForBout(idx);
- ({ sideA, sideB, winner } = resolveKachinukiBoutSides({ aName, bName, wKey, teamWinnerName }));
- // The two SIDE ids travel with the winner's, because the winner id
- // alone settles nothing: every consumer compares it against these two,
- // so a row carrying one and not the others is read exactly like a row
- // carrying none. The server stamps them when IT appends a pairing,
- // but the first bout of an encounter is built here, and a browser run
- // on a real same-name pairing is what showed that gap -- the unit
- // fixtures had hand-written all three.
- sideAMemberId = aMemberId || "";
- sideBMemberId = bMemberId || "";
- if (winner) winnerMemberId = (wKey === "a" ? aMemberId : wKey === "b" ? bMemberId : "") || "";
+ ({ sideA, sideB, winner } = resolveKachinukiBoutSides({ aName: fighters.aName, bName: fighters.bName, wKey, teamWinnerName }));
} else {
// A fixed-order (or daihyosen) bout settles at the MATCH level, so the
// row records no per-fighter identity: the winner is the team, and the
@@ -3049,6 +3306,19 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
sideB = isDaihyo ? sideBName : "";
winner = teamWinnerName;
}
+ // The two SIDE ids travel with the winner's, because the winner id
+ // alone settles nothing: every consumer compares it against these two,
+ // so a row carrying one and not the others is read exactly like a row
+ // carrying none. The server stamps them when IT appends a pairing,
+ // but the first bout of an encounter is built here, and a browser run
+ // on a real same-name pairing is what showed that gap -- the unit
+ // fixtures had hand-written all three. On the representative row they
+ // are the operator's picks (bc-dhrp), beside the team names above.
+ if (fighters) {
+ sideAMemberId = fighters.aMemberId || "";
+ sideBMemberId = fighters.bMemberId || "";
+ if (winner) winnerMemberId = (wKey === "a" ? fighters.aMemberId : wKey === "b" ? fighters.bMemberId : "") || "";
+ }
const entry = {
position: isDaihyo ? DAIHYOSEN_POSITION : idx + 1,
sideA,
@@ -3060,11 +3330,17 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
winner,
decision,
};
- // Omitted, not stated empty: an absent key leaves the stored id and the
- // server's own derivation untouched, which is what "this writer knows
- // no id" has to mean. A typed-name override resolves to no id at all
- // (playerNamesForBout short-circuits it), so a substitution never sends
- // the replaced fighter's id under the new name.
+ // Omitted, not stated empty. On a numbered row an absent key leaves the
+ // stored id and the server's own derivation untouched, which is what "this
+ // writer knows no id" has to mean. On the representative row an absent id
+ // is how a CLEARED pick is sent, by naming that side's pick group: the write
+ // carries that row, so statedGroups adds repPickA and repPickB
+ // (match_groups.jsx) and groupKey reads an absent id as empty. The server
+ // copies the bout row WITHOUT the picks (CopyGroup keeps the destination's)
+ // and moves each side's pick as its own group, so an id left off the row
+ // clears the stored one only when that side's group is named. A typed-name
+ // override resolves to no id at all (playerNamesForBout short-circuits it), so
+ // a substitution never sends the replaced fighter's id under the new name.
if (sideAMemberId) entry.sideAMemberId = sideAMemberId;
if (sideBMemberId) entry.sideBMemberId = sideBMemberId;
if (winnerMemberId) entry.winnerMemberId = winnerMemberId;
@@ -3349,6 +3625,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// bout log and stays open (operator decision 2026-09-27).
const runRepBoutChange = async ({ preSave, send, refusals, notDone }) => {
setEditorErr("");
+ // A typed representative still being named answers for the row this change moves: its
+ // answer must land nothing on whatever row is then the daihyosen. Both sides' picks are
+ // superseded here (the newer pick wins). Add and Remove are disabled while one is out
+ // (repTyping), so this is the backstop, not the usual path.
+ newRepPick("a");
+ newRepPick("b");
setDaihyosenBusy(true);
const settle = holdScoringWrite();
try {
@@ -3404,7 +3686,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// One serialisation, read three times (isDirty, the adopt signature, and the
// per-row merge below). This editor re-renders on every SSE broadcast for the
// competition, so serialising the same board once per consumer was pure
- // repetition on the busiest path in the file.
+ // repetition on the busiest path in the file. isDirty compares the local board
+ // with the server board itself, so a representative pick the sheet dropped when its
+ // side was given another team (giveSideAnotherTeam) is no unsaved change once the
+ // server board no longer holds it.
const serverSubsSig = JSON.stringify(serverSubs);
const isDirty = JSON.stringify(subs) !== serverSubsSig || daihyosenVerdictDirty
// A removed withdrawal is unsaved until Save correction sends it.
@@ -3455,30 +3740,50 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// untouched row would read as edited, keep its stale value, and be
// written back over the server's by the next save.
const priorByPos = new Map((prevServerSubsRef.current || []).map(s => [s._pos, s]));
+ // The representative row follows the server per PART (repRowTakes): the score
+ // and each side's pick are separate, so a pick made here does not hold another
+ // device's point on the bout, and a point struck here does not hold another
+ // device's pick. Decided HERE from the board this render showed, not inside the
+ // updater: whether a pick was taken is told to useChangedGroups below, and an
+ // updater may run a render later or twice. The kept parts are composed from the
+ // updater's own `prev` row, so a pick giveSideAnotherTeam dropped meanwhile is not
+ // put back.
+ const dhServed = daihyosenIdx >= 0 ? serverSubs[daihyosenIdx] : null;
+ const dhShown = daihyosenIdx >= 0 ? subs[daihyosenIdx] : null;
+ const repTakes = dhServed && dhShown
+ ? repRowTakes(dhShown, priorByPos.get(DAIHYOSEN_POSITION), {
+ score: lastRowEditRef.current.get(DAIHYOSEN_POSITION),
+ a: lastRepPickEditRef.current.a,
+ b: lastRepPickEditRef.current.b,
+ }, m.modifiedAt)
+ : null;
setSubs(prev => {
const localByPos = new Map(prev.map(s => [s._pos, s]));
return serverSubs.map(ss => {
const local = localByPos.get(ss._pos);
if (!local) return ss;
- // bc-kclr: a snapshot written BEFORE the operator's last edit to
- // this row cannot know that edit, so the row stays theirs, whatever
- // it now equals. Striking a point and taking it back returns the
- // row to the value the server showed BEFORE the strike, so the
- // untouched test below read it as untouched, and the court feed's
- // lagging snapshot carrying the autosaved point was adopted over
- // the clear (and written back). Once a snapshot written after the
- // edit arrives (this editor's own save of it, or a later change on
- // another device) the ordinary rule applies. Both stamps are in the
- // server's clock frame (server_clock.jsx), so no time window.
- const editedAt = lastRowEditRef.current.get(ss._pos);
- if (editedAt !== undefined && (m.modifiedAt || 0) < editedAt) return local;
- const prior = priorByPos.get(ss._pos);
- // Untouched: the operator's row still equals what the server last
- // said, so there is nothing of theirs to keep — take the new value.
- if (prior && JSON.stringify(local) === JSON.stringify(prior)) return ss;
- return local;
+ if (repTakes && ss._pos === DAIHYOSEN_POSITION) return composeRepRow(local, ss, repTakes);
+ // bc-kclr: a snapshot written BEFORE the operator's last edit to this row
+ // cannot know that edit, so the row stays theirs (rowTakenFromServer).
+ return rowTakenFromServer(local, priorByPos.get(ss._pos), lastRowEditRef.current.get(ss._pos), m.modifiedAt) ? ss : local;
});
});
+ // What this editor took from the server it now agrees with, so an edit that puts it
+ // back to what the editor mounted with (clearing a pick or a bout another device
+ // made) is still a change and is named, and an edit of something else does not name
+ // it again. What it kept is the operator's own edit and stays one, so each row, and
+ // each side's pick, is agreed on its own. The same rule as the updater above
+ // (rowTakenFromServer, repRowTakes), asked of THIS render's board here, for the same
+ // reason as repTakes: an updater may run a render later or twice.
+ const shownByPos = new Map(subs.map(s => [s._pos, s]));
+ for (const ss of serverSubs) {
+ const taken = repTakes && ss._pos === DAIHYOSEN_POSITION
+ ? repTakes.score
+ : rowTakenFromServer(shownByPos.get(ss._pos), priorByPos.get(ss._pos), lastRowEditRef.current.get(ss._pos), m.modifiedAt);
+ if (taken) claimChanged.agree(boutGroup(ss._pos));
+ }
+ if (repTakes && repTakes.a) claimChanged.agree(GROUP_REP_PICK_A);
+ if (repTakes && repTakes.b) claimChanged.agree(GROUP_REP_PICK_B);
// Keep the correction baseline in step. It snapshots who won the bout
// being corrected so renderCorrectionWarning can say "you changed who won
// bout N"; left alone across a re-seed it would compare the SERVER's new
@@ -3508,7 +3813,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
const leaveEditor = async (go) => {
// Same contract as ScoreEditorModal: never close while a save,
// decision, or daihyosen request is mid-flight.
- if (submitting || decisionSubmitting || daihyosenBusy) return;
+ if (submitting || decisionSubmitting || daihyosenBusy || repTyping) return;
// bc-dscn: on a RUNNING match every scoring edit is autosaved, and the
// unmount writes one still inside the debounce window
// (useDebouncedRunningWrite), so leaving discards nothing and asks
@@ -3632,7 +3937,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
{isComplete ? "CORRECTION" : "PRE-MATCH"}
)}
- {canClose && }
+ {canClose && }
@@ -3916,12 +4221,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// A rename waits on the server, and the side can be given another team
// meanwhile: the member it names is then the old team's, which is not put in
// the new team's list nor reported in its row (submitInlineLineup's rule).
- const sideChanged = watchLineupSide(side);
- const sideChangedNotice = () => {
- if (!sideChanged()) return false;
- setLineupNotice({ key: noticeKey, tone: "error", text: SIDE_TEAM_CHANGED_NOTICE });
- return true;
- };
+ const sideChangedNotice = sideChangedGuard(side, noticeKey);
return pickManualBoutName({
sub: s, idx, sideKey: `${side}Name`, memberIdKey: `${side}MemberIdOverride`, squad, teamId,
compId: m.compId, password, updateSub,
@@ -3974,7 +4274,41 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// never disagree on it.
const manualPathB = isManualRow || kachinukiPastFirst || freeNameB;
const manualPathA = isManualRow || kachinukiPastFirst || freeNameA;
+ // bc-dhrp: the representative bout's picker (submitRepresentative). It is
+ // offered whenever the row is open to this viewer: a participant's sheet
+ // (selfReport) offers it only while the match runs, and the judges'
+ // decision on the row locks it (repBoutDecidedForParticipant). Its roster
+ // is the team's whole squad: a representative is usually fielded at a
+ // numbered position already, so the placed-elsewhere filter does not apply.
+ const repPickable = isDaihyoRow && !repBoutDecidedForParticipant && (!selfReport || m.status === "running");
+ const repRosterFor = (side, lineup, squad) => (repPickable ? rosterForSide(side, lineup, squad) : []);
+ // A list pick is the member itself; a typed name is resolved (submitRepresentative);
+ // a box cleared empties the pick. Nothing here writes a lineup.
+ const pickRepresentative = (side, noticeKey, priorId) => (value, member) => {
+ setLineupNotice(null);
+ const seq = newRepPick(side);
+ if (member && member.id) { pickDaihyosenRepresentative({ idx, side, updateSub }, member); return; }
+ if (!value) { pickDaihyosenRepresentative({ idx, side, updateSub }, null); return; }
+ submitRepresentative({ side, noticeKey, idx, value, priorId, seq });
+ };
+
+ // One picker per side: the representative row's (bc-dhrp: the team's whole squad,
+ // and a pick writes the representative's id) or the bout row's (lineup, manual
+ // or free name). Its roster, box visibility and handler are derived together, so
+ // they cannot disagree on which path a side takes.
+ const pickerB = isDaihyoRow
+ ? { roster: repRosterFor(m.sideB, lineupB, squadB), forceInput: repPickable, onSelectName: pickRepresentative("b", noticeKeyB, s.bMemberIdOverride) }
+ : { roster: rosterB, forceInput: manualPathB, onSelectName: manualPathB ? pickManual("b", noticeKeyB, squadB, teamBId) : pickPlayer("b", noticeKeyB) };
+ const pickerA = isDaihyoRow
+ ? { roster: repRosterFor(m.sideA, lineupA, squadA), forceInput: repPickable, onSelectName: pickRepresentative("a", noticeKeyA, s.aMemberIdOverride) }
+ : { roster: rosterA, forceInput: manualPathA, onSelectName: manualPathA ? pickManual("a", noticeKeyA, squadA, teamAId) : pickPlayer("a", noticeKeyA) };
+ // The read-only representative row (no picker: repPickable is false, as a
+ // participant's sheet is once decided or not running) names an unpicked side by
+ // its team, as the read-only scoreboards do (resolveBoutSideName's daihyosen rule).
+ // A pickable box keeps its "Representative" placeholder, and a picked side shows
+ // its representative. Display only: buildPatch sends what it always has.
+ const repTeamWhenUnpicked = (memberId, teamName) => (isDaihyoRow && !repPickable && !memberId ? teamName : "");
// Each row: [left side, center score, right side]: left=SHIRO, right=AKA
// T096/FR-031: manual pts/fouls edits end the per-bout fusensho
// (updateSubScore: the default-win circles go, struck points stay,
@@ -4006,8 +4340,8 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// The daihyosen is a representative bout, not a lineup position:
// "daihyosen" is not a valid lineup key (domain/team_lineup.go
// accepts only senpo/… or "1".."N"), so a name pick there would
- // 4xx. Suppress the picker by passing an empty roster (the input
- // only renders when roster.length > 0).
+ // 4xx, so the picker here writes the representative's member id
+ // instead (submitRepresentative).
// bc-dnst: playerName is the DISPLAYED name only -- a rename
// reaches a bout already fought via resolveBoutSideDisplayName
// (id-first against the current squad), while playerBName
@@ -4016,12 +4350,11 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// name untouched. This covers both the live LineupNameInput's
// `value` and the read-only static row, which both render
// from this same prop.
- playerName: resolveBoutSideDisplayName({ squad: squadB, memberId: playerBMemberId, storedName: playerBName }),
+ playerName: resolveBoutSideDisplayName({ squad: squadB, memberId: playerBMemberId, storedName: playerBName }) || repTeamWhenUnpicked(playerBMemberId, sideBName),
memberId: playerBMemberId,
- memberLabel: playerBLabel, roster: isDaihyoRow ? [] : rosterB, forceInput: manualPathB,
+ memberLabel: playerBLabel, ...pickerB,
noticeKey: noticeKeyB,
lineupSlot: !isDaihyoRow && idx + 1 <= teamSize,
- onSelectName: manualPathB ? pickManual("b", noticeKeyB, squadB, teamBId) : pickPlayer("b", noticeKeyB),
},
{
key: "a", tapKey: `${idx}:a`, pts: s.aPts, fouls: s.aFouls,
@@ -4032,14 +4365,13 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
return { ...prev, aFouls: r.fouls, bPts: r.opponentPts };
}),
color: "aka", label: "AKA",
- // See SHIRO note above: no lineup picker on the daihyosen row.
+ // See SHIRO note above.
// bc-dnst: displayed name only, see the SHIRO note above.
- playerName: resolveBoutSideDisplayName({ squad: squadA, memberId: playerAMemberId, storedName: playerAName }),
+ playerName: resolveBoutSideDisplayName({ squad: squadA, memberId: playerAMemberId, storedName: playerAName }) || repTeamWhenUnpicked(playerAMemberId, sideAName),
memberId: playerAMemberId,
- memberLabel: playerALabel, roster: isDaihyoRow ? [] : rosterA, forceInput: manualPathA,
+ memberLabel: playerALabel, ...pickerA,
noticeKey: noticeKeyA,
lineupSlot: !isDaihyoRow && idx + 1 <= teamSize,
- onSelectName: manualPathA ? pickManual("a", noticeKeyA, squadA, teamAId) : pickPlayer("a", noticeKeyA),
},
];
@@ -4142,7 +4474,8 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
metadata at all, so a fixed-order team registered
without members can still name its fighters bout by
bout; the static fallback remains only for the
- daihyosen row and for a row beyond teamSize that is
+ daihyosen row a participant cannot pick on (see
+ repPickable), and for a row beyond teamSize that is
not a kachinuki free/manual bout. No side chip here:
the header badge names the side once and the tinted
box carries it down the sheet. */}
@@ -4161,6 +4494,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
// The Up Next panel passes this for exactly that
// case; only this host was missed (bc-dnst).
clearable={!!rs.memberId}
+ placeholder={isDaihyoRow ? "Representative" : undefined}
ariaLabel={`${posLabel} ${rs.label} player`}
// A pick from the list closes it, and the list
// drops over this side's ippon buttons, so a double
@@ -4174,8 +4508,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
onSelect={rs.onSelectName}
/>
) : (
- /* The read-only branch, which the DAIHYOSEN row
- always takes: it has no picker, so it never gets
+ /* The read-only branch: a daihyosen row offers no
+ picker on a participant's sheet once the judges
+ decided it or while the match is not running
+ (repPickable). It shows the picked representative,
+ else the side's team name (repTeamWhenUnpicked), and
+ "-" only where a row names no one. It never gets
LineupNameInput's ` player`
aria-label. Removing the visible SHIRO/AKA chip
(operator ruling: the side is named once, by the
@@ -4388,7 +4726,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
data-testid="team-daihyosen-remove"
title="Remove the representative bout"
onClick={onRemoveDaihyosen}
- disabled={daihyosenBusy || submitting || decisionSubmitting}
+ disabled={daihyosenBusy || submitting || decisionSubmitting || repTyping}
>
Remove daihyosen
@@ -4478,7 +4816,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
admin_scoring_shared.jsx). The term is taught in the
title/hint above instead. */}
{!removingWithdrawal &&
-
}
@@ -4707,7 +5045,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
{/* bc-mrgc: every write that reached this match, kept or applied.
The organiser's view, so not on a self-run participant's sheet. */}
-
+
{/* 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
@@ -4722,7 +5060,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
)}
>
diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx
index 85e5a9c82..c04dea50f 100644
--- a/web-mobile/js/admin_shiaijo.jsx
+++ b/web-mobile/js/admin_shiaijo.jsx
@@ -14,8 +14,8 @@
import { createTimerPool } from './timer_pool.jsx';
import { applyPatch, keepNewerCompetitions } from './patch.jsx';
-import { SideCell } from './side_cell.jsx';
-import { useOpenedTapGuard } from './tap_guard.jsx';
+import { SideCell, WinnerTick } from './side_cell.jsx';
+import { useOpenedTapGuard, acceptTap } from './tap_guard.jsx';
import { useDialogFocus } from './dialog_focus.jsx';
// Imported DIRECTLY from the leaf rather than read off `window`. Two of the
// call sites below sit inside a `try { } catch (_e) { }` that swallows, so a
@@ -25,10 +25,18 @@ import { useDialogFocus } from './dialog_focus.jsx';
// api_client.jsx does not apply to it — the same move admin_scoring_shared.jsx
// already makes.
import {
- writeDidNotLand, writeKeepsEditorOpen, writeWasSuperseded, writeWasRefusedForClock, CLOCK_SKEW_REASON_TEXT,
+ writeDidNotLand, writeKeepsEditorOpen, writeWasSuperseded, writeWasRefusedForClock,
attemptScoreWrite, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, OVERRIDE_HELD_NOTICE,
- startWhileCorrectingMessage, correctWhileRunningMessage,
+ startWhileCorrectingMessage, startWhileStartingMessage, startWasBlockedByStartMessage,
+ correctWhileRunningMessage, wasToasted,
} from './write_result.jsx';
+// What a Start came back with (a clock_skew refusal, a thrown 409) is
+// start_match.jsx's to classify, shared with the Scores tab's own automatic
+// start (admin_schedule_score_editor.jsx). startPatch, the write itself, comes
+// from the same leaf, not from `window.startPatch`. The state wiring stays here:
+// startingRef/pickingRef carry the pick exemption (pickMatch), which that
+// leaf's guard has no word for.
+import { startPatch, classifyStartOutcome, startFailureMessage, startRefusalStands } from './start_match.jsx';
// swissRoundLabel: single owner is pool_ids.jsx (mp-dej2); this file used to
// carry its own copy. scoreRowMatchName names a match in the refusal notices.
import { swissRoundLabel, scoreRowMatchName } from './pool_ids.jsx';
@@ -49,8 +57,16 @@ import { isBarredMatch, sideBarredByDecision, involvesCompetitor } from './ineli
// 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.
+// header. The same rule is why winnerSideLR and teamMatchMarks come from the
+// side_marks.jsx leaf below, not from bracket.jsx.
import { BarredMatchNotice } from './barred_match_notice.jsx';
+// The winner cue and a withdrawn team's mark, imported from their leaf (its one
+// import is competitor_identity.jsx), never read off `window`: a missing global
+// used to paint nothing, with no error to say why.
+import { winnerSideLR, teamMatchMarks } from './side_marks.jsx';
+// bc-tmwn: team match marks (Kiken, Fus.) are placed beside the withdrawn
+// side's name via teamNameMark.
+import { teamNameMark } from './match_scoreboard.jsx';
const { useState: useStateSh, useMemo: useMemoSh, useEffect: useEffectSh, useRef: useRefSh, useCallback: useCallbackSh } = React;
@@ -309,7 +325,7 @@ export function shiaijoScoreCell(m) {
const ipponsB = m.ipponsB || [];
const s = window.formatIpponsScore
? window.formatIpponsScore(ipponsB, ipponsA, m.score, m.decision, m.encho, m.decidedByHantei,
- window.winnerSideLR ? window.winnerSideLR(m) : null)
+ winnerSideLR(m))
: "";
return s ? { kind: "ippon", ippon: s } : { kind: "none" };
}
@@ -618,14 +634,16 @@ 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
+ // The last Start the server refused, or the console refused for a start still
+ // out that has since landed, as { key, compId, msg } for the match it was
+ // refused for (null = none). It belongs to that match: it shows on that
+ // match's Up next card or queue row for as long as the match is still
+ // scheduled on this court, and goes when the match leaves scheduled or the
+ // court's (non-empty) list no longer holds it (the effect beside upNext
+ // below), when any competitor's eligibility changes in that competition (the
+ // competitor_status_updated handler just below), which is what restoring a
+ // withdrawn competitor broadcasts, or when the operator starts a match
+ // (startMatch). Up next changing by itself does not drop it. 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
@@ -729,10 +747,30 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// Selected match for the inline scoring panel. `calledKey` marks the match
// the operator has announced this session (local cue only); `callingKey`
- // guards the in-flight announce request.
+ // guards the in-flight announce request. `callTapRef` guards against double
+ // taps on Call to court (bc-cdbl).
const [calledKey, setCalledKey] = useStateSh(null);
const [callingKey, setCallingKey] = useStateSh(null);
const [startingKey, setStartingKey] = useStateSh(null);
+ // The start in flight, as { key, match } (null = none). The guards READ this
+ // ref, never startingKey: an advance awaits its finish write and then starts
+ // the next match from the render that began the await, so a closure's state
+ // is stale by then (a start begun meanwhile was not seen; one that has
+ // landed still was). The state above only drives the rendering.
+ const startingRef = useRefSh(null);
+ // The scheduled match a pick (pickMatch) is starting, from the moment its
+ // guards pass until the pick settles: { key, match } or null. It is a start in
+ // flight for the one-start guard, because the pick awaits the defer of the
+ // running bout before it reaches startMatch (and so before startingRef is
+ // set). startMatch is handed its own pick, which is exempt from the guard.
+ const pickingRef = useRefSh(null);
+ const callTapRef = useRefSh(null);
+ // The newest court call this console made per match, keyed by matchKey, as
+ // { id, msg }: the announcement id (a promise of it, null if the send failed)
+ // and the text sent, so the call can be withdrawn once its match leaves
+ // scheduled (bc-cdbl). A reload forgets them; the 5-minute expiry is the
+ // backstop.
+ const callIdsRef = useRefSh(new Map());
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.
@@ -799,10 +837,16 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// 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);
- // The last tap the console refused because a match is live (a Correct) or
- // a correction is open (a Start): { key, why: "running" | "correcting" }.
+ // The last tap the console refused because a match is live (a Correct), a
+ // correction is open (a Start) or another start is still out (a Start):
+ // { key, compId, why: "running" | "correcting" | "starting", against, label, start }
+ // (`against` is the blocker's key, `label` its name as the operator sees it, and
+ // `start` the in-flight start or pick record for "starting", null otherwise).
// Only the tap is stored; the notice text is derived at render while the
- // condition still holds (refusalNotice below), so it cannot go stale.
+ // condition still holds (refusalNotice below), so it cannot go stale. The one
+ // that outlives its condition is "starting": when the start it named lands,
+ // the effect beside startMatch turns it into a stored startError for the
+ // refused match, in the past tense.
const [refusedTap, setRefusedTap] = useStateSh(null);
// Pending court reassignment, awaiting operator confirmation. Moving a match
// off this shiaijo is disruptive (it leaves the court and joins another's
@@ -1001,6 +1045,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// running. ONE predicate for the editor's `started` prop and for liveBout
// below, so the two cannot drift.
const isStartedSnapshot = (m) => !!m && !!startedFrom && startedFrom.key === matchKey(m) && startedFrom.at === m.modifiedAt;
+ // The match whose start request is still in flight (startingKey), found on
+ // the court, for the render (liveBout below). The guards read startingRef.
+ const startingBlocker = startingKey ? sorted.find((x) => matchKey(x) === startingKey) || null : null;
// The bout live on this court right now (bc-crpn): the pick when it is
// running or is the start this console just made (the feed still reads
// "scheduled" until the refetch; a queued offline start too), else
@@ -1008,24 +1055,35 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// else the match whose start request is still in flight.
const liveBout = (pickedMatch && (pickedMatch.status === "running" || isStartedSnapshot(pickedMatch)))
? pickedMatch
- : running[0]
- || (startingKey ? sorted.find((x) => matchKey(x) === startingKey) || null : null);
+ : running[0] || startingBlocker;
// The correction the console holds open (completed), if any. A reopened
// one is the live bout and is handed over by the effect above.
const openCorrection = correctingMatch && correctingMatch.status === "completed" ? correctingMatch : null;
// The "Another bout is running" list: the bouts beyond the one the panel
// shows, plus the running one itself while a correction holds the panel.
const alsoRunning = openCorrection ? running : running.slice(1);
- // What refuses a tap right now, by kind: a live bout refuses a Correct,
- // an open correction refuses a Start. ONE place builds each sentence, for
- // the toast and the notice alike.
- const refusalBlocker = (why) => (why === "running" ? liveBout : openCorrection);
+ // What refuses a tap right now, by kind: a live bout refuses a Correct, an
+ // open correction refuses a Start, and a start still out refuses a Start of
+ // another match. ONE place builds each sentence, for the toast and the
+ // notice alike.
+ // "starting" reads the refs, so a refusal made from an advance's async
+ // continuation names the start that is out NOW (the render's copy may be
+ // stale there), and a refusal made during a pick's defer names that pick. At
+ // render time the refs and the state agree.
+ const refusalBlocker = (why) => {
+ if (why === "running") return liveBout;
+ if (why === "starting") {
+ const s = startingRef.current || pickingRef.current;
+ return s ? s.match : null;
+ }
+ return openCorrection;
+ };
const refusalText = (why) => {
const blocker = refusalBlocker(why);
if (!blocker) return null;
- return why === "running"
- ? correctWhileRunningMessage({ court, label: scoreRowMatchName(blocker) })
- : startWhileCorrectingMessage({ label: scoreRowMatchName(blocker) });
+ if (why === "running") return correctWhileRunningMessage({ court, label: scoreRowMatchName(blocker) });
+ if (why === "starting") return startWhileStartingMessage({ label: scoreRowMatchName(blocker) });
+ return startWhileCorrectingMessage({ label: scoreRowMatchName(blocker) });
};
// The notice for a refused tap, derived: shown only while the match that
// refused it still does, so it goes by itself when that match is finished,
@@ -1037,9 +1095,27 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
};
// Refuse a tap: remember it against the match that blocks it, and toast.
const refuseTap = (m, why) => {
- setRefusedTap({ key: matchKey(m), why, against: matchKey(refusalBlocker(why)) });
+ const blocker = refusalBlocker(why);
+ setRefusedTap({
+ key: matchKey(m), compId: m.compId, why, against: matchKey(blocker), label: scoreRowMatchName(blocker),
+ // The start (or pick) record itself, which startMatch marks `landed` once the
+ // start went out: the effect beside startMatch asks it before writing the
+ // past-tense sentence.
+ start: why === "starting" ? (startingRef.current || pickingRef.current) : null,
+ });
if (showToast) showToast(refusalText(why), "error");
};
+ // The stored refusal is read against the refused match's LIVE row in the
+ // court's list (the same list the court-call withdrawal reads), never against
+ // a snapshot: the notice shows only while that row is still scheduled, so it
+ // cannot outlive the match for the render before the effect below drops it.
+ const startErrorKey = startError ? startError.key : null;
+ const startErrorRow = startErrorKey ? courtMatchesRaw.find((x) => matchKey(x) === startErrorKey) : null;
+ const startErrorStatus = startErrorRow ? startErrorRow.status : null;
+ // The Start notice a scheduled match's card or queue row shows: a refused
+ // tap's derived notice, else the stored Start refusal for that match.
+ const startNoticeFor = (key) => refusalNotice(key)
+ || (startError && startError.key === key && startErrorStatus === "scheduled" ? startError.msg : null);
// For pool daihyosen/tiebreaker bouts, enrich the selected match with
// rep-player roster data so ScoreEditorModal renders the rep-picker dropdowns
@@ -1122,11 +1198,8 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// 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;
- // The Up next card's alert line: a refused tap's notice wins over a stored
- // start refusal, being the newer tap.
- const upNextNotice = upNext
- ? refusalNotice(matchKey(upNext)) || (startError && startError.key === matchKey(upNext) ? startError.msg : null)
- : null;
+ // The Up next card's alert line (startNoticeFor).
+ const upNextNotice = upNext ? startNoticeFor(matchKey(upNext)) : 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
@@ -1134,20 +1207,29 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
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;
+ // A stored Start refusal describes ONE match at one moment, so it is judged
+ // against that match's live row and nothing else (startErrorStatus above;
+ // start_match.jsx startRefusalStands owns the rule, and the Scores tab asks
+ // the same one, admin_schedule_score_editor.jsx). It is
+ // dropped when the match leaves scheduled (started here or elsewhere, closed
+ // by a decision), or when the court's list is non-empty and no longer holds
+ // it (the match moved). Up next changing by itself drops nothing: a refusal
+ // for a match picked from further down the queue stays on its row, and is
+ // there on the card if the match later reaches Up next. A refusal is about
+ // one match at one moment, so a stale one never revives when that match is
+ // later sent back to the queue: the next tap asks the server again. A start
+ // by the operator clears it too (startMatch), and so does an eligibility
+ // change in its competition (the competitor_status_updated handler).
+ //
+ // The list is EMPTY while it loads and across a transient empty refetch, and
+ // an empty list says nothing about the match, so the refusal is left where it
+ // is then (the court-call withdrawal below leaves an absent key alone for the
+ // same reason). Only a list that holds matches yet lacks this one counts.
+ const startErrorStands = startRefusalStands({ row: startErrorRow, listHoldsMatches: courtMatchesRaw.length > 0 });
useEffectSh(() => {
- setStartError(null);
- }, [upNextKey]);
+ if (!startErrorKey) return;
+ if (!startErrorStands) setStartError(null);
+ }, [startErrorKey, startErrorStands]);
// "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
@@ -1223,50 +1305,125 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
return { entries, alsoWaiting: selHasActive };
}, [allMatches, effectiveCompId, running, filteredScheduled, courtKnown]);
- // Delegate to the canonical start-patch factory (admin_schedule.jsx) rather
- // than re-declaring its shape: a second copy could silently drift. Both
- // modules ship in the same admin bundle, so the global is always present;
- // fail loudly if that ever stops being true instead of forking behaviour.
- const startPatch = () => {
- if (typeof window.startPatch !== "function") {
- throw new Error("startPatch factory unavailable: admin_schedule.jsx not loaded");
- }
- return window.startPatch();
+ // The one place the Starting… state follows the refs: the start in flight,
+ // else the pick still deferring the running bout (so a start settling inside
+ // a pick does not blank the button the pick is still holding).
+ const syncStartingKey = () => {
+ const s = startingRef.current || pickingRef.current;
+ if (mountedRef.current) setStartingKey(s ? s.key : null);
};
-
- // Returns true when the start write succeeded, false otherwise: pickMatch
- // relies on this so it only pins pickedKey for a match that actually started
- // (a blocked-by-eligibility start must not steal the panel).
- const startMatch = async (m) => {
- if (startingKey) return false;
+ // A refusal for a start that was still out ("starting") is derived while that
+ // start is out: "X is still being started". When it has landed (or failed),
+ // the sentence stops being true, but nothing else would say the match the
+ // operator asked for was never started (a 2.7s toast was the only sign,
+ // bc-aadv). So it becomes a STORED refusal for the refused match, in the past
+ // tense, bound to that match like every stored refusal (the effect beside
+ // upNext). The refused match is NOT started on its own: the court now has a
+ // running bout, so a second automatic start would draw court_busy or put two
+ // bouts on the court unasked, and one start at a time is the rule (fce5866c).
+ // The operator starts it, which clears the sentence. The effect re-runs when
+ // the start in flight changes (startingKey), and reads the refs for the truth.
+ //
+ // A start that did NOT land leaves the court free, so the past-tense sentence
+ // would be false, and that start's own refusal (a thrown 409, a clock_skew
+ // answer or a superseded one that held its result group) is what the
+ // operator needs. It is stored for the
+ // blocker before this runs (startMatch's refusal branch or its catch, ahead
+ // of its finally), and startMatch clears
+ // startError when a start passes the guard, so any value present when the
+ // blocker ends is the blocker's own: it is kept, and the waiting match shows
+ // nothing, as it did before the sentence existed.
+ //
+ // The sentence also needs a start that WENT OUT. The blocker can be a pick whose
+ // deferral of the running bout failed (the revert threw or was unavailable): it
+ // never started, nothing was written and the court is as it was, so "was being
+ // started" would be false. The start record the refusal holds says whether a start
+ // landed (`landed`, set by startMatch, also on the pick's record), not the court's
+ // list: the list is refreshed a moment after the write and may still show the match
+ // scheduled, and a start queued offline stays scheduled until the connection
+ // returns, yet both were sent. Otherwise the refused tap is dropped, with no sentence.
+ useEffectSh(() => {
+ if (!refusedTap || refusedTap.why !== "starting") return;
+ if (startingRef.current || pickingRef.current) return;
+ if (refusedTap.start && refusedTap.start.landed) {
+ const blocked = { key: refusedTap.key, compId: refusedTap.compId, msg: startWasBlockedByStartMessage({ label: refusedTap.label }) };
+ setStartError((prev) => prev ?? blocked);
+ }
+ setRefusedTap(null);
+ }, [refusedTap, startingKey]);
+ // Returns true when the start went out (landed, or was queued offline), false
+ // otherwise: pickMatch relies on this so it only pins pickedKey for a match that
+ // actually started (a blocked-by-eligibility start, a clock_skew refusal or a
+ // superseded answer that held the result group must not steal the panel; a
+ // superseded answer that held nothing is an echo of a start that landed and
+ // does pin it).
+ // `pick` is the pickingRef entry pickMatch made for THIS start, if it is one:
+ // that pick is the start itself, not a start in flight against it.
+ const startMatch = async (m, { pick = null } = {}) => {
+ const key = matchKey(m);
+ const refusalFor = (msg) => ({ key, compId: m.compId, msg });
+ const inFlight = startingRef.current || (pickingRef.current && pickingRef.current !== pick ? pickingRef.current : null);
+ if (inFlight) {
+ // One start at a time. An advance (Finish + Start Next, or the one
+ // after a decision) that asks for another match while a start is out
+ // is refused, and the refusal says so where the refused match is
+ // shown (refuseTap: its Up next card or its queue row) and toasts,
+ // rather than doing nothing (bc-aadv). A repeat for the match already
+ // being started is silent: it is on its way.
+ if (inFlight.key !== key && mountedRef.current) refuseTap(m, "starting");
+ return false;
+ }
setStartError(null);
- setStartingKey(matchKey(m));
- const refusalFor = (msg) => ({ key: matchKey(m), compId: m.compId, msg });
+ const started = { key, match: m };
+ startingRef.current = started;
+ setStartingKey(key);
try {
// Starting makes the match running; the scoring panel shows
// running[0], so it picks the match up on the next refetch.
const res = await onEditScore(m.compId, m.id, startPatch(), m);
- // A clock_skew refusal means the server stored NOTHING and, unlike
- // a queued start, nothing will land later — so returning true here
- // would pin the panel on a match that never started while the tap
- // looked like it worked. Found in browser verification: this card
- // button is a start path none of the review sweeps enumerated (it
- // is not one of the editor call sites). The relearn the refusal
- // triggers means a SECOND tap normally succeeds; the toast tells
- // 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(refusalFor(msg));
- if (showToast) showToast(msg, "error");
+ // A clock_skew answer, or a superseded one that held the result
+ // group, means the server stored NOTHING of the start and, unlike a
+ // queued start, nothing will land later, so returning true here would
+ // pin the panel on a match that never started while the tap looked
+ // like it worked. Found in browser verification: this card button is
+ // a start path none of the review sweeps enumerated (it is not one of
+ // the editor call sites). (A superseded answer that held NOTHING is an
+ // echo of a start that already landed: classifyStartOutcome answers
+ // ok for it and it takes the landed path below.)
+ // Both are a REFUSED start (start_match.jsx classifyStartOutcome): the
+ // sentence is stored on the refused match and toasted once, and
+ // `landed` stays unset, so a refusal that waited on this start is not
+ // worded as "was being started". The sentences differ in their advice
+ // (after a clock refusal a SECOND tap normally succeeds; after a
+ // held-group superseded one the operator checks the match first), which is why
+ // the toast and the card carry the sentence and not a bare failure.
+ //
+ // For a PICK the court is not "as it was": the running bout was
+ // already sent back to the queue (deferAndStart), and this refusal
+ // leaves the court idle with that bout waiting in the queue.
+ const outcome = classifyStartOutcome(res);
+ if (!outcome.ok) {
+ if (mountedRef.current) setStartError(refusalFor(outcome.msg));
+ if (showToast) showToast(outcome.msg, "error");
return false;
}
+ // The start went out. A refusal that waited on it (or on the pick that
+ // made it) reads this, whichever record it holds.
+ started.landed = true;
+ if (pick) pick.landed = true;
return true;
} catch (e) {
- 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");
+ const msg = startFailureMessage(e);
+ if (mountedRef.current) setStartError(refusalFor(msg));
+ // A thrown refusal onEditScore (editMatchScore, admin.jsx) already
+ // toasted is not toasted again: the toast is a single slot, so a
+ // second one only restarts its timer. The card or row says it either way.
+ if (showToast && !wasToasted(e)) showToast(msg, "error");
return false;
} finally {
- if (mountedRef.current) setStartingKey(null);
+ // Only the start that holds the ref clears it; the state follows the refs.
+ if (startingRef.current && startingRef.current.key === key) startingRef.current = null;
+ syncStartingKey();
}
};
@@ -1283,13 +1440,42 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// • While a correction is open nothing starts (operator ruling
// 2026-09-27, bc-crpn): the tap is refused with a notice, BEFORE the
// defer above, so a refused start sends nothing back to the queue.
+ // • One start at a time (bc-aadv): while another match's start is still
+ // out, a Start of a different scheduled match is refused with a notice
+ // on its row, also BEFORE the defer, so the running bout is not sent
+ // back for a start that never happens. A repeat for the match already
+ // being started is silent: it is on its way.
const pickMatch = async (m) => {
if (!m || m.status === "completed") return;
if (openCorrection) {
refuseTap(m, "correcting");
return;
}
+ const inFlight = startingRef.current || pickingRef.current;
+ if (inFlight && m.status === "scheduled") {
+ if (inFlight.key !== matchKey(m)) refuseTap(m, "starting");
+ return;
+ }
setRefusedTap(null);
+ // A scheduled pick is a start in flight from here, before the defer below
+ // awaits: a second Start tapped during that wait is refused, and does not
+ // send the running bout back a second time.
+ const pick = m.status === "scheduled" ? { key: matchKey(m), match: m } : null;
+ if (pick) {
+ pickingRef.current = pick;
+ syncStartingKey();
+ }
+ try {
+ await deferAndStart(m, pick);
+ } finally {
+ if (pick && pickingRef.current === pick) pickingRef.current = null;
+ syncStartingKey();
+ }
+ };
+
+ // The defer of the running bout and the start of the picked one, after
+ // pickMatch's guards and its pickingRef entry (see pickMatch).
+ const deferAndStart = async (m, pick) => {
const cur = running[0] || null;
const isSame = cur && matchKey(cur) === matchKey(m);
if (cur && !isSame) {
@@ -1310,7 +1496,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
if (!mountedRef.current) return;
}
if (m.status === "scheduled") {
- const ok = await startMatch(m);
+ const ok = await startMatch(m, { pick });
if (!ok || !mountedRef.current) return;
// bc-strt: the editor treats this snapshot as running, so a point
// struck before the refetch shows it running saves at once.
@@ -1355,14 +1541,45 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// competitors (and anyone watching the public app) are notified they're
// being summoned to this shiaijo. It does NOT start the match; Start is
// always available on its own.
- const callToCourt = async (m) => {
+ const callToCourt = async (m, ev) => {
+ // bc-cdbl: double tap guard. acceptTap returns false for a bounce and
+ // true otherwise, stamping the tap as it goes.
+ if (!acceptTap(callTapRef, ev, matchKey(m))) return;
if (!window.API || typeof window.API.sendAnnouncement !== "function") return;
const a = (m.sideA && m.sideA.name) || "Aka";
const b = (m.sideB && m.sideB.name) || "Shiro";
const msg = `Now calling ${b} and ${a} to Shiaijo ${court}.`.slice(0, 200);
setCallingKey(matchKey(m));
+ // The call already up for this match, if any: { id, msg }, where id is a
+ // promise of its announcement id. It comes down only once the new call is
+ // answered (below), so a send that fails leaves it up. The server replaces
+ // only an announcement with identical text, so a call whose text changed
+ // (a side's name resolved) would otherwise leave the earlier banner up
+ // until it expired (bc-cdbl).
+ const earlier = callIdsRef.current.get(matchKey(m)) || null;
try {
- await window.API.sendAnnouncement(msg, 5, password);
+ const sent = window.API.sendAnnouncement(msg, 5, password);
+ const key = matchKey(m);
+ // Keep the newest call per match, with its text, so starting the match
+ // can withdraw it (bc-cdbl). The id is kept as a promise: a start that
+ // lands before the answer must still withdraw the call once its id
+ // arrives. If the send fails, the earlier call stays up, and so does
+ // its entry.
+ const entry = { msg, id: sent.then((ann) => (ann && ann.id) || null, () => (earlier ? earlier.id : null)) };
+ callIdsRef.current.set(key, entry);
+ sent.catch(() => {
+ if (callIdsRef.current.get(key) !== entry) return;
+ if (earlier) callIdsRef.current.set(key, earlier);
+ else callIdsRef.current.delete(key);
+ });
+ // The earlier call comes down once this one is answered, but only when
+ // its text differs. An identical text already replaced it on the server
+ // (AnnouncementStore.Add), so a DELETE of it would 404; a failed send
+ // leaves it up (its id comes back unchanged, so nothing is taken down).
+ if (earlier && earlier.msg !== msg) {
+ takeDown(Promise.all([earlier.id, entry.id]).then(([oldId, newId]) => (oldId !== newId ? oldId : null)));
+ }
+ await sent;
if (!mountedRef.current) return;
setCalledKey(matchKey(m));
if (showToast) showToast(`Called ${b} and ${a} to Shiaijo ${court}`);
@@ -1373,6 +1590,40 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
}
};
+ // withdrawCall: take this console's court call for the match keyed `key`
+ // back down once that match has left scheduled (bc-cdbl). Fire
+ // and forget: a failed delete is logged, never allowed to fail the start,
+ // and the 5-minute expiry still clears the banner.
+ const withdrawCall = (key) => {
+ const entry = callIdsRef.current.get(key);
+ if (!entry) return;
+ callIdsRef.current.delete(key);
+ takeDown(entry.id);
+ };
+ // takeDown: delete the announcement whose id `pending` resolves to (none
+ // when it resolves to null). Fire and forget, shared by withdrawCall and the
+ // earlier call callToCourt replaces.
+ const takeDown = (pending) => {
+ pending
+ .then((id) => {
+ if (!id || !window.API || typeof window.API.deleteAnnouncement !== "function") return;
+ return window.API.deleteAnnouncement(id, password);
+ })
+ .catch((e) => console.warn("Could not withdraw the court call", e));
+ };
+
+ // A court call comes down once its match has left scheduled, however that
+ // happened: started here, started from another device, or closed by a
+ // decision. A key the list does not hold is left alone, since the list is
+ // empty while it loads and a match moved off this court is not this
+ // console's to judge.
+ useEffectSh(() => {
+ for (const key of callIdsRef.current.keys()) {
+ const m = courtMatchesRaw.find((x) => matchKey(x) === key);
+ if (m && m.status !== "scheduled") withdrawCall(key);
+ }
+ }, [courtMatchesRaw]);
+
// moveMatch: reorder by swapping scheduledAt with the adjacent row.
// direction: "up" (earlier) or "down" (later). Only scheduled matches
// can be reordered; running/completed are stable.
@@ -1460,7 +1711,13 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
const { compId, matchId, to } = pendingMove;
setMovingCourt(true);
try {
- await onMoveCourt(compId, matchId, to);
+ // The host answers true only when the move landed (the host reports
+ // its own failure and resolves false). A move that did not land
+ // leaves this console's call for the match up.
+ const landed = await onMoveCourt(compId, matchId, to);
+ // The call names the court the match is leaving, so it comes down
+ // once the move has landed (bc-cdbl).
+ if (landed === true) withdrawCall(matchKey({ compId, id: matchId }));
if (mountedRef.current) setPendingMove(null);
} finally {
if (mountedRef.current) setMovingCourt(false);
@@ -1677,7 +1934,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
callToCourt(upNext)}
+ onClick={(ev) => callToCourt(upNext, ev)}
title="Announce this match to spectators and competitors"
>
{Icon && }{" "}
@@ -1707,7 +1964,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
label="Upcoming" subGroup matches={upcomingQueueMatches}
courts={courts} onMoveCourt={requestMoveCourt}
onMove={moveMatch} onEnterLineup={(m) => setLineupKey(matchKey(m))}
- onPick={pickMatch} noticeFor={(m) => refusalNotice(matchKey(m))}
+ onPick={pickMatch} noticeFor={(m) => startNoticeFor(matchKey(m))}
onCall={callToCourt} callingKey={callingKey} calledKey={calledKey} startingKey={startingKey}
scheduled={filteredScheduled}
password={password}
@@ -1884,8 +2141,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
}}
onSubmitAndNext={async (patch) => {
const next = nextActiveAfter(selectedMatch);
+ let res;
try {
- const res = await onEditScore(selectedMatch.compId, selectedMatch.id, patch, selectedMatch);
+ res = await onEditScore(selectedMatch.compId, selectedMatch.id, patch, selectedMatch);
// See the onSubmit handler above: a superseded write must not
// advance the local bracket on a winner the server discarded.
if (!writeWasSuperseded(res)) maybeAdvanceLocal(selectedMatch, patch);
@@ -1898,9 +2156,13 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// Finish + start the next scheduled match, which then
// becomes the running match the panel shows.
if (next && next.status === "scheduled") {
- try { await onEditScore(next.compId, next.id, startPatch(), next); } catch (_s) { /* gate */ }
+ await startMatch(next);
}
} catch (_e) { /* keep panel */ }
+ // The finish write's answer, as the editor needs it (a partial
+ // apply's note, a refused write's banner); undefined when the
+ // write threw.
+ return res;
}}
onAfterDecision={async (result) => {
// A fusenpai/hantei decision already persisted the bout via
@@ -1917,7 +2179,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
// competitor a withdrawal or no-show has just barred.
const next = nextActiveAfter(selectedMatch, sideBarredByDecision(result, selectedMatch));
if (next && next.status === "scheduled") {
- try { await onEditScore(next.compId, next.id, startPatch(), next); } catch (_s) { /* gate */ }
+ await startMatch(next);
}
}}
password={password}
@@ -2170,6 +2432,10 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE
const aName = slotName(m.sideA?.name || "", (m.feeders || [])[0]);
const bName = slotName(m.sideB?.name || "", (m.feeders || [])[1]);
const scoreCell = shiaijoScoreCell(m);
+ // bc-tmwn: for completed rows, determine which side won and get team marks.
+ const winnerSide = isComplete ? winnerSideLR(m) : null;
+ // teamMatchMarks itself returns no marks for a non-team row or one not completed.
+ const { shiro: teamShiroMark, aka: teamAkaMark } = teamMatchMarks(m, isTeamMatch(m));
// Derive position in the full scheduled list to know when to disable ↑/↓.
// `scheduled` is the court's complete scheduled array (including Up Next);
// the row may be in the Upcoming slice but we disable based on absolute pos.
@@ -2225,12 +2491,18 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE
nothing. Removing the badge on top of it would have left the
side carried by colour alone on the one surface where reading
it wrong mis-scores a bout. */}
-
-
+
+ {winnerSide === "left" && }
+
+ {teamNameMark("shiro", teamShiroMark, )}
+ vs
-
-
+
+ {winnerSide === "right" && }
+
+ {teamNameMark("aka", teamAkaMark, )}
+
{/* bc-cse: a barred match cannot be started (the server refuses it),
@@ -2288,7 +2560,7 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE
call to the court for a match the server would refuse to
start. */}
{onCall && window.API && typeof window.API.sendAnnouncement === "function" && !barred && (
- onCall(m)} title="Announce this match to spectators and competitors">
+ onCall(m, ev)} title="Announce this match to spectators and competitors">
{callingKey === matchKey(m) ? "Calling…" : (calledKey === matchKey(m) ? "Call again" : "Call to court")}
)}
diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx
index 44042b46e..d3674615f 100644
--- a/web-mobile/js/api_client.jsx
+++ b/web-mobile/js/api_client.jsx
@@ -41,7 +41,7 @@ import { bridge as _bridge } from './court_bridge.jsx';
// The offset lives in a leaf so a score editor can read the same clock (server_clock.jsx).
import { serverNowMs, serverClockOffsetMs, setServerClockOffsetMs } from './server_clock.jsx';
import {
- writeDidNotLand, writeWasSuperseded, writeWasRefusedForClock, writeNeedsWinner, writeDefaultWinStands, writeHeldDecision, writeDisplacedGroups, supersededBanner,
+ writeDidNotLand, writeWasSuperseded, writeWasRefusedForClock, writeNeedsWinner, writeDefaultWinStands, writeHeldDecision, writeDisplacedGroups, writeDisplacedForWinner, supersededBanner,
decisionWord,
SUPERSEDED_REASON, SUPERSEDED_ADVICE,
CLOCK_SKEW_REASON_TEXT, CLOCK_SKEW_ADVICE, CLOCK_SKEW_UNHEALED_ADVICE,
@@ -719,8 +719,10 @@ function _isAllowedTerminalRequest(method, url) {
// 'sent' GOOD news (bc-offl): held finished results landed in one
// flush; the one kind App renders as a success toast
// 'displaced' held finished results landed and moved a later change of
-// their match to its history (it would have left the
-// finished match without a winner); recorded, told once
+// their match to its history; recorded, told once.
+// `forWinner` of the `count` were moved because the change
+// would have left the finished match without a winner
+// (writeDisplacedForWinner); the rest carry no such reason
// ---------------------------------------------------------------------------
const _queueAlertListeners = new Set();
const _pendingQueueAlerts = [];
@@ -1464,7 +1466,11 @@ async function _flushQueue() {
};
// Held finishes that landed and moved a LATER change of their match to
// its history (writeDisplacedGroups): told once per pass, like 'sent'.
+ // displacedForWinnerThisPass counts those whose answer said the change
+ // would have left the finished match without a winner
+ // (writeDisplacedForWinner); the alert words the rest plainly.
let displacedThisPass = 0;
+ let displacedForWinnerThisPass = 0;
// bc-offl: HELD finished results that landed in this flush, counted the
// same way and announced once at the end ('sent'), so the operator who saw
// them held is told they arrived. A terminal write enters the queue only
@@ -1725,7 +1731,10 @@ async function _flushQueue() {
// applied:false answer, a clock-skew refusal included
// (which never reaches here: the arms above continue).
if (terminal && !writeWasSuperseded(body)) sentThisPass++;
- if (terminal && writeDisplacedGroups(body).length > 0) displacedThisPass++;
+ if (terminal && writeDisplacedGroups(body).length > 0) {
+ displacedThisPass++;
+ if (writeDisplacedForWinner(body)) displacedForWinnerThisPass++;
+ }
// A confirmed terminal score write needs no further rev
// tracking: drop its counter (mirrors recordScore's online
// completed path) so _matchRevCounters doesn't grow for the
@@ -1913,7 +1922,7 @@ async function _flushQueue() {
// success, so a pass with both still ends on the error, and a pass with
// no error shows the confirmation.
if (sentThisPass > 0) _notifyQueueAlert({ kind: 'sent', count: sentThisPass, terminalCount: sentThisPass });
- if (displacedThisPass > 0) _notifyQueueAlert({ kind: 'displaced', count: displacedThisPass, terminalCount: displacedThisPass });
+ if (displacedThisPass > 0) _notifyQueueAlert({ kind: 'displaced', count: displacedThisPass, terminalCount: displacedThisPass, forWinner: displacedForWinnerThisPass });
_notifyScoreSupersededAlert(
supersededThisPass,
lastSupersededMatch ? lastSupersededMatch.compID : undefined,
@@ -4485,6 +4494,9 @@ const API = {
method: 'DELETE',
headers: { 'X-Tournament-Password': password }
});
+ // 404: the announcement is already gone (expired, or another console
+ // withdrew it first), which is the outcome the caller asked for.
+ if (res.status === 404) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Failed to delete announcement (Status ${res.status})`);
diff --git a/web-mobile/js/app.jsx b/web-mobile/js/app.jsx
index 78dd6a8f8..93cd0dd16 100644
--- a/web-mobile/js/app.jsx
+++ b/web-mobile/js/app.jsx
@@ -390,10 +390,12 @@ export function queueAlertMessage(alert) {
case "sent":
return `${n} ${noun} sent.`;
// Held finishes that landed and moved a later change of their match to
- // its history (it would have left the finished match without a winner).
- // Recorded, so not an error: the history has the change.
+ // its history. `forWinner` of them say the change would have left the
+ // finished match without a winner; the rest carry no such reason, and an
+ // alert without the count words none of them so. Recorded, so not an
+ // error: the history has the change.
case "displaced":
- return displacedAlertText(n, one);
+ return displacedAlertText(n, one, alert.forWinner || 0);
default:
return null;
}
diff --git a/web-mobile/js/bracket.jsx b/web-mobile/js/bracket.jsx
index a6480dc12..8fb50d809 100644
--- a/web-mobile/js/bracket.jsx
+++ b/web-mobile/js/bracket.jsx
@@ -17,7 +17,14 @@ import { barredSides } from './ineligible_match.jsx';
import { BarredChip } from './barred_chip.jsx';
import { realIppons, enchoOn, defaultWinMaru } from './result_slot.jsx';
import { sameCompetitor } from './competitor_identity.jsx';
+// The result-mark placement helpers live in the side_marks.jsx leaf, which the
+// court console imports without reaching this module (its render suite stubs
+// window.BracketTree, which this module's body reassigns). This file imports
+// them for its own use, keeps their `window.*` lines and re-exports the names,
+// so every importer of bracket.jsx and every window reader is unchanged.
+import { joinSp, placeMarks, sideMarks, winnerSideLR, teamMatchMarks } from './side_marks.jsx';
import { NumberedName } from './numbered_name.jsx';
+import { WinnerTick } from './side_cell.jsx';
import { creditedBoutSide, isTeamDefaultWinDecision } from './team_default_credit.jsx';
import { matchShowsScore } from './match_shows_score.jsx';
@@ -33,7 +40,6 @@ function TermBC(props) {
// Local hikiwake check: bracket.jsx is tested in isolation, so we don't rely
// on window.isHikiwake here. See specs/openapi.yaml.
function isHikiwakeBC(v) { return v === "hikiwake"; }
-function isKikenDecisionBC(v) { return v === "kiken" || v === "kiken-voluntary" || v === "kiken-injury"; }
// roundLabelFromEnd: the ONE mapping from "rounds still to come after this one"
// to a round NAME. 0 = Final, 1 = Semifinals, 2 = Quarterfinals, then the
@@ -206,26 +212,6 @@ function middleMark(decision, encho) {
return enchoLabel(encho);
}
-// joinSp: join a score fragment and a result mark with a space, skipping
-// empties ("M" + "Ht" → "M Ht", "" + "Kiken" → "Kiken"). The JS twin of
-// domain.JoinNonEmpty in internal/domain/result_marks.go.
-const joinSp = (a, b) => [a, b].filter(Boolean).join(" ");
-
-// placeMarks: resolve sideMarks onto the two display slots — the winner's
-// mark rides the winning side, the loser's the other. When neither slot is
-// known to have won, no marks are placed; each caller owns that fallback
-// (score strings trail the marks, match cards drop them). The JS analogue
-// of domain.SideMarksAB (internal/domain/result_marks.go), whose marks
-// export.SideMarksLR places White-left on the sheet.
-// Companion rule: on the two-slot GRID surfaces (the shared scoreboard and the
-// team score editor) which of a side's two cells the mark takes is answered by
-// 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.
-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
// score.type, so both sources count).
@@ -264,86 +250,6 @@ function matchMiddleMark(match) {
return mid === "vs" ? "" : mid;
}
-// sideMarks: the per-side RESULT marks. winner goes in the winning side's
-// score cell, loser in the losing side's — the mark names its competitor:
-// 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 → winner "Fus." (the default WIN names the present side)
-// Mirrors domain.SideMarks (internal/domain/result_marks.go) 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.";
- 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
-// id/no-id pair that sameCompetitor refuses to guess on). Accepts both
-// object sides ({id, name}) and bare name strings (routed through
-// sameCompetitor, competitor_identity.jsx -- the one owner of the id/name
-// attribution rule).
-function winnerSideLR(m) {
- if (!m || !m.winner) return null;
- if (sameCompetitor(m.winner, m.sideB)) return "left";
- if (sameCompetitor(m.winner, m.sideA)) return "right";
- return null;
-}
-
// Format ippons as a readable score string: ["M","K"] → "MK", [] → ""
// Returns something like "MM vs K", "M (E) –", "M X K", "X", "BYE".
//
@@ -518,7 +424,7 @@ const PlayerLine = React.memo(({ player, isWinner, side, showDojo, score, isTBD,
- {isWinner ? ✓ : null}
+ {isWinner ? : null}
{m2 ? {m2} : null}
@@ -1503,9 +1409,11 @@ window.sideLabel = sideLabel;
// 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).
+// VSchedItem/TWMatch/PoolNumberedMatchRow and the viewer/display bundles --
+// none of which ES-import bracket.jsx (see those files' own window.matchScoreStr
+// / window.boutMiddle usage for the pre-existing pattern this follows). The
+// court console and the admin Scores row import the same helpers from
+// side_marks.jsx instead, where they are defined.
window.sideMarks = sideMarks;
window.placeMarks = placeMarks;
window.teamMatchMarks = teamMatchMarks;
diff --git a/web-mobile/js/lineup_resolver.jsx b/web-mobile/js/lineup_resolver.jsx
index 1c136bb93..c64c9880d 100644
--- a/web-mobile/js/lineup_resolver.jsx
+++ b/web-mobile/js/lineup_resolver.jsx
@@ -225,6 +225,42 @@ export function mergeLineupIdsForPosition(existingIds, posKey, resolvedId) {
return updated;
}
+// resolveTypedMemberName: the one step that resolves a name typed into a team
+// slot to its squad member, through resolveMemberIdsForPositions (admin_lineup.jsx,
+// reached via window.AdminLineupHelpers). `key` is the name's key in the
+// resolver's map: a lineup position's posKey, or the representative of a
+// daihyosen row (bc-dhrp). A lineup position's typed name (buildInlineLineupWrite)
+// and a representative's typed name (submitRepresentative) both resolve here, so
+// they resolve the same way. Answers { squad, memberId, failures, resolverMissing }. A resolver that is
+// missing or throws answers no member, with resolverMissing set when it was
+// missing, and the caller goes on without the member: the NAME is the
+// load-bearing half of a slot and the id an enhancement over it, so an
+// identity failure must never block the write (an operator swapping a
+// fighter mid-encounter keeps the swap). The caller reports the members-
+// unavailable warning, which memberIdentityWarning composes.
+export async function resolveTypedMemberName(compId, teamId, key, value, squad, password, currentIds) {
+ const answer = { squad: Array.isArray(squad) ? squad : [], memberId: null, failures: [], resolverMissing: false };
+ try {
+ const resolver = window.AdminLineupHelpers?.resolveMemberIdsForPositions;
+ if (typeof resolver !== "function") {
+ answer.resolverMissing = true;
+ } else {
+ // currentIds: a name typed into a slot PICKED by number (its member id
+ // already set on this key) renames that same member rather than falling
+ // back to the position's index default -- see resolveMemberIdsForPositions'
+ // own doc comment.
+ const resolved = await resolver(compId, teamId, { [key]: value }, squad, password, currentIds);
+ answer.squad = resolved.squad;
+ answer.memberId = resolved.memberIds[key] || null;
+ answer.failures = resolved.failures || [];
+ }
+ } catch (_e) {
+ // The helper's own per-position mint catch sits beneath this one, and the
+ // write still proceeds: see the note above.
+ }
+ return answer;
+}
+
// buildInlineLineupWrite computes exactly what the inline lineup picker
// (submitInlineLineup, inside TeamScoreEditorModal in admin_scoring_team.jsx)
// writes with putMatchLineup: the one position it changes, `changed: [posKey]`
@@ -293,28 +329,12 @@ export async function buildInlineLineupWrite(compId, teamId, lineup, squad, posK
let nextSquad = Array.isArray(squad) ? squad : [];
let failures = [];
if (!pickedMemberId && value) {
- try {
- const resolver = window.AdminLineupHelpers?.resolveMemberIdsForPositions;
- if (typeof resolver === "function") {
- // currentIds: a name typed into a slot PICKED by number (its
- // memberId already set on this position) renames that same member
- // rather than falling back to the position's index default -- see
- // resolveMemberIdsForPositions' own doc comment.
- const resolved = await resolver(compId, teamId, { [posKey]: value }, squad, password, lineup?.memberIds);
- nextSquad = resolved.squad;
- resolvedId = resolved.memberIds[posKey] || null;
- failures = resolved.failures || [];
- }
- } catch (_e) {
- // On top of the helper's own per-position mint catch, because this
- // runs on the live scoring path: an operator swapping a fighter
- // mid-encounter must not lose the swap because identity resolution
- // failed. The NAME is the load-bearing half of a lineup slot and the
- // id is an enhancement over it, so the write proceeds either way;
- // mergeLineupIdsForPosition below clears this position's id, the same
- // as any other unresolved name, and the load-time repair fills it in
- // later from the squad.
- }
+ // A failed resolve still writes the name: mergeLineupIdsForPosition below
+ // clears this position's id, and the load-time repair fills it in later.
+ const typed = await resolveTypedMemberName(compId, teamId, posKey, value, squad, password, lineup?.memberIds);
+ nextSquad = typed.squad;
+ resolvedId = typed.memberId;
+ failures = typed.failures;
}
const otherPosKey = memberPlacedElsewhere(lineup?.memberIds, posKey, resolvedId);
@@ -515,9 +535,9 @@ export function lineupPositionLabel(posKey) {
// there so placeHt can put the hantei mark on the winner's side, and blanking
// them drops the mark from the wire. Filtering them here blanked both of that
// row's name cells, and the rep bout is the ONE row with no fallback label
-// (its lineup key is the literal "daihyosen", which no lineup carries, and it
-// offers no roster or typeable box), so the editor printed "-" against both
-// teams where the two names used to be.
+// (its lineup key is the literal "daihyosen", which no lineup carries; its
+// fighters are the member ids the operator picked on the row, bc-dhrp), so the
+// editor printed "-" against both teams where the two names used to be.
export function resolveBoutSideName({ isKachinuki, isDaihyosen, existingName, lineupName, teamNameA, teamNameB }) {
const namesATeam = existingName === teamNameA || existingName === teamNameB;
const stored = namesATeam && !isDaihyosen ? "" : existingName;
@@ -529,7 +549,8 @@ export function resolveBoutSideName({ isKachinuki, isDaihyosen, existingName, li
// bootstrap (index 0), the lineup position no longer identifies who fights
// (winner-stays advancement determines the pairing), so display surfaces must
// suppress the position-lineup name and fall back to the bout number. The
-// daihyosen (isDaihyosen) is an operator-chosen rep bout and stays lineup-first.
+// daihyosen (isDaihyosen) is an operator-chosen rep bout, not a lineup position,
+// and is never hidden.
// Shared by the display surfaces (match_scoreboard, streaming_overlay) so the
// bootstrap rule lives in one place.
export function kachinukiHidesLineupPosition(isKachinuki, isDaihyosen, index) {
@@ -576,14 +597,18 @@ export function pickMemberIdFromLineup(lineup, index, teamSize) {
// numbered bout's pairing is server-bout-log first, the SubMatchResult's own
// sideAMemberId/sideBMemberId -- backfilled from team-members.yaml by the
// legacy-upgrade repair -- so the lineup position's id must never outrank
-// it; fixed-format and the daihyosen row stay lineup-first). Callers pass
+// it; fixed-format rows stay lineup-first). The representative bout
+// (isDaihyosen) is the one row whose fighter is the operator's own pick,
+// recorded on that row (bc-dhrp), so it reads only that row's id and never a
+// lineup position. Callers pass
// existingMemberId/lineupMemberId from the SAME existing/lineup objects
// resolveBoutSideName was given for the SAME row, and must independently
// block an operator's free-typed override (which has no valid lineup key to
// resolve an id from at all) before ever reaching this function -- see
// admin_scoring_team.jsx's playerNamesForBout for that guard.
export function resolveBoutSideMemberId({ isKachinuki, isDaihyosen, existingMemberId, lineupMemberId }) {
- if (isKachinuki && !isDaihyosen) return existingMemberId || lineupMemberId || "";
+ if (isDaihyosen) return existingMemberId || "";
+ if (isKachinuki) return existingMemberId || lineupMemberId || "";
return lineupMemberId || existingMemberId || "";
}
diff --git a/web-mobile/js/match_groups.jsx b/web-mobile/js/match_groups.jsx
index b58f83258..438e6c948 100644
--- a/web-mobile/js/match_groups.jsx
+++ b/web-mobile/js/match_groups.jsx
@@ -3,8 +3,8 @@
// events must be ordered.").
//
// The server merges a score write group by group (internal/state/
-// match_groups.go): points, result, encho, flags, rep, and one group per bout
-// row, "bout:". A write names the groups it changes in `changed`;
+// match_groups.go): points, result, encho, flags, rep, repPickA, repPickB, and one group
+// per bout row, "bout:". A write names the groups it changes in `changed`;
// each named group applies only when the write is not older than that group's
// stored change, a group the write does not name is never overwritten, and a
// change that is not applied is kept in the match's history. So a client write
@@ -27,7 +27,7 @@
import { IPPON_PLACEHOLDER } from './result_slot.jsx';
import {
NEEDS_WINNER_NOTE, DEFAULT_WIN_STANDS_NOTE, writeNeedsWinner, writeDefaultWinStands, writeHeldDecision,
- writePartlyHeld, writeHeldGroups, writeDisplacedGroups,
+ writePartlyHeld, writeHeldGroups, writeDisplacedGroups, writeDisplacedForWinner,
} from './write_result.jsx';
export const GROUP_POINTS = 'points';
@@ -35,9 +35,15 @@ export const GROUP_RESULT = 'result';
export const GROUP_ENCHO = 'encho';
export const GROUP_FLAGS = 'flags';
export const GROUP_REP = 'rep';
+// The two representatives of the representative bout (the member ids on its -1
+// row), one group per side: each is a change of its own, dated apart from the
+// other side's and from "bout:-1", which keeps the bout's score and result. Not
+// to be confused with GROUP_REP, the pool daihyosen/tiebreaker players.
+export const GROUP_REP_PICK_A = 'repPickA';
+export const GROUP_REP_PICK_B = 'repPickB';
// The scalar groups in the server's fixed order (state.ScalarGroups).
-export const SCALAR_GROUPS = [GROUP_POINTS, GROUP_RESULT, GROUP_ENCHO, GROUP_FLAGS, GROUP_REP];
+export const SCALAR_GROUPS = [GROUP_POINTS, GROUP_RESULT, GROUP_ENCHO, GROUP_FLAGS, GROUP_REP, GROUP_REP_PICK_A, GROUP_REP_PICK_B];
const BOUT_PREFIX = 'bout:';
@@ -81,6 +87,8 @@ export function groupLabel(group) {
case GROUP_ENCHO: return 'overtime';
case GROUP_FLAGS: return 'flags';
case GROUP_REP: return 'the representative players';
+ case GROUP_REP_PICK_A: return 'Aka\'s pick for the representative bout';
+ case GROUP_REP_PICK_B: return 'Shiro\'s pick for the representative bout';
default: {
const pos = parseBoutGroup(group);
if (pos === null) return String(group || '');
@@ -110,26 +118,33 @@ export function heldGroupsNote(groups, needsWinner = false, defaultWinStands = f
}
// displacedGroupsNote: the note for a write that WAS recorded and moved a
-// later change of the match to its history, because that change would have
-// left the finished match without a winner (write_result.jsx
-// writeDisplacedGroups). null when nothing was moved.
-export function displacedGroupsNote(groups) {
+// later change of the match to its history (write_result.jsx
+// writeDisplacedGroups). `needsWinner` (writeDisplacedForWinner) when the
+// answer says why with heldReason "needs_winner": the change would have left
+// the finished match without a winner. A change moved for another reason (a representative's pick
+// stamped after the representative bout's removal, which leaves it nothing to
+// stand on) carries no such reason and is worded plainly, never with a winner
+// the match does not lack. null when nothing was moved.
+export function displacedGroupsNote(groups, needsWinner = false) {
const words = groupsLabel(groups);
if (!words) return null;
- return `Saved. A later change to ${words} would have left the finished match without a winner, so it was moved to the match's history.`;
+ return needsWinner
+ ? `Saved. A later change to ${words} would have left the finished match without a winner, so it was moved to the match's history.`
+ : `Saved. A later change to ${words} was moved to the match's history.`;
}
// keptInHistoryNote: the ONE note a score editor (useKeptInHistoryNote) or
// its closing host (admin.jsx's toast) shows for what a write's answer kept
// in the match's history: this write's own groups held (applied in part, or
// superseded because the finished match needs a winner), and/or later
-// changes it moved there. null when the answer kept nothing.
+// changes it moved there (worded by the reason the answer gives, if any).
+// null when the answer kept nothing.
export function keptInHistoryNote(res) {
const parts = [];
if (writePartlyHeld(res) || writeNeedsWinner(res) || writeDefaultWinStands(res)) {
parts.push(heldGroupsNote(writeHeldGroups(res), writeNeedsWinner(res), writeDefaultWinStands(res), writeHeldDecision(res)));
}
- parts.push(displacedGroupsNote(writeDisplacedGroups(res)));
+ parts.push(displacedGroupsNote(writeDisplacedGroups(res), writeDisplacedForWinner(res)));
const text = parts.filter(Boolean).join(' ');
return text || null;
}
@@ -184,6 +199,10 @@ function statedGroups(next) {
out.push(GROUP_ENCHO);
if (has(next, 'flagsA') || has(next, 'flagsB')) out.push(GROUP_FLAGS);
if (has(next, 'repPlayerA') || has(next, 'repPlayerB')) out.push(GROUP_REP);
+ // The representatives sit on the representative bout's row: a write that
+ // carries that row states both sides' picks (an id left off the row is that
+ // side's pick cleared).
+ if (rowAt(next, -1)) out.push(GROUP_REP_PICK_A, GROUP_REP_PICK_B);
if (Array.isArray(next.subResults)) {
for (const s of next.subResults) {
const g = s ? boutGroup(num(s.position)) : null;
@@ -220,12 +239,43 @@ function groupKey(wire, group, next) {
has(next, 'repPlayerA') ? str(w.repPlayerA) : null,
has(next, 'repPlayerB') ? str(w.repPlayerB) : null,
]);
+ case GROUP_REP_PICK_A: {
+ // Each side's pick is a change of its own, so its value is that side's
+ // id alone. An absent id reads as empty: a pick cleared leaves its key
+ // out, and must still be named.
+ const row = rowAt(w, -1) || {};
+ return str(row.sideAMemberId);
+ }
+ case GROUP_REP_PICK_B: {
+ const row = rowAt(w, -1) || {};
+ return str(row.sideBMemberId);
+ }
default: {
const pos = parseBoutGroup(group);
const own = rowAt(next, pos);
const keys = new Set(Object.keys(own || {}).filter((k) => own[k] !== undefined));
keys.add('encho');
keys.add('position');
+ // On the representative bout the two picks are GROUP_REP_PICK_A's and
+ // GROUP_REP_PICK_B's (the server copies the bout without them), so a
+ // pick never reads as a change of the bout. The winner's member id is
+ // re-derived by the server from the bout's winner side and the stored
+ // picks (ReconcileWinnerMemberID), so a pick change moves it with no
+ // change to the bout: it is left out too, and the winner's name is what
+ // names the bout. The row's side names are the MATCH's sides: the server
+ // stamps the team names on the row at the add and keeps them equal to the
+ // match's on every re-seat, and the editor restates them so the hantei mark
+ // can be placed on the winner's side. They are never a change of the bout
+ // either, or a pick on a bout nobody has scored would name it and stamp its
+ // scoreline.
+ // Numbered rows keep the rule above.
+ if (pos < 0) {
+ keys.delete('sideAMemberId');
+ keys.delete('sideBMemberId');
+ keys.delete('winnerMemberId');
+ keys.delete('sideA');
+ keys.delete('sideB');
+ }
// A row the baseline does not hold reads as an empty one, so an
// untouched blank bout is not a change.
return JSON.stringify(rowProjection(rowAt(w, pos) || { position: pos }, [...keys].sort()));
diff --git a/web-mobile/js/match_history_view.jsx b/web-mobile/js/match_history_view.jsx
index 94f66744c..d1e943a75 100644
--- a/web-mobile/js/match_history_view.jsx
+++ b/web-mobile/js/match_history_view.jsx
@@ -24,7 +24,7 @@ import { formatIpponsScore } from './bracket.jsx';
import { hanteiDecided } from './result_slot.jsx';
import { withdrawalLabel } from './admin_scoring_shared.jsx';
import { sideWord } from './side_cell.jsx';
-import { groupLabel, parseBoutGroup, GROUP_POINTS, GROUP_RESULT, GROUP_ENCHO, GROUP_FLAGS, GROUP_REP } from './match_groups.jsx';
+import { groupLabel, parseBoutGroup, GROUP_POINTS, GROUP_RESULT, GROUP_ENCHO, GROUP_FLAGS, GROUP_REP, GROUP_REP_PICK_A, GROUP_REP_PICK_B } from './match_groups.jsx';
const { useState: useStateH, useEffect: useEffectH, useRef: useRefH } = React;
@@ -45,10 +45,14 @@ const DOOR_WORDS = {
'kachinuki-advance': 'Next kachinuki bout added',
'kachinuki-remove-bout': 'Kachinuki bout removed',
'downstream-reopen': 'Reopened by a correction to an earlier match',
- // A change already recorded that an earlier-made finish, arriving later,
- // moved out of the match because it would have left the finished match
- // without a winner (the entry carries that change's own time).
+ // A change already recorded that a write arriving later moved out of the
+ // match: either an earlier-made finish whose later change would have left the
+ // finished match without a winner, or a representative pick made after its
+ // representative bout was removed (the entry carries that change's own time).
'displaced': 'Later change moved to history',
+ // A match that was not reopened but had a side seated again by a correction to an
+ // earlier match (a feeder's winner changed), for a line of its own.
+ 'reseat': 'Side given another team by a correction to an earlier match',
};
export function historyDoorWords(door) {
@@ -95,8 +99,20 @@ function resultText(v) {
return 'finished with no winner';
}
+// pickText: a representative pick is a member id, which says nothing to the operator by
+// itself. `resolve(side, memberId)` is the editor's (the team editor holds both teams'
+// members; the others pass none): a label for the member, null for an id the side's team
+// does not hold now (a correction seated another team), undefined when the editor has no
+// members to look in. Then the line says only that the side was picked.
+function pickText(memberId, side, resolve) {
+ if (!memberId) return 'not picked';
+ const who = typeof resolve === 'function' ? resolve(side, memberId) : undefined;
+ if (who === null) return 'picked (not on the team now)';
+ return who ? `picked ${who}` : 'picked';
+}
+
// heldValueText: a short readable form of a change kept in the history.
-export function heldValueText(group, value) {
+export function heldValueText(group, value, resolve) {
const v = value && typeof value === 'object' ? value : null;
switch (group) {
case GROUP_POINTS:
@@ -112,14 +128,20 @@ export function heldValueText(group, value) {
return v ? `flags Shiro ${Number(v.flagsB) || 0}, Aka ${Number(v.flagsA) || 0}` : 'no flags';
case GROUP_REP:
return v ? `Shiro ${v.repPlayerB || 'not picked'}, Aka ${v.repPlayerA || 'not picked'}` : 'not picked';
+ case GROUP_REP_PICK_A:
+ // A = Aka = the match's side A; the id belongs to that side's team.
+ return pickText(v && v.sideAMemberId, 'a', resolve);
+ case GROUP_REP_PICK_B:
+ return pickText(v && v.sideBMemberId, 'b', resolve);
default:
if (parseBoutGroup(group) === null) return '';
return v ? `${scorelineText(v)}${foulsText(v)}` : 'no bout';
}
}
-// historyEntryView: one history entry as the lines the disclosure shows.
-export function historyEntryView(entry) {
+// historyEntryView: one history entry as the lines the disclosure shows. `resolve` is
+// the editor's member resolver for a representative pick (pickText); optional.
+export function historyEntryView(entry, resolve) {
const changed = (entry.changed || []).map(groupLabel).filter(Boolean);
// A line per group the server recorded as HELD, and only those: an echo
// it recorded as unchanged (the value already stored) never shows, even
@@ -131,7 +153,7 @@ export function historyEntryView(entry) {
const keptWord = moved ? 'Moved to history' : 'Kept in history';
const held = Object.keys(entry.held || {}).filter((g) => outcomes[g] === 'held').map((g) => ({
group: g,
- text: `${keptWord}: ${groupLabel(g)}, ${heldValueText(g, entry.held[g])}`,
+ text: `${keptWord}: ${groupLabel(g)}, ${heldValueText(g, entry.held[g], resolve)}`,
}));
const cleared = entry.clearedWithdrawal
? `Withdrawal cleared by later scoring: ${resultText(entry.clearedWithdrawal)}`
@@ -150,7 +172,10 @@ export function historyEntryView(entry) {
};
}
-export function MatchHistoryDisclosure({ match, password, hidden = false }) {
+// `members`, optional: the editor's resolver from (side, memberId) to the representative's
+// name (see pickText). Only the team editor holds the members, so the others pass none
+// and a pick reads as it always did.
+export function MatchHistoryDisclosure({ match, password, hidden = false, members }) {
const [open, setOpen] = useStateH(false);
const [state, setState] = useStateH({ key: '', loading: false, error: '', entries: null });
const mountedRef = useRefH(true);
@@ -203,7 +228,7 @@ export function MatchHistoryDisclosure({ match, password, hidden = false }) {
{!error && entries && entries.length > 0 && (
{entries.map((e, i) => {
- const v = historyEntryView(e);
+ const v = historyEntryView(e, members);
// An entry has no id. The history is append-only and read
// whole, oldest first, so its position is stable; the
// rest only helps React tell a reload's entries apart.
diff --git a/web-mobile/js/side_cell.jsx b/web-mobile/js/side_cell.jsx
index d58fa6952..fc243574c 100644
--- a/web-mobile/js/side_cell.jsx
+++ b/web-mobile/js/side_cell.jsx
@@ -55,6 +55,21 @@ export function SideLabel({ side }) {
return {sideWord(side)}: ;
}
+// The winner's tick beside the winning side's name. The glyph is decorative and
+// hidden from assistive technology; the sr-only word carries the fact, for the
+// same reason SideLabel is a span rather than an aria-label. The words follow
+// the tick's place in the row: "Winner: " before the name, ", winner" when the
+// tick sits after it (`trailing`, Aka on Recent results), so the row is spoken
+// "Aka: Tanaka, winner" and not "Aka: Tanaka Winner:".
+export function WinnerTick({ trailing = false }) {
+ return (
+
+ ✓
+ {trailing ? ", winner" : "Winner: "}
+
+ );
+}
+
// A tinted side cell.
//
// side "shiro" | "aka". The one place the surface states it.
diff --git a/web-mobile/js/side_marks.jsx b/web-mobile/js/side_marks.jsx
new file mode 100644
index 000000000..7fbf80208
--- /dev/null
+++ b/web-mobile/js/side_marks.jsx
@@ -0,0 +1,126 @@
+// side_marks.jsx: the RESULT-mark placement helpers (Kiken, Fus., Ht beside the
+// competitor they name) and the winner-side lookup they hang off. Leaf module:
+// its one import is competitor_identity.jsx, itself a leaf, so a surface that
+// must not reach bracket.jsx can still place a mark.
+//
+// WHY THIS IS NOT IN bracket.jsx. The court console (admin_shiaijo.jsx) places a
+// completed row's winner cue and a withdrawn team's mark, and it cannot import
+// bracket.jsx: its render suite stubs window.BracketTree before importing the
+// console, the console reads that global at module eval, and bracket.jsx
+// reassigns window.BracketTree in its own module body. An import's dependencies
+// evaluate FIRST, so a path from the console to bracket.jsx would overwrite the
+// stub (the console's header states the rule). The helpers used to be read off
+// `window` there, behind a guard that painted nothing when the global was
+// missing; they are imported from here instead. side_marks_leaf.test.jsx pins
+// the one-import rule.
+//
+// bracket.jsx imports these six, keeps every `window.X = X` line (the display
+// and viewer bundles read them) and re-exports the names, so every importer of
+// bracket.jsx keeps working.
+
+import { sameCompetitor } from './competitor_identity.jsx';
+
+// Local kiken check, so this leaf relies on no window global (the twin of
+// bracket.jsx's isHikiwakeBC). The kiken family is the wire's `kiken`,
+// `kiken-voluntary` and `kiken-injury`: see specs/openapi.yaml.
+export function isKikenDecisionBC(v) { return v === "kiken" || v === "kiken-voluntary" || v === "kiken-injury"; }
+
+// joinSp: join a score fragment and a result mark with a space, skipping
+// empties ("M" + "Ht" → "M Ht", "" + "Kiken" → "Kiken"). The JS twin of
+// domain.JoinNonEmpty in internal/domain/result_marks.go.
+export const joinSp = (a, b) => [a, b].filter(Boolean).join(" ");
+
+// placeMarks: resolve sideMarks onto the two display slots — the winner's
+// mark rides the winning side, the loser's the other. When neither slot is
+// known to have won, no marks are placed; each caller owns that fallback
+// (score strings trail the marks, match cards drop them). The JS analogue
+// of domain.SideMarksAB (internal/domain/result_marks.go), whose marks
+// export.SideMarksLR places White-left on the sheet.
+// Companion rule: on the two-slot GRID surfaces (the shared scoreboard and the
+// team score editor) which of a side's two cells the mark takes is answered by
+// 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.
+export function placeMarks(marks, firstWins, secondWins) {
+ return firstWins ? [marks.winner, marks.loser] : secondWins ? [marks.loser, marks.winner] : ["", ""];
+}
+
+// sideMarks: the per-side RESULT marks. winner goes in the winning side's
+// score cell, loser in the losing side's — the mark names its competitor:
+// 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 → winner "Fus." (the default WIN names the present side)
+// Mirrors domain.SideMarks (internal/domain/result_marks.go) 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.
+export function sideMarks(decision, decidedByHantei) {
+ let winner = "", loser = "";
+ if (isKikenDecisionBC(decision)) loser = "Kiken";
+ else if (decision === "fusenpai") loser = "Fus.";
+ 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 { shiro: "", aka: "" } for a non-team row, a not-yet-completed
+// match, or a decision sideMarks has nothing to say about (the same empty
+// marks as the individual case).
+export 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
+// id/no-id pair that sameCompetitor refuses to guess on). Accepts both
+// object sides ({id, name}) and bare name strings (routed through
+// sameCompetitor, competitor_identity.jsx -- the one owner of the id/name
+// attribution rule).
+export function winnerSideLR(m) {
+ if (!m || !m.winner) return null;
+ if (sameCompetitor(m.winner, m.sideB)) return "left";
+ if (sameCompetitor(m.winner, m.sideA)) return "right";
+ return null;
+}
diff --git a/web-mobile/js/start_match.jsx b/web-mobile/js/start_match.jsx
new file mode 100644
index 000000000..a1d5c3283
--- /dev/null
+++ b/web-mobile/js/start_match.jsx
@@ -0,0 +1,200 @@
+// start_match.jsx: the ONE owner of how a match START is written and of what
+// the answer to it means (bc-aadv, PR #463 round 13).
+//
+// Two hosts start a match on the operator's behalf: the court console
+// (admin_shiaijo.jsx startMatch: the Up next card, a queue row, Finish + Start
+// Next and the start after a decision) and the Scores tab
+// (admin_schedule_score_editor.jsx: the same two automatic advances). The
+// console read the answer and the Scores tab did not, so a start the server
+// refused with HTTP 200 {applied:false, reason:'clock_skew'} was taken for one
+// that landed there, a thrown 409 (eligibility, court_busy, already_ineligible)
+// was swallowed, and nothing stopped a second start while one was out. The
+// three rules below are therefore asked here, once, and neither host restates
+// them:
+//
+// - startPatch(): the write itself.
+// - classifyStartOutcome(res) / startFailureMessage(err): what an answer or a
+// thrown error means, in the operator's words.
+// - createStartGuard(): one start at a time.
+//
+// Each host keeps its own STATE (the console's startError/refusedTap and its
+// startingRef/pickingRef pair, whose pick exemption is the console's alone; the
+// Scores tab's startRefusal) and its own surface for the sentence. This leaf
+// holds no state of its own beyond the guard object a host creates.
+//
+// A leaf: write_result.jsx (the answer predicates and the clock-skew and
+// superseded words) and
+// match_groups.jsx (the group name startPatch writes, itself a near leaf over
+// write_result.jsx and result_slot.jsx). Neither host's module is imported
+// here, and both import this one directly.
+
+import {
+ writeWasRefusedForClock, writeWasSuperseded, writeHeldGroups, notSavedText,
+ CLOCK_SKEW_REASON_TEXT, SUPERSEDED_REASON, SUPERSEDED_ADVICE,
+} from './write_result.jsx';
+import { GROUP_RESULT } from './match_groups.jsx';
+
+// Minimal "start" patch (status -> running, empty score). Mirrors the editors'
+// own buildPatch("running") for an unscored match and works for both individual
+// and team matches (subResults is omitted, which the serializer treats as "no
+// bouts scored yet"). The server routes it through eng.StartMatchTx, so all
+// start-gating (eligibility 409, court_busy, the players check) still runs: a
+// 409 throws and the caller reports it.
+//
+// startOnly (bc-sbq): this write only starts the match, so the server keeps the
+// score the match already holds (a match sent back to the queue keeps one);
+// toBackendMatchResult leaves the empty scoreline below off the wire. The
+// editors' own Start sends their board unflagged, so an operator who cleared
+// every mark still clears it.
+//
+// bc-mrgc: a start changes the status and nothing else, so it names the result
+// group alone (match_groups.jsx): the server keeps every other group of the
+// match as stored, the score a send-back kept included.
+//
+// The court console and the Scores tab both import it from here; it is not
+// published on `window`.
+export function startPatch() {
+ return {
+ startOnly: true,
+ changed: [GROUP_RESULT],
+ status: "running", winner: null, ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0,
+ score: { type: "ippon", winnerPts: 0, loserPts: 0, ippons: [], fouls: { a: 0, b: 0 }, live: true, corrected: false },
+ };
+}
+
+// The sentence for a start the server refused for the device's clock. A
+// clock_skew refusal means the server stored NOTHING and, unlike a queued
+// start, nothing will land later; the relearn it triggers means a second tap
+// normally succeeds, so the sentence says that instead of leaving a dead first
+// tap.
+export const START_CLOCK_SKEW_MESSAGE = "Could not start: " + CLOCK_SKEW_REASON_TEXT + ". The clock has been resynced; try again.";
+
+// The sentence for a start the server held as superseded WITH a held group (HTTP
+// 200 applied:false, reason superseded, heldGroups ['result']): a different
+// result is already stored (a send-back, a correction), so nothing of the start
+// was written and the match is not running. Unlike the clock sentence above it
+// never says "try again": the opposite is true, the operator must look at the
+// match first, since starting it again blind is exactly what that newer change
+// may have made wrong. Its words are write_result.jsx's (SUPERSEDED_REASON and
+// SUPERSEDED_ADVICE, composed by notSavedText as for every superseded write),
+// under this file's own "Not started" lead; they read, via notSavedText:
+// "Not started: a newer change to the same thing was recorded first, so this one
+// was kept in the match's history and nothing is lost. Check the match and its
+// history before entering anything again: entering it again would replace the
+// newer change." A superseded start with NO held group is not this sentence at
+// all: see classifyStartOutcome.
+export const START_SUPERSEDED_MESSAGE = notSavedText({ lead: 'Not started', reason: SUPERSEDED_REASON, advice: SUPERSEDED_ADVICE });
+
+// The sentence for a thrown start that carries none of its own.
+export const START_FAILED_MESSAGE = "Could not start the match: check eligibility and try again.";
+
+// classifyStartOutcome: what did a start write come back with?
+// { ok: true } the start landed, or was QUEUED (a queued start lands
+// on reconnect, so the host treats it as started), or
+// came back without a body, or was held as an ECHO of a
+// start that already landed (below).
+// { ok: false, msg } the start was REFUSED and nothing will land later: a
+// host that called it started would pin a panel on a
+// match that never started while the tap looked like it
+// worked. Two answers are refusals, each in its own
+// words:
+// - clock_skew: the device's clock was out of step;
+// the relearn it triggers means a second tap
+// normally succeeds (START_CLOCK_SKEW_MESSAGE).
+// - superseded WITH a held group: a newer change to
+// the match's result is already stored, so nothing
+// of the start was written (START_SUPERSEDED_MESSAGE).
+// The sentence never invites a retry, since the
+// operator must look at the match first.
+// Both hosts take a refusal the same way (store it on the refused match, toast
+// it once, return not-started), so a superseded start needs no branch of its own.
+// A start that THROWS (a 409) is not an answer at all; startFailureMessage
+// words it.
+//
+// Why a superseded answer is two cases (PR #463 round 19). A start names the
+// `result` group alone (startOnly, startPatch's `changed`), and the server lists
+// a held group in `heldGroups` only when its incoming value DIFFERS from the
+// stored one: an equal one is a HeldEcho, neither listed nor kept (reportHeld,
+// match_merge.go), and respondSuperseded omits `heldGroups` when it would be
+// empty. A start does carry rev/revSession like every running payload
+// (api_client.jsx stamps them), and the rev guard (holdOlderRevision ->
+// holdWriteTx, holdAll) holds through reportHeld too, so an echo is an echo-hold
+// there as well. So:
+// - NO heldGroups: the stored result already equals the start. Another
+// device's start of this same match landed first with a later stamp, i.e. the
+// match IS running. The start went out, as far as this device is concerned:
+// a refusal would say "Not started" about a live match, a pick would not pin
+// the panel on it, and the Scores tab would clear a waiting refusal ("was
+// being started") that was accurate.
+// - heldGroups ['result']: a different result is stored, so the match is not
+// running and the start is refused.
+// Residual, not fixed: the `result` group's projection also holds
+// CorrectionReason and ResultSource, so a start over a match that is running under
+// a different source or reason answers heldGroups ['result'] and shows the refused
+// sentence while the match is running. The stored notice drops once the live data
+// shows it running (startRefusalStands); only the toast is left, and it is
+// wrong for that one case.
+export function classifyStartOutcome(res) {
+ if (writeWasRefusedForClock(res)) return { ok: false, msg: START_CLOCK_SKEW_MESSAGE };
+ if (writeWasSuperseded(res) && writeHeldGroups(res).length > 0) return { ok: false, msg: START_SUPERSEDED_MESSAGE };
+ return { ok: true };
+}
+
+// startFailureMessage: the sentence for a start that threw. The server's own
+// words when it sent any ("Alice withdrew in Pool A · Match 1 and cannot
+// fight again. ..."), else one fallback.
+export function startFailureMessage(err) {
+ return (err && err.message) || START_FAILED_MESSAGE;
+}
+
+// startRefusalStands: does a stored Start refusal still hold? A refusal is about
+// one match at one moment, so it falls when its row has left `scheduled`, or
+// when a list that holds matches no longer holds it (the match moved), and a
+// stale one never revives when that match is later sent back to the queue. An
+// EMPTY list says nothing about the match (it is empty while a feed loads and
+// across a transient empty refetch), so the refusal stands then. `row` is the
+// refused match's live row in the host's list, or null when the list lacks it;
+// `listHoldsMatches` is whether that list holds any match at all. The court
+// console and the Scores tab both ask this, so they cannot disagree on how long
+// the notice lives.
+export function startRefusalStands({ row, listHoldsMatches }) {
+ if (row) return row.status === "scheduled";
+ return !listHoldsMatches;
+}
+
+// createStartGuard: ONE start at a time. A host creates one and brackets every
+// start it makes with begin/end.
+//
+// begin(key, match) { started: true } the start may go ahead
+// { started: false, repeat: true, blocker } the SAME match is already being
+// started: it is on its way, the
+// repeat is dropped silently
+// { started: false, repeat: false, blocker } ANOTHER match is being started:
+// the host refuses this one and
+// says so (startWhileStartingMessage,
+// write_result.jsx) naming
+// blocker.match
+// end(key) frees the guard, but only for the start that holds it.
+// inFlight() the { key, match } being started, or null.
+//
+// The console keeps its own startingRef/pickingRef pair instead: a pick
+// (pickMatch) is exempt from its own guard and defers the running bout before
+// its start, which this guard has no word for. The Scores tab has no pick.
+export function createStartGuard() {
+ let current = null;
+ return {
+ begin(key, match) {
+ if (current) {
+ return { started: false, repeat: current.key === key, blocker: current };
+ }
+ current = { key, match };
+ return { started: true };
+ },
+ end(key) {
+ if (current && current.key === key) current = null;
+ },
+ inFlight() {
+ return current;
+ },
+ };
+}
diff --git a/web-mobile/js/viewer_competition.jsx b/web-mobile/js/viewer_competition.jsx
index bd7f565f7..86fbad720 100644
--- a/web-mobile/js/viewer_competition.jsx
+++ b/web-mobile/js/viewer_competition.jsx
@@ -151,8 +151,11 @@ export function ViewerCompetition({ tournament, competition, pools, poolMatches,
// answers the same question from the same rule (mp-jnvl). An unstamped
// bout still falls back to scheduled time, so a competition whose results
// predate the stamp reads exactly as it did.
+ // A completed bout with no winner is a hikiwake (drawn match), which is a
+ // result. The membership rule matches the operator's Completed list (status
+ // alone), and the order comes from result_recency.jsx.
const recent = allMatches
- .filter((m) => m.status === "completed" && m.winner && hasBothSides(m) && matchInvolvesWatched(m))
+ .filter((m) => m.status === "completed" && hasBothSides(m) && matchInvolvesWatched(m))
.sort(resultRecencyDesc)
.slice(0, hasActiveFilter ? 20 : 5);
return { runningMatches: running, upcomingMatches: upcoming, recentMatches: recent };
@@ -726,7 +729,7 @@ export function ViewerOverview({ c, myPlayer, myUpcoming, currentMatch, runningM
{recentMatches.map((m) => (
- handleMatchClick(m)} highlight={isPlayerWatched(m.sideA, highlightPlayers) || isPlayerWatched(m.sideB, highlightPlayers)} />
+ handleMatchClick(m)} highlight={isPlayerWatched(m.sideA, highlightPlayers) || isPlayerWatched(m.sideB, highlightPlayers)} winnerTick={true} />
{!isSelfRun && expandedMatchId === m.id && setExpandedMatchId(null)} />}
))}
diff --git a/web-mobile/js/viewer_match.jsx b/web-mobile/js/viewer_match.jsx
index eb12474e6..59f7421ed 100644
--- a/web-mobile/js/viewer_match.jsx
+++ b/web-mobile/js/viewer_match.jsx
@@ -13,7 +13,7 @@
// unchanged.
import { writeKeepsEditorOpen } from './write_result.jsx';
-import { SideLabel } from './side_cell.jsx';
+import { SideLabel, WinnerTick } from './side_cell.jsx';
import { useTeamLineups, TeamScoreboard, IndividualScore, numberedParts, teamNameMark } from './match_scoreboard.jsx';
import { NumberedName } from './numbered_name.jsx';
import { TermV, poolLabel } from './viewer_utils.jsx';
@@ -195,7 +195,7 @@ export function MatchDetailCard({ match, onClose, escapeToClose = true, slotLabe
// VSchedItem
// ---------------------------------------------------------------------------
-export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, highlight }) => {
+export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, highlight, winnerTick }) => {
// bc-pnum: sameCompetitor, never a bare `winner.id === side.id` (see
// bracket.jsx's MatchCard for why the naked equality lights both sides
// once both are id-less). No presence guard: sameCompetitor(null, x) is
@@ -291,7 +291,10 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig
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, ))}
+ {/* The winner tick (Recent results only) rides INSIDE the `.n` flex
+ row, at the side's outer edge: first on Shiro, last on Aka. As a
+ sibling of `.n` it took a line of its own above the name. */}
+ {winnerTick && bWin && }{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) →
@@ -319,7 +322,7 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig
)}
diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx
index 9f53d6951..3a7214608 100644
--- a/web-mobile/js/write_result.jsx
+++ b/web-mobile/js/write_result.jsx
@@ -120,15 +120,22 @@ export function supersededAlertText(n, one, needsWinner = false, defaultWinStand
// one choice of banner for a superseded write, asked by notLandedBanner and by
// api_client.jsx's not-applied broadcast.
//
-// The same reason rides on a second, APPLIED answer (writeDisplacedGroups): a
-// finish that arrived after a newer scoring change which, applied after it,
+// The same reason can ride on a second, APPLIED answer (writeDisplacedGroups):
+// a finish that arrived after a newer scoring change which, applied after it,
// would have left the match without a winner. The finish is recorded and that
// later change is MOVED to the history (`displacedGroups`, no heldGroups).
// writeNeedsWinner is therefore true only for a write whose OWN change was
-// held for the reason; a displaced answer is not one, and says "Saved". An
-// answer that both held groups and moved a later change carries the one
-// reason for both, and the client reads it as the move's: telling the
-// operator to correct a result that IS recorded would be the worse error.
+// held for the reason; a displaced answer is not one, and says "Saved".
+//
+// A change can also be moved for another reason and then carries NO
+// heldReason: a representative's pick stamped after the representative bout's
+// removal has nothing left to stand on. writeDisplacedForWinner is the one
+// question "was the move for a winner": true for heldReason needs_winner only,
+// never for "a reason is present", because the reason is per ANSWER and a
+// decision that stands (default_win_stands) can ride beside displacedGroups
+// too. An answer that both held groups and moved a later change carries the
+// one reason and is worded by it, which is the move's: telling the operator to
+// correct a result that IS recorded would be the worse error.
export const HELD_REASON_NEEDS_WINNER = 'needs_winner';
export function writeDisplacedGroups(res) {
return res && Array.isArray(res.displacedGroups) ? res.displacedGroups.filter((g) => typeof g === 'string') : [];
@@ -138,12 +145,27 @@ export function writeNeedsWinner(res) {
&& writeDisplacedGroups(res).length === 0
&& (writeWasSuperseded(res) || writeHeldGroups(res).length > 0);
}
+export function writeDisplacedForWinner(res) {
+ return !!res && res.heldReason === HELD_REASON_NEEDS_WINNER && writeDisplacedGroups(res).length > 0;
+}
// displacedAlertText: the queue alert for queued finishes that landed and
// moved a later change of the same match to its history (writeDisplacedGroups).
-export function displacedAlertText(n, one) {
- return one
- ? "A held result was saved. A later change to that match would have left it without a winner, so the change was moved to the match's history."
- : `${n} held results were saved. Later changes to those matches would have left them without a winner, so the changes were moved to each match's history.`;
+// `forWinner` is how many of the n answers moved their change for a winner
+// (writeDisplacedForWinner): all of them keeps the winner sentence, none says
+// plainly that the change was moved, and a mix says both. An alert that
+// carries no such count reads as none.
+export function displacedAlertText(n, one, forWinner = 0) {
+ const f = Number(forWinner) || 0;
+ if (one) {
+ return f > 0
+ ? "A held result was saved. A later change to that match would have left it without a winner, so the change was moved to the match's history."
+ : "A held result was saved. A later change to that match was moved to the match's history.";
+ }
+ if (f >= n) {
+ return `${n} held results were saved. Later changes to those matches would have left them without a winner, so the changes were moved to each match's history.`;
+ }
+ const moved = `${n} held results were saved. Later changes to those matches were moved to each match's history.`;
+ return f > 0 ? `${moved} ${f} of those changes would have left a match without a winner.` : moved;
}
export const NEEDS_WINNER_REASON = "this change would leave the finished match without a winner, and it needs one, so it was kept in the match's history and nothing is lost";
export const NEEDS_WINNER_ADVICE = 'Correct the result with a winner.';
@@ -839,6 +861,23 @@ export function startWhileCorrectingMessage({ label }) {
return `Save the correction of ${label}, or leave it with Back to court, then start this match.`;
}
+// A second Start while another match's start is still out (bc-aadv, one start at a time).
+export function startWhileStartingMessage({ label }) {
+ return `${label} is still being started on this court. Start this match once it has.`;
+}
+
+// The same refusal once the start it named has LANDED (bc-aadv, item 4). While
+// that start was out the sentence above was true and was derived from it. When it
+// lands the court has a running bout, the refused match is NOT started on its own
+// (one start at a time; a second automatic start would draw court_busy or put two
+// bouts on the court unasked), and nothing else would say that the match was asked
+// for and never started. So the refusal is kept, in the past tense, on the refused
+// match until that match leaves scheduled or the operator starts one. The court
+// console and the Scores tab both word it from here.
+export function startWasBlockedByStartMessage({ label }) {
+ return `Not started: ${label} was being started when this match was asked for. Start it when the court is free.`;
+}
+
export function correctWhileRunningMessage({ court, label }) {
return `${courtBusyMessage({ court, label })} Then correct this match.`;
}
@@ -982,3 +1021,26 @@ export async function attemptScoreWrite({ recordScore, confirmDialog, compId, ma
throw e;
}
}
+
+// A thrown refusal is toasted ONCE. editMatchScore (admin.jsx), the chokepoint
+// every score write goes through, toasts the error it is about to throw; a host
+// that catches that same error to show it on a card or a row (the court console's
+// startMatch, the Scores tab's startNext) used to toast the same sentence again,
+// which replaced the single-slot toast and restarted its timer. editMatchScore
+// marks every error it toasted (markToasted) and a host asks before it toasts
+// (wasToasted); the card or row notice is set either way. A refusal only the host
+// sees (a clock_skew answer is a RETURN, not a throw; a local "still being
+// started" refusal never reaches onEditScore) carries no mark and is toasted
+// there, once.
+//
+// The mark rides on the error object, the way downstreamKnockoutPlayedCancelled
+// does, so nothing is threaded through a signature. `toasted` is the field name
+// (check-write-result.mjs polices `.applied`/`.persisted`, never this).
+export function markToasted(err) {
+ if (err && typeof err === 'object') err.toasted = true;
+ return err;
+}
+
+export function wasToasted(err) {
+ return !!err && typeof err === 'object' && err.toasted === true;
+}