Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
53b1927
fix(bc-evts): engi editor keeps a flag tap made while its previous sa…
gitrgoliveira Oct 13, 2026
65fe321
test(bc-e2rv): engi journey, and the bc-evts tap-back guard end to end
gitrgoliveira Oct 13, 2026
ca4f90d
test(bc-e2rv): harness review fixes, README checked against the code
gitrgoliveira Oct 13, 2026
9abfdf7
fix(bc-evts): stamp a board edit only while the match is running; rev…
gitrgoliveira Oct 13, 2026
b886399
fix(bc-evts): clear the edit stamp when the match leaves running; rev…
gitrgoliveira Oct 13, 2026
4691cea
test(bc-e2rv): independent review fixes for the held-write guard and …
gitrgoliveira Oct 13, 2026
30e0efe
test(bc-e2rv): follow-up write gets its own wait; enterFlags checks f…
gitrgoliveira Oct 13, 2026
d67cb64
fix(bc-evts): team sheet stamps only while running; a refused autosav…
gitrgoliveira Oct 13, 2026
c36252f
test(bc-e2rv): kachinuki in pools journey
gitrgoliveira Oct 13, 2026
053bc4a
test(bc-e2rv): organiser roster journey (check-in, seeds, participant…
gitrgoliveira Oct 13, 2026
398799e
test(bc-e2rv): organiser admin journeys (export, import, branding, re…
gitrgoliveira Oct 13, 2026
51d4544
fix(bc-imdt): a folder-import row with no date takes the tournament date
gitrgoliveira Oct 13, 2026
83a5d0a
fix(bc-lgnf): Edit details no longer probes a logo that may answer 404
gitrgoliveira Oct 13, 2026
bdc6fb0
test(bc-e2rv): e2e polish: condition waits, one owner per helper, tig…
gitrgoliveira Oct 13, 2026
be3b86f
docs(bc-e2rv): e2e README lists the new journeys, fixtures, server op…
gitrgoliveira Oct 13, 2026
06d6264
fix(bc-evts): a clock_skew autosave keeps the tap; review round 3
gitrgoliveira Oct 13, 2026
f45c791
test(bc-e2rv): e2e round 3 review fixes: seeds that can fail, a workb…
gitrgoliveira Oct 13, 2026
0eaac2c
test(bc-e2rv): e2e review round 4: settled reads, one navigation per …
gitrgoliveira Oct 14, 2026
9fc0a90
test(bc-e2rv): the bc-adna fixme navigates once and polls the Scores …
gitrgoliveira Oct 14, 2026
ff0484b
test(bc-e2rv): name-list option taps wait out the bout list's bounce …
gitrgoliveira Oct 14, 2026
8c543be
test(bc-e2rv): the bc-adna fixme asserts its bead in full; README reflow
gitrgoliveira Oct 14, 2026
eddedb0
test(bc-e2tb): pools-tiebreak journey: minimum pool size and a tie-br…
gitrgoliveira Oct 14, 2026
7d40360
test(bc-e2pb): spectator journey on the public (phone) project
gitrgoliveira Oct 14, 2026
9cdaed5
docs(bc-e2rv): e2e README lists the pools-tiebreak and spectator.publ…
gitrgoliveira Oct 14, 2026
483e3fc
fix(bc-tbdg): a pool tie awaiting its tie-break bout shows both compe…
gitrgoliveira Oct 14, 2026
ddaf26c
fix(bc-tbdg): tie display follows the server's tie-break cut; review …
gitrgoliveira Oct 14, 2026
8f60a98
fix(bc-xqtb): a tie over a larger-pools extra qualifier place gets it…
gitrgoliveira Oct 14, 2026
d4f643c
fix(bc-fbtb): a tie over a fill-bracket drafted 2nd place gets its ti…
gitrgoliveira Oct 14, 2026
de5d22a
docs(bc-fbtb): poolQualifiers counts only the places a small pool's d…
gitrgoliveira Oct 14, 2026
a2b7444
test(bc-e2rv): the workbook reader decodes entities in one pass and r…
gitrgoliveira Oct 14, 2026
f7cb416
test(bc-e2rv): review round 6: requalification and representative-bou…
gitrgoliveira Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/rules/match-results-and-marks.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr

Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it).

**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save carrying the last edit has landed, so every later server copy is followed. All three editors take the stamp only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. All three also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. A REFUSED autosave (`writeWasRefused`: nothing handed back, or `applied:false`) stores and broadcasts nothing, so no later copy would re-run the adopt and the stamp would hold the operator's value against the server's for good. `landedAutosave` therefore takes an optional `onRefused(seq)`; each editor, when no edit was made since that save was built (the `seq` still matches), clears the stamp and takes the server's CURRENT copy through its own adopt path (the engi counts, the individual `applyServerScore`, the team sheet's rows and pick parts the save carried, composed by `composeRepRow`), reading the latest render's closure through a ref. An edit made since keeps the stamp and the save that carries it decides; the individual and engi editors also require the stamp still to be set, because their counter counts only running edits, so a correction tapped on a match that finished elsewhere while the save was out is not wiped by a late refusal. A QUEUED answer calls neither callback and keeps the stamp: that write will land. A queued write that the server later refuses on replay is not seen by the editor (no promise reaches it), so its stamp stays until the match leaves running or a later save lands. A `clock_skew` answer (`writeWasRefusedForClock`) calls neither callback either and keeps the stamp: `recordScore` keeps a running one silent, relearns the clock and leaves the tap on the board for the NEXT save to carry with a corrected stamp (nothing re-sends it by itself; `_handleOkBody` only relearns). Known limits: the team sheet's refusal adopt (`takeServerRowsRef`) restores bout rows and representative picks only, so the representative-bout verdict and the encounter overtime stay local after a refused save; and a save applied IN PART (`heldGroups`) counts as landed and clears the stamp, and no new broadcast follows when the server's value for a held group equals the copy the adopt already skipped; that stays the genuine disagreement `keepLocalEdits` leaves to the operator, with the held values in the match's history.

## Silence on the wire

Silence still means "no opinion" on the wire. It is now OMITTED IPPONS (nil, not `[]`) rather than an omitted flag, and the distinction is load-bearing: an explicit `[]` is the operator saying ZERO (a 0-0 withdrawal), a missing key is a writer with nothing to say, whose stored verdict `preserveSubHantei` restores. The team editor IS one of those writers: `buildPatch` (admin_scoring_team.jsx) has a `daihyosenSilent` branch that OMITS the daihyosen row's ippon arrays when the operator has not touched that row this session AND nothing about it is known locally. That branch is the only reason the nil case is reachable from a real client at all, so do NOT "fix" it into an unconditional write. Any touch, or any locally-known verdict or scoreline, falls through to the explicit-array path, so a deliberate withdrawal still withdraws. Scope note: the restore protects a stored HANTEI verdict only; `preserveSubHantei` bails when the stored row carries no mark, so a rep bout decided by a strike is not restored by this mechanism.
Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,10 @@ docs/media: go/build $(SHOTS_BROWSER_STAMP) ## Regenerate every captured screens
@node $(SHOTS_DIR)/run.mjs $(if $(KIND),'KIND=$(KIND)',) $(SHOTS_ARGS)

# Local e2e journeys (scripts/e2e, see its README.md). Like the capture harness
# above, Playwright lives in its own package.json rather than web-mobile's, and
# both pin the same Playwright release so they share one downloaded Chromium
# under ~/.cache/ms-playwright. Deliberately NOT part of go/test and not run in
# above, Playwright lives in its own package.json rather than web-mobile's. Both
# lockfiles currently resolve the same Playwright release (1.63.0; the capture
# harness's package.json only asks for ^1.56.0), so they share one downloaded
# Chromium under ~/.cache/ms-playwright. Deliberately NOT part of go/test and not run in
# CI: a journey drives the real app through a browser, setup wizard and all.
E2E_DIR := scripts/e2e
E2E_DEPS_STAMP := $(E2E_DIR)/node_modules/.package-lock.json
Expand Down
2 changes: 2 additions & 0 deletions docs/user-guide/organisers/run-tournament.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ A draw-ready competition is already listed on the shiai-jo operator views, since

The **Pools** tab shows standings for every pool. Ranks are computed automatically from match results. Operators do not edit them by hand, with one exception: chusen (drawing lots), the last-resort tie-break for a consequential team-pool tie that a daihyosen cannot settle. Refer to [Recording decisions](../court-operators/recording-decisions.md). When a daihyosen settles a tie that determines pool advancement, the winning side carries a **DH** badge in the standings.

When two or more competitors are level on every ranking criterion, the app schedules tie-break bouts between them if the best-placed of them finishes within the pool's qualifying places, because finishing place decides where a qualifier lands in the bracket. Those places are **Winners per pool**, plus the extra place an oversized pool sends under **Oversized send +1**, plus the second place of each pool that **Fit the knockout** drafts to fill the bracket, so a tie over such an extra place gets a bout too. A tie wholly below the qualifying places gets no bout and shares its place. The pool's qualifiers are seated in the bracket only once the bouts are scored. Until the bouts are scored, the tied competitors share one place, shown as **=2nd** for a tie over second place. A tie that runs across the advancing line leaves none of them with the advancing mark, while a tie inside the advancing places, such as two competitors level for first, reads **=1st** and keeps it. Once the bouts are scored, each takes their own place. A team pool settles a tie with a daihyosen instead, and if that round still leaves teams level, the pool waits for chusen. In an individual pool, each tied pair fights once, and no second round is scheduled if that leaves them level.

Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. A pool result corrected later still moves its qualifiers in the bracket; refer to [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started).

![The Pools tab: each pool as a card with its standings in draw order, every competitor carrying their number and a rank badge, the pool's matches with their results, and the head-to-head grid beneath.](../../screenshots/mobile-pool-standings.png)
Expand Down
12 changes: 10 additions & 2 deletions internal/engine/chusen.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,14 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) {
if overridesObj != nil {
poolRanks = overridesObj.PoolRanks
}
poolWinners := comp.EffectivePoolWinners()
pools, err := e.store.LoadPools(compID)
if err != nil {
return report, err
}
bracket, err := e.store.LoadBracket(compID)
if err != nil {
return report, err
}

poolNames := make([]string, 0, len(standings))
for name := range standings {
Expand All @@ -209,9 +216,10 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) {
for _, poolName := range poolNames {
poolStandings := standings[poolName]
groupOverrides := poolRanks[poolName]
cut := poolTieBreakCut(comp, pools, bracket, poolName)
for _, positions := range detectPoolTies(poolStandings) {
// Only a tie that affects advancement/seed warrants a decider at all.
if !tieAffectsAdvancement(positions, poolWinners) {
if !tieAffectsAdvancement(positions, cut) {
continue
}
group := standingsAt(poolStandings, positions)
Expand Down
10 changes: 9 additions & 1 deletion internal/engine/daihyosen.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult,
return err
}
e.noteMatchRead(compID)
pools, err := tx.LoadPools(compID)
if err != nil {
return err
}
bracket, err := tx.LoadBracket(compID)
if err != nil {
return err
}

// Scan existing DH matches per pool for idempotency and ID sequencing.
// poolNameFromMatchID strips the trailing suffix deterministically so
Expand Down Expand Up @@ -281,7 +289,7 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult,
if regularIncomplete[poolName] {
continue
}
for _, p := range pendingTieBreaks(comp, poolName, poolStandings, poolDH[poolName], poolCourt[poolName], true) {
for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), poolStandings, poolDH[poolName], poolCourt[poolName], true) {
injected = append(injected, p.bouts...)
}
}
Expand Down
14 changes: 10 additions & 4 deletions internal/engine/pool_daihyosen_bandaware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,29 @@ import (

// These tests pin the band-aware rule for the pool daihyosen: a supplementary
// bout is played only where the tie affects who advances (or their seed). The
// top `poolWinners` of each pool advance and 1st place gets a bracket bye, so a
// top `poolWinners` of each pool advance (the pool's own qualifier count, which
// is larger for an oversized pool under larger-pools; see
// tiebreak_extra_qualifier_test.go) and 1st place gets a bracket bye, so a
// tie whose best position is within [1..poolWinners] is consequential, while a
// tie sitting entirely below the cut shares its rank with no bout
// (running_a_kendo_tournament.md:441 - a daihyosen is played "to determine
// their ranking", i.e. where the ranking matters).

// setupTeamPoolWinners is setupTeamPool with an explicit advancement cutoff so a
// tie can be placed above, across, or below the qualifying band.
func setupTeamPoolWinners(t *testing.T, compID string, teams []string, poolWinners int, matches []state.MatchResult) (*Engine, *state.Store) {
func setupTeamPoolWinners(t *testing.T, compID string, teams []string, poolWinners int, matches []state.MatchResult, opts ...func(*state.Competition)) (*Engine, *state.Store) {
t.Helper()
eng, store, _ := setupTestEngine(t)
require.NoError(t, store.SaveCompetition(&state.Competition{
comp := &state.Competition{
ID: compID, Name: "Band-aware DH Test",
Format: state.CompFormatLeague, Status: state.CompStatusPools,
Courts: []string{"A"}, Kind: "team", TeamSize: 2,
PoolWinners: poolWinners,
}))
}
for _, o := range opts {
o(comp)
}
require.NoError(t, store.SaveCompetition(comp))
players := make([]helper.Player, len(teams))
for i, n := range teams {
players[i] = helper.Player{Name: n}
Expand Down
6 changes: 3 additions & 3 deletions internal/engine/pool_requalify.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func (e *Engine) planRequalification(tx state.StoreTx, compID string, comp *stat
// label. Engi places are decided by the standings' own order.
tied := map[string]bool{}
if !comp.Engi {
tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, standings[poolName], ranksNeeded)
tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), standings[poolName], ranksNeeded)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -253,7 +253,7 @@ func rankOfLabel(poolName, label string, ranksNeeded int) int {
// tiedQualifyingLabels returns the pool's qualifying labels whose rank sits in
// a tied group that still owes a supplementary bout (pendingTieBreaks, the
// injectors' own rule), read through the transaction.
func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *state.Competition, poolName string, poolStandings []state.PlayerStanding, ranksNeeded int) (map[string]bool, error) {
func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *state.Competition, poolName string, cut int, poolStandings []state.PlayerStanding, ranksNeeded int) (map[string]bool, error) {
rows, err := tx.LoadPoolMatches(compID)
if err != nil {
return nil, fmt.Errorf("requalification: load pool matches for %s: %w", compID, err)
Expand All @@ -273,7 +273,7 @@ func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *sta
}
}
tied := map[string]bool{}
for _, p := range pendingTieBreaks(comp, poolName, poolStandings, existing, court, daihyosen) {
for _, p := range pendingTieBreaks(comp, poolName, cut, poolStandings, existing, court, daihyosen) {
for _, pos := range p.positions {
if pos+1 <= ranksNeeded {
tied[qualifierLabel(poolName, pos+1)] = true
Expand Down
Loading
Loading