From 6347560dff870ed2fa74e90907083262f035fd06 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 8 Oct 2026 16:11:43 +0100 Subject: [PATCH 01/38] fix(bc-aadv): auto-advance keeps a refused start on the Up next card Route both Finish + Start Next and onAfterDecision auto-advance starts through startMatch() instead of inline try/onEditScore/catch blocks. This ensures the Up next card displays lasting refusal messages (ineligible_competitor, court_busy, clock_skew) that were previously being swallowed silently. - Replace onEditScore call with startMatch call in onSubmitAndNext - Replace onEditScore call with startMatch call in onAfterDecision - Add three new tests: refused start, onAfterDecision refusal, clock_skew refusal - Update docs to mention Up next card displays refusal reasons Co-Authored-By: Claude Haiku 4.5 --- .../court-operators/scoring-a-match.md | 2 + .../render/admin_shiaijo.render.test.jsx | 69 ++++++++++++++++++- web-mobile/js/admin_shiaijo.jsx | 4 +- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index 1fa107a18..22475dfcd 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. +**Finish** also starts the next scheduled match automatically if one is available. If the next match cannot start for any reason (for example, one competitor is competing on another court), the Up next card shows 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/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 9d202dc00..1f3320c81 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, act, fireEvent, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; -import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, startWhileCorrectingMessage, correctWhileRunningMessage } from '../../write_result.jsx'; +import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, startWhileCorrectingMessage, correctWhileRunningMessage, CLOCK_SKEW_REASON_TEXT } from '../../write_result.jsx'; import { scoreRowMatchName } from '../../pool_ids.jsx'; // Window globals required by admin_shiaijo.jsx. // MODULE-EVAL-TIME entries (e.g. `const AdminTopbar = window.AdminTopbar;`) @@ -1081,6 +1081,73 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { expect(onEditScore.mock.calls[1][1]).toBe('m-open'); }); + // bc-aadv: Finish + Start Next keeps a refused start on the Up next card, + // instead of swallowing the error silently. + it('Finish + Start Next keeps a refused start on the Up next card', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + window.tournamentMatches = () => [mRun, openMatch]; + window.filterMatchesByCourt = (m) => m; + const onEditScore = vi.fn() + .mockResolvedValueOnce({ status: 'ok' }) + .mockRejectedValueOnce(new Error('Sato is fighting on Shiaijo B.')); + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m-run'); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed', winner: side('p1', 'Yamada') }); }); + // The finishing write succeeds; the start-next write fails. + expect(onEditScore).toHaveBeenCalledTimes(2); + // The error is recorded on the Up next card. + const errorEl = utils.container.querySelector('.shiaijo-upnext__error'); + expect(errorEl).toBeTruthy(); + expect(errorEl.textContent).toContain('Sato is fighting on Shiaijo B.'); + }); + + it('onAfterDecision keeps a refused start on the Up next card', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + window.tournamentMatches = () => [mRun, openMatch]; + window.filterMatchesByCourt = (m) => m; + const onEditScore = vi.fn() + .mockRejectedValueOnce(new Error('Could not start the match: check eligibility and try again.')); + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m-run'); + await act(async () => { await probe.props.onAfterDecision({ winner: side('p1', 'Yamada') }); }); + // The start-next write fails. + expect(onEditScore).toHaveBeenCalledTimes(1); + // The error is recorded on the Up next card. + const errorEl = utils.container.querySelector('.shiaijo-upnext__error'); + expect(errorEl).toBeTruthy(); + expect(errorEl.textContent).toContain('Could not start the match'); + }); + + it('clock_skew on the auto-start is reported on the Up next card', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + window.tournamentMatches = () => [mRun, openMatch]; + window.filterMatchesByCourt = (m) => m; + const onEditScore = vi.fn() + .mockResolvedValueOnce({ status: 'ok' }) + .mockResolvedValueOnce({ applied: false, reason: 'clock_skew' }); + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m-run'); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed', winner: side('p1', 'Yamada') }); }); + // The finishing write succeeds; the start-next write returns clock_skew. + expect(onEditScore).toHaveBeenCalledTimes(2); + // The clock-skew error is recorded on the Up next card. + const errorEl = utils.container.querySelector('.shiaijo-upnext__error'); + expect(errorEl).toBeTruthy(); + expect(errorEl.textContent).toContain(CLOCK_SKEW_REASON_TEXT); + }); + it('offers Reinstate for a reinstateable (kiken-injury) withdrawal', async () => { window.tournamentMatches = () => [barredMatch({ ineligibleSides: { b: 'kiken-injury' } }), openMatch]; window.filterMatchesByCourt = (m) => m; diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 85e5a9c82..b004cf1bf 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1898,7 +1898,7 @@ 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 */ } }} @@ -1917,7 +1917,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} From e8e52cef3c1330826041c34db4b2ec15b8d9d3a5 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 8 Oct 2026 16:13:01 +0100 Subject: [PATCH 02/38] fix(bc-tmwn): the Completed list names the winner - Add .shiaijo-qrow__side--win CSS class for winner styling - Import teamNameMark from match_scoreboard.jsx - Compute winner side using window.winnerSideLR for completed rows - Get team marks using window.teamMatchMarks for team matches - Add bc-winner-tick span before winning side's name - Add msb-name--labelled class when team marks are present - Add winnerTick prop to VSchedItem component - Pass winnerTick={true} to Recent results VSchedItem in viewer_competition.jsx - Add comprehensive tests for winner display on court and viewer Co-Authored-By: Claude Haiku 4.5 --- web-mobile/css/styles.css | 1 + .../js/__tests__/admin_shiaijo.test.jsx | 184 +++++++++++++++++- .../js/__tests__/vsched_winner_cue.test.jsx | 63 ++++++ web-mobile/js/admin_shiaijo.jsx | 22 ++- web-mobile/js/viewer_competition.jsx | 2 +- web-mobile/js/viewer_match.jsx | 4 +- 6 files changed, 268 insertions(+), 8 deletions(-) diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 6cc8d4843..171cccb32 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -9876,6 +9876,7 @@ abbr.msb-lab { text-decoration: none; cursor: help; } .shiaijo-qrow__side { display: flex; align-items: center; gap: 6px; min-width: 0; padding: 4px 8px; } .shiaijo-qrow__side--shiro { border-radius: 6px 0 0 6px; } .shiaijo-qrow__side--aka { flex-direction: row-reverse; text-align: right; border-radius: 0 6px 6px 0; } +.shiaijo-qrow__side--win .shiaijo-qrow__name { color: var(--accent); font-weight: 700; } .shiaijo-qrow__name { font-weight: 600; font-size: 14px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .shiaijo-qrow__vs { font-size: 11px; color: var(--ink-3); } /* Completed result, centred on its own line below the names (the names keep the diff --git a/web-mobile/js/__tests__/admin_shiaijo.test.jsx b/web-mobile/js/__tests__/admin_shiaijo.test.jsx index e95123f8f..60c689858 100644 --- a/web-mobile/js/__tests__/admin_shiaijo.test.jsx +++ b/web-mobile/js/__tests__/admin_shiaijo.test.jsx @@ -141,16 +141,60 @@ describe('ShiaijoQueueRow; completed result placement', () => { }; beforeEach(async () => { - orig.fmt = window.formatIpponsScore; orig.iv = window.teamIVScore; + orig.fmt = window.formatIpponsScore; + orig.iv = window.teamIVScore; + orig.winnerSideLR = window.winnerSideLR; + orig.teamMatchMarks = window.teamMatchMarks; + window.formatIpponsScore = () => '·;MK'; window.teamIVScore = () => null; + + // bc-tmwn: stubs for winner and team marks, based on sameCompetitor from competitor_identity.jsx + const competitorKey = (c) => c && (c.id ? c.id : (c.name && c.dojo ? `${c.name}|${c.dojo}` : null)); + const sameCompetitor = (a, b) => { + const ka = competitorKey(a); + return !!ka && ka === competitorKey(b); + }; + + window.winnerSideLR = (m) => { + if (!m || !m.winner) return null; + if (sameCompetitor(m.winner, m.sideB)) return "left"; // Shiro = left = sideB + if (sameCompetitor(m.winner, m.sideA)) return "right"; // Aka = right = sideA + return null; + }; + + window.teamMatchMarks = (m, _isTeam) => { + if (!m) return { shiro: "", aka: "" }; + + let shiroMark = "", akaMark = ""; + if (m.decision && m.decision.startsWith("kiken")) { + // Kiken: mark the side that withdrew + if (m.decisionBy === "shiro" || sameCompetitor(m.decisionBy, m.sideB)) { + shiroMark = "Kiken"; + } else if (m.decisionBy === "aka" || sameCompetitor(m.decisionBy, m.sideA)) { + akaMark = "Kiken"; + } + } else if (m.decision === "fusenpai") { + // Fusenpai: mark the side that no-showed + if (m.decisionBy === "shiro" || sameCompetitor(m.decisionBy, m.sideB)) { + shiroMark = "Fus."; + } else if (m.decisionBy === "aka" || sameCompetitor(m.decisionBy, m.sideA)) { + akaMark = "Fus."; + } + } + return { shiro: shiroMark, aka: akaMark }; + }; + runtime = makeReactive(); global.React = runtime.React; ({ ShiaijoQueueRow } = await import('../admin_shiaijo.jsx')); }); afterEach(() => { runtime.unmount(); global.React = realReact; - window.formatIpponsScore = orig.fmt; window.teamIVScore = orig.iv; + window.formatIpponsScore = orig.fmt; + window.teamIVScore = orig.iv; + window.winnerSideLR = orig.winnerSideLR; + window.teamMatchMarks = orig.teamMatchMarks; }); it('puts the score in a centred result line below the names, not the corner', () => { @@ -180,6 +224,142 @@ describe('ShiaijoQueueRow; completed result placement', () => { expect(byClass(tree, 'shiaijo-qrow__result').length).toBe(0); expect(byClass(tree, 'shiaijo-qrow__vs').length).toBe(1); }); + + // bc-tmwn: tests for winner display on completed rows + it('W1: shows winner class and tick for daihyosen-decided team match (sideA winner)', () => { + runtime.mount(ShiaijoQueueRow, { + m: { + id: 'm-dh1', compId: 'c1', status: 'completed', teamSize: 3, + compKind: 'team', + sideA: { id: 'a1', name: 'Team A' }, + sideB: { id: 'b1', name: 'Team B' }, + winner: { id: 'a1', name: 'Team A' }, // sideA won (Aka) + score: [], + subResults: [ + { position: 1, sideA: 'M1', sideB: 'M2', winner: 'M1' }, + { position: 2, sideA: 'M3', sideB: 'M4', winner: 'M4' }, + ] + }, + scheduled: [], courts: ['A'], + }); + const tree = runtime.currentTree(); + const sides = byClass(tree, 'shiaijo-qrow__side'); + expect(sides.length).toBe(2); + + // Find Aka side (sideA, should be second side, but order depends on implementation) + // The Aka side should have the winner class and tick + const akaSide = sides[1]; + expect(akaSide.props.className).toContain('shiaijo-qrow__side--aka'); + expect(akaSide.props.className).toContain('shiaijo-qrow__side--win'); + + // Check for the tick span in Aka side + let hasTick = false; + walk(akaSide, (n) => { + if (n && n.props?.className === 'bc-winner-tick' && text(n) === '✓') { + hasTick = true; + } + }); + expect(hasTick).toBe(true); + + // Shiro side should NOT have winner class + const shiroSide = sides[0]; + expect(shiroSide.props.className).not.toContain('shiaijo-qrow__side--win'); + }); + + it('W2: shows team mark and winner cue for kiken-decided team match', () => { + runtime.mount(ShiaijoQueueRow, { + m: { + id: 'm-kiken1', compId: 'c1', status: 'completed', teamSize: 3, + compKind: 'team', + sideA: { id: 'a1', name: 'Team A' }, + sideB: { id: 'b1', name: 'Team B' }, + winner: { id: 'a1', name: 'Team A' }, // sideA won (Aka), sideB withdrew + decision: 'kiken-voluntary', + decisionBy: 'shiro', // sideB withdrew (shiro is sideB) + score: [], + subResults: [] + }, + scheduled: [], courts: ['A'], + }); + const tree = runtime.currentTree(); + const sides = byClass(tree, 'shiaijo-qrow__side'); + expect(sides.length).toBe(2); + + // Shiro side should have the Kiken mark + const shiroSide = sides[0]; + expect(shiroSide.props.className).toContain('shiaijo-qrow__side--shiro'); + let kikenMarkFound = false; + walk(shiroSide, (n) => { + if (n && n.props?.['data-testid'] === 'team-summary-mark-shiro') { + kikenMarkFound = true; + } + }); + expect(kikenMarkFound).toBe(true); + + // Aka side should have winner cue + const akaSide = sides[1]; + expect(akaSide.props.className).toContain('shiaijo-qrow__side--aka'); + expect(akaSide.props.className).toContain('shiaijo-qrow__side--win'); + }); + + it('W3: shows winner cue on individual kiken match without team mark', () => { + runtime.mount(ShiaijoQueueRow, { + m: { + id: 'm-ind-kiken', compId: 'c1', status: 'completed', teamSize: 0, + sideA: { id: 'p1', name: 'Player A' }, + sideB: { id: 'p2', name: 'Player B' }, + winner: { id: 'p1', name: 'Player A' }, + decision: 'kiken-voluntary', + decisionBy: 'p2', + score: [] + }, + scheduled: [], courts: ['A'], + }); + const tree = runtime.currentTree(); + const sides = byClass(tree, 'shiaijo-qrow__side'); + + // Aka side (sideA) should have winner cue + const akaSide = sides[1]; + expect(akaSide.props.className).toContain('shiaijo-qrow__side--win'); + + // Should NOT have team mark (individual match) + let hasTeamMark = false; + walk(akaSide, (n) => { + if (n && n.props?.['data-testid']?.startsWith('team-summary-mark-')) { + hasTeamMark = true; + } + }); + expect(hasTeamMark).toBe(false); + }); + + it('W4: shows no winner cue for hikiwake (drawn) match', () => { + runtime.mount(ShiaijoQueueRow, { + m: { + id: 'm-draw', compId: 'c1', status: 'completed', teamSize: 0, + sideA: { id: 'p1', name: 'Player A' }, + sideB: { id: 'p2', name: 'Player B' }, + winner: null, // No winner + decision: 'hikiwake', + score: [] + }, + scheduled: [], courts: ['A'], + }); + const tree = runtime.currentTree(); + const sides = byClass(tree, 'shiaijo-qrow__side'); + + // Neither side should have winner class + expect(sides[0].props.className).not.toContain('shiaijo-qrow__side--win'); + expect(sides[1].props.className).not.toContain('shiaijo-qrow__side--win'); + + // No tick on either side + let tickCount = 0; + walk(tree, (n) => { + if (n && n.props?.className === 'bc-winner-tick') { + tickCount++; + } + }); + expect(tickCount).toBe(0); + }); }); // mp-tidg: viewer competition tab routing; /competition/:id/:tab gives diff --git a/web-mobile/js/__tests__/vsched_winner_cue.test.jsx b/web-mobile/js/__tests__/vsched_winner_cue.test.jsx index 09e58fd4a..b5849760e 100644 --- a/web-mobile/js/__tests__/vsched_winner_cue.test.jsx +++ b/web-mobile/js/__tests__/vsched_winner_cue.test.jsx @@ -157,4 +157,67 @@ describe('T5: VSchedItem winner cue - completed team match', () => { const winners = sides.filter(n => hasClass(n, 'vsched-item__side--w')); expect(winners).toHaveLength(0); }); + + // bc-tmwn: W5 test for winner tick on Recent results + it('W5: winnerTick prop renders bc-winner-tick on the winning side only', () => { + const raw = { + id: 'm-team-4', + status: 'completed', + court: 'A', + phase: 'bracket', + round: 'Final', + sideA: 'Ryu', + sideB: 'Phoenix', + winner: 'Ryu', // sideA (Aka) wins + }; + const m = normalizeMatch(raw, {}); + + // Mount with winnerTick=true (as Recent results does) + const tree = runtime.mount(VSchedItem, { m, tweaks: {}, winnerTick: true }); + + const sides = findAll(tree, n => hasClass(n, 'vsched-item__side')); + const akaSide = sides.find(n => hasClass(n, 'vsched-item__side--aka')); + const shiroSide = sides.find(n => hasClass(n, 'vsched-item__side--shiro')); + + // Find bc-winner-tick spans + const ticks = findAll(tree, n => n.props?.className === 'bc-winner-tick'); + + // Should have exactly one tick + expect(ticks).toHaveLength(1); + + // The tick should be inside the Aka side (winning side) + let akaHasTick = false; + const akaSideTicks = findAll(akaSide, n => n.props?.className === 'bc-winner-tick'); + akaHasTick = akaSideTicks.length > 0; + + // The tick should NOT be in the Shiro side + let shiroHasTick = false; + const shiroSideTicks = findAll(shiroSide, n => n.props?.className === 'bc-winner-tick'); + shiroHasTick = shiroSideTicks.length > 0; + + expect(akaHasTick).toBe(true); + expect(shiroHasTick).toBe(false); + }); + + it('W5b: without winnerTick prop, no bc-winner-tick is rendered', () => { + const raw = { + id: 'm-team-5', + status: 'completed', + court: 'A', + phase: 'bracket', + round: 'SF', + sideA: 'Ryu', + sideB: 'Phoenix', + winner: 'Ryu', + }; + const m = normalizeMatch(raw, {}); + + // Mount WITHOUT winnerTick prop (as Up Next/Running do) + const tree = runtime.mount(VSchedItem, { m, tweaks: {} }); + + const ticks = findAll(tree, n => n.props?.className === 'bc-winner-tick'); + + // Should have no ticks + expect(ticks).toHaveLength(0); + }); }); diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index b004cf1bf..f76b40111 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -51,6 +51,9 @@ import { isBarredMatch, sideBarredByDecision, involvesCompetitor } from './ineli // stub taking effect and silently overwrote it. See barred_match_notice.jsx's // header. import { BarredMatchNotice } from './barred_match_notice.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; @@ -2170,6 +2173,11 @@ 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 ? window.winnerSideLR?.(m) : null; + const { shiro: teamShiroMark, aka: teamAkaMark } = isComplete && isTeamMatch(m) && window.teamMatchMarks + ? window.teamMatchMarks(m, true) + : { shiro: "", aka: "" }; // 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 +2233,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), diff --git a/web-mobile/js/viewer_competition.jsx b/web-mobile/js/viewer_competition.jsx index bd7f565f7..0cf15e052 100644 --- a/web-mobile/js/viewer_competition.jsx +++ b/web-mobile/js/viewer_competition.jsx @@ -726,7 +726,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..a7f9b8701 100644 --- a/web-mobile/js/viewer_match.jsx +++ b/web-mobile/js/viewer_match.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,6 +291,7 @@ 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. */} + {winnerTick && bWin && ✓} {barredNameMark(m, "shiro", teamNameMark("shiro", teamShiroMark, ))} {tweaks.showDojo && m.sideB?.dojo ? {m.sideB.dojo} : null}
@@ -319,6 +320,7 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig )}
+ {winnerTick && aWin && ✓} {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))} {tweaks.showDojo && m.sideA?.dojo ? {m.sideA.dojo} : null}
From 67143a5a8ca845e743ce7db3d9c0b32852ba8577 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 8 Oct 2026 16:35:30 +0100 Subject: [PATCH 03/38] docs(bc-aadv): the sentence names Finish + Start Next, not Finish Plain Finish does not start the next match; only the court console's Finish + Start Next does. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/court-operators/scoring-a-match.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index 22475dfcd..b904fec41 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -15,7 +15,7 @@ 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. -**Finish** also starts the next scheduled match automatically if one is available. If the next match cannot start for any reason (for example, one competitor is competing on another court), the Up next card shows why. +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. From ceebcf593afcbd6dc5c51ce6480b4f3bd3899e1d Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 15:56:30 +0100 Subject: [PATCH 04/38] fix(bc-tmwn): the viewer's winner tick rides the name row As a sibling of the name span it took a line of its own above the name (measured at 390x844: tick at y 292, name at y 306, the side 52px tall). Inside the name's flex row it sits at the outer edge, first on Shiro and last on Aka, and the side is 37px tall again. Co-Authored-By: Claude Opus 5.5 --- web-mobile/css/styles.css | 5 ++++- web-mobile/js/viewer_match.jsx | 13 +++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 171cccb32..8d17811b6 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -7273,7 +7273,10 @@ button.vsched-item:not([data-clickable]) { .msb-name--labelled .numbered-name--clip { min-width: 0; flex: 0 1 auto; } .msb-name--labelled .msb-member-label, .msb-name--labelled .sb-result-mark, -.msb-name--labelled .barred-chip { flex-shrink: 0; } +.msb-name--labelled .barred-chip, +.msb-name--labelled .bc-winner-tick { flex-shrink: 0; } +/* The row's gap spaces the tick; its bracket-card margin would double it. */ +.msb-name--labelled .bc-winner-tick { margin-right: 0; } /* The decided winner's name. .msb-slots--win marks only an ippon-LESS result (hantei, or a default win), so without this an ordinary 2-0 or 1-0 win is emphasised nowhere. Set by IndividualScore from the same id-first winner key diff --git a/web-mobile/js/viewer_match.jsx b/web-mobile/js/viewer_match.jsx index a7f9b8701..c965336c4 100644 --- a/web-mobile/js/viewer_match.jsx +++ b/web-mobile/js/viewer_match.jsx @@ -195,6 +195,10 @@ export function MatchDetailCard({ match, onClose, escapeToClose = true, slotLabe // VSchedItem // --------------------------------------------------------------------------- +// The bracket card's winner tick (.bc-winner-tick), shown by VSchedItem on the +// Recent results list only (its `winnerTick` prop). +const winnerTickMark = ✓; + 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 @@ -291,8 +295,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. */} - {winnerTick && bWin && ✓} - {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 && winnerTickMark}{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) → @@ -320,8 +326,7 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig )}
- {winnerTick && aWin && ✓} - {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))} + {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))}{winnerTick && aWin && winnerTickMark} {tweaks.showDojo && m.sideA?.dojo ? {m.sideA.dojo} : null}
From 057ed376ddc32bd2c79358f29774ef6674124ffc Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 15:54:23 +0100 Subject: [PATCH 05/38] fix(bc-rrdr): a drawn bout appears in Recent results A completed hikiwake has no winner, so the viewer's Recent results filter dropped it while the operator's Completed list showed it. The filter now keys on status and the bye/watched predicates alone. Co-Authored-By: Claude Haiku 5.5 --- .../viewer_competition_recent_order.test.jsx | 194 ++++++++++++++++++ web-mobile/js/viewer_competition.jsx | 5 +- 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx b/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx index 73e08fc55..f762618a5 100644 --- a/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx +++ b/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx @@ -165,3 +165,197 @@ describe('ViewerCompetition Recent results orders by result-write time (mp-jnvl) expect(rows.map((m) => m.id)).toEqual(['late-slot', 'middle-slot', 'early-slot']); }); }); + +describe('ViewerCompetition Recent results includes completed hikiwake (drawn) bouts (bc-rrdr)', () => { + const realReact = global.React; + let runtime, saved; + let ViewerCompetition, ViewerOverview, VSchedItem, normalizeCompetitionDetail; + + beforeEach(async () => { + runtime = makeReactive(); + global.React = runtime.React; + saved = installStubs(); + vi.resetModules(); + await import('../admin_helpers.jsx'); + await import('../bracket.jsx'); + ({ ViewerCompetition, ViewerOverview, VSchedItem } = await import('../viewer.jsx')); + ({ normalizeCompetitionDetail } = await import('../api_serializers.jsx')); + }); + + afterEach(() => { + runtime.unmount(); + global.React = realReact; + restoreStubs(saved); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + const rawDetail = () => ({ + id: 'c1', + name: 'Pool Tournament', + kind: 'individual', + teamSize: 0, + format: 'pools', + status: 'knockout', + startTime: '09:00', + courts: ['A'], + config: { + players: [ + { id: 'p1', name: 'Aiko Tanaka', dojo: 'Dojo One' }, + { id: 'p2', name: 'Bo Nakamura', dojo: 'Dojo Two' }, + { id: 'p3', name: 'Chie Sato', dojo: 'Dojo Three' }, + { id: 'p4', name: 'Dai Mori', dojo: 'Dojo Four' }, + ], + }, + pools: [ + { + name: 'Pool A', + poolName: 'Pool A', + players: ['p1', 'p2', 'p3', 'p4'], + }, + ], + // The viewer API sends pool bouts as a flat poolMatches list (compMatches + // reads it in preference to pools[].matches, so an empty [] would hide them). + poolMatches: [ + { + id: 'Pool A-0', + phase: 'pool', + poolName: 'Pool A', + sideA: 'Aiko Tanaka', + sideB: 'Bo Nakamura', + status: 'completed', + court: 'A', + scheduledAt: '09:00', + ipponsA: ['K'], + ipponsB: ['M'], + decision: 'hikiwake', + modifiedAt: 2000, + }, + { + id: 'Pool A-1', + phase: 'pool', + poolName: 'Pool A', + sideA: 'Chie Sato', + sideB: 'Dai Mori', + winner: 'Chie Sato', + status: 'completed', + court: 'A', + scheduledAt: '09:10', + ipponsA: ['M', 'K'], + ipponsB: ['D'], + modifiedAt: 1000, + }, + ], + standings: {}, + bracket: { preview: false, rounds: [] }, + }); + + function mountCompetition(detail) { + const tree = runtime.mount(ViewerCompetition, { + tournament: { competitions: [detail], mode: 'public' }, + competition: detail, + pools: detail.pools, + poolMatches: detail.poolMatches, + standings: detail.standings, + bracket: detail.bracket, + onBack: () => {}, + tweaks: {}, + activeTab: 'overview', + }); + const overview = findInTree(tree, (n) => n.type === ViewerOverview); + expect(overview, 'expected the Overview tab to render ViewerOverview').not.toBeNull(); + return overview.props; + } + + function recentRows(overviewProps) { + const tree = runtime.mount(ViewerOverview, overviewProps); + const section = findInTree(tree, (n) => { + const kids = [].concat(n.props?.children || []).filter((k) => k && typeof k === 'object'); + return kids.some((k) => hasClass(k, 'section-title') && collectText(k) === 'Recent results') + && kids.some((k) => hasClass(k, 'vsched')); + }); + expect(section, 'expected a "Recent results" section with a match list').not.toBeNull(); + return findAll(section, (n) => n.type === VSchedItem).map((n) => n.props.m); + } + + it('a completed hikiwake pool bout (K X M, no winner) appears in Recent results, newest first', () => { + const detail = normalizeCompetitionDetail(rawDetail()); + const rows = recentRows(mountCompetition(detail)); + expect(rows.map((m) => m.id)).toEqual(['Pool A-0', 'Pool A-1']); + expect(rows[0].winner).toBeUndefined(); + expect(rows[0].decision).toBe('hikiwake'); + }); + + it('a completed hikiwake bout (K X M) renders with the X middle mark', () => { + const detail = normalizeCompetitionDetail(rawDetail()); + const overviewProps = mountCompetition(detail); + const rows = recentRows(overviewProps); + const tree = runtime.mount(VSchedItem, { + m: rows[0], + tweaks: {}, + winnerTick: true, + }); + const scoreText = collectText(tree); + expect(scoreText).toMatch(/\bX\b/); + }); + + it('a 0-0 hikiwake (no ippons) renders as just "X"', () => { + const detail = normalizeCompetitionDetail({ + id: 'c1', + name: 'Pool Tournament', + kind: 'individual', + teamSize: 0, + format: 'pools', + status: 'knockout', + startTime: '09:00', + courts: ['A'], + config: { + players: [ + { id: 'p1', name: 'Aiko Tanaka', dojo: 'Dojo One' }, + { id: 'p2', name: 'Bo Nakamura', dojo: 'Dojo Two' }, + ], + }, + pools: [{ name: 'Pool A', poolName: 'Pool A', players: ['p1', 'p2'] }], + poolMatches: [ + { + id: 'Pool A-0', + phase: 'pool', + poolName: 'Pool A', + sideA: 'Aiko Tanaka', + sideB: 'Bo Nakamura', + status: 'completed', + court: 'A', + scheduledAt: '09:00', + ipponsA: [], + ipponsB: [], + decision: 'hikiwake', + modifiedAt: 1000, + }, + ], + standings: {}, + bracket: { preview: false, rounds: [] }, + }); + const overviewProps = mountCompetition(detail); + const rows = recentRows(overviewProps); + const tree = runtime.mount(VSchedItem, { + m: rows[0], + tweaks: {}, + winnerTick: true, + }); + const scoreText = collectText(tree); + expect(scoreText).toContain('X'); + }); + + it('a hikiwake bout with winnerTick prop carries no bc-winner-tick (no winner)', () => { + const detail = normalizeCompetitionDetail(rawDetail()); + const overviewProps = mountCompetition(detail); + const rows = recentRows(overviewProps); + const tree = runtime.mount(VSchedItem, { + m: rows[0], + tweaks: {}, + winnerTick: true, + }); + const ticks = findAll(tree, (n) => n.props?.className === 'bc-winner-tick'); + expect(ticks).toHaveLength(0); + }); +}); diff --git a/web-mobile/js/viewer_competition.jsx b/web-mobile/js/viewer_competition.jsx index 0cf15e052..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 }; From a10944c9b33596f9ddc96665ed4af5ea81e254bf Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 16:04:41 +0100 Subject: [PATCH 06/38] fix(bc-cdbl): one announcement per court call, withdrawn when the match starts A double tap on Call to court posted two identical "Now calling" banners, and a call stayed up for its full five minutes after the match had started. - Console: callToCourt takes the tap through acceptTap (tap_guard.jsx), so a bounce of the tap is never sent. Both the Up next card and the queue row pass the event. The callingKey disabled state is unchanged. - Console: the announcement id of each call is kept per match (as a promise, so a start that lands before the answer still withdraws it). startMatch deletes it once the match starts, and a fusensho or both-barred decision recorded from the queue row's notice does the same. A failed delete is logged, never fails the start; the 5-minute expiry backstops it. - Server: AnnouncementStore.Add replaces an active announcement with the identical message (new id, fresh TTL) instead of stacking it, for every announcement, the organiser's Announce modal included. - Docs: run-tournament.md describes the replacement and the court-call withdrawal. Co-Authored-By: Claude Haiku 5.5 --- docs/user-guide/organisers/run-tournament.md | 4 + .../mobileapp/handlers_announcement_test.go | 42 ++++ internal/state/announcement_store.go | 16 +- internal/state/announcement_store_test.go | 85 ++++++- specs/openapi.yaml | 3 +- .../call_to_court_double_tap.render.test.jsx | 221 ++++++++++++++++++ web-mobile/js/admin_shiaijo.jsx | 56 ++++- web-mobile/js/barred_match_notice.jsx | 6 +- 8 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index b48bf3b45..33db2093f 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 when that court starts the match. A match started from another device does not take the call down, so it 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/internal/mobileapp/handlers_announcement_test.go b/internal/mobileapp/handlers_announcement_test.go index a17ede1bd..00e75734e 100644 --- a/internal/mobileapp/handlers_announcement_test.go +++ b/internal/mobileapp/handlers_announcement_test.go @@ -207,3 +207,45 @@ func TestAnnouncementHandlers(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } + +// TestAnnouncementHandlers_IdenticalMessageReplaces pins bc-cdbl: posting the +// text that is already showing replaces its banner rather than stacking a +// second one, so a double tap on Call to court is one announcement from any +// device. +func TestAnnouncementHandlers_IdenticalMessageReplaces(t *testing.T) { + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "Test Tournament", Password: "secret-password"})) + + eng := engine.New(store) + res := resources.NewResources(nil, fstest.MapFS{ + "web-mobile/index.html": {Data: []byte("Mobile")}, + }) + router, _, limiter := NewRouter(store, eng, res, NewFileVerifier(store)) + t.Cleanup(limiter.Close) + + post := func(msg string) state.Announcement { + t.Helper() + body, _ := json.Marshal(announcementRequest{Message: msg, DurationMinutes: 5}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/tournament/announce", bytes.NewReader(body)) + req.Header.Set("X-Tournament-Password", "secret-password") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var ann state.Announcement + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &ann)) + return ann + } + + first := post("Now calling Yamada and Tanaka to Shiaijo A.") + second := post("Now calling Yamada and Tanaka to Shiaijo A.") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/tournament/announcements", nil) + router.ServeHTTP(w, req) + var list []state.Announcement + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &list)) + require.Len(t, list, 1) + assert.Equal(t, second.ID, list[0].ID) + assert.NotEqual(t, first.ID, list[0].ID) +} diff --git a/internal/state/announcement_store.go b/internal/state/announcement_store.go index cb7eb6711..0d4c0a589 100644 --- a/internal/state/announcement_store.go +++ b/internal/state/announcement_store.go @@ -16,9 +16,10 @@ func NewAnnouncementStore() *AnnouncementStore { return &AnnouncementStore{} } -// Add appends a new announcement. Returns the new item and the updated list -// snapshot under the same lock, eliminating any race between mutation and -// broadcast. +// Add appends a new announcement. If an active announcement with the identical +// message already exists, it is replaced (removed and re-added with a new ID and +// fresh TTL). Returns the new item and the updated list snapshot under the same +// lock, eliminating any race between mutation and broadcast. func (s *AnnouncementStore) Add(msg string, dur time.Duration) (Announcement, []Announcement) { s.mu.Lock() defer s.mu.Unlock() @@ -26,6 +27,15 @@ func (s *AnnouncementStore) Add(msg string, dur time.Duration) (Announcement, [] ann := makeAnnouncement(msg, dur) s.pruneExpiredLocked(time.Now()) + // Remove the active announcement with the identical message, if any: at + // most one exists, since every Add replaces it (bc-cdbl). + for i, a := range s.active { + if a.Message == msg { + s.active = append(s.active[:i], s.active[i+1:]...) + break + } + } + if len(s.active) >= maxActiveAnnouncements { s.active = s.active[1:] } diff --git a/internal/state/announcement_store_test.go b/internal/state/announcement_store_test.go index 9dcafb168..89f2096bd 100644 --- a/internal/state/announcement_store_test.go +++ b/internal/state/announcement_store_test.go @@ -1,6 +1,7 @@ package state import ( + "fmt" "sync" "testing" "time" @@ -135,8 +136,10 @@ func TestAnnouncementStoreRemovePrunesExpired(t *testing.T) { func TestAnnouncementStoreCapEvictsOldest(t *testing.T) { store := NewAnnouncementStore() - for range maxActiveAnnouncements { - _, _ = store.Add("msg", 30*time.Minute) + // Distinct messages: an identical one would replace its predecessor + // (bc-cdbl), and this test is about the cap, not about replacement. + for i := range maxActiveAnnouncements { + _, _ = store.Add(fmt.Sprintf("msg %d", i), 30*time.Minute) } newest, _ := store.Add("newest", 30*time.Minute) list := store.List() @@ -197,3 +200,81 @@ func TestAnnouncementStoreConcurrentAddList(t *testing.T) { } wg.Wait() } + +func TestAnnouncementStore_AddReplacesIdenticalActive(t *testing.T) { + tests := []struct { + name string + scenario func(t *testing.T, store *AnnouncementStore) + }{ + { + name: "identical active announcement is replaced", + scenario: func(t *testing.T, store *AnnouncementStore) { + msg := "Call to court" + a1, _ := store.Add(msg, 5*time.Minute) + a2, list := store.Add(msg, 5*time.Minute) + + // Only one announcement should exist + if len(list) != 1 { + t.Fatalf("expected 1 announcement, got %d", len(list)) + } + // IDs should differ (a2 is the new one) + if a1.ID == a2.ID { + t.Error("new announcement should have a different ID") + } + // The stored announcement should be a2, not a1 + if list[0].ID != a2.ID { + t.Errorf("expected newest announcement (ID %s), got ID %s", a2.ID, list[0].ID) + } + if list[0].Message != msg { + t.Errorf("expected message %q, got %q", msg, list[0].Message) + } + }, + }, + { + name: "different messages both stack", + scenario: func(t *testing.T, store *AnnouncementStore) { + a1, _ := store.Add("First call", 5*time.Minute) + a2, list := store.Add("Second call", 5*time.Minute) + + if len(list) != 2 { + t.Fatalf("expected 2 announcements, got %d", len(list)) + } + if list[0].ID != a1.ID { + t.Errorf("expected first announcement first") + } + if list[1].ID != a2.ID { + t.Errorf("expected second announcement second") + } + }, + }, + { + name: "identical message after expiry just adds", + scenario: func(t *testing.T, store *AnnouncementStore) { + msg := "Call to court" + a1, _ := store.Add(msg, 5*time.Millisecond) + time.Sleep(10 * time.Millisecond) + a2, list := store.Add(msg, 5*time.Minute) + + // The expired copy is pruned, so the fresh one is the only entry + // and nothing is replaced: the identical message just adds. + if len(list) != 1 { + t.Fatalf("expected 1 announcement (expired first removed), got %d", len(list)) + } + if list[0].ID != a2.ID { + t.Errorf("expected the fresh announcement, got %q", list[0].ID) + } + // a1 should be gone (expired) + if list[0].ID == a1.ID { + t.Error("expired announcement should be pruned") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewAnnouncementStore() + tt.scenario(t, store) + }) + } +} diff --git a/specs/openapi.yaml b/specs/openapi.yaml index b1adc581d..07dce2a42 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -934,7 +934,8 @@ paths: Sends a transient text announcement to all connected viewer screens via SSE. The announcement is stored in-memory only (not persisted to disk); a server restart clears it. Late-connecting viewers can retrieve the active announcement - via `GET /api/tournament/announcement`. + via `GET /api/tournament/announcement`. A message identical to one still active + replaces it (new id, fresh duration) rather than adding a second entry. **Validation:** - `message` must be non-empty after trimming whitespace. diff --git a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx new file mode 100644 index 000000000..5e4e50653 --- /dev/null +++ b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx @@ -0,0 +1,221 @@ +// bc-cdbl: a double tap on "Call to court" sends ONE announcement, and starting +// the called match withdraws that call. +// +// The tap is checked with acceptTap (tap_guard.jsx) before anything else in +// callToCourt, so the bounce of a tap is never sent. The callingKey disabled +// state stays the in-flight guard, and a deliberate tap after the bounce window +// still calls again. The call's announcement id is kept per match and deleted +// when the console starts that match (startMatch). +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. + +import React from 'react'; +import { render, act, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +const CALL_TITLE = 'Announce this match to spectators and competitors'; + +const STUBBED_GLOBALS = { + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + ScoreEditorModal: (props) =>
, + CourtPicker: () => , + BracketTree: () => null, + Icon: ({ name }) => {name}, + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + sendAnnouncement: vi.fn(), + deleteAnnouncement: vi.fn(), + updateMatchTime: vi.fn(), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + reinstateCompetitor: vi.fn().mockResolvedValue({}), + }, + startPatch: vi.fn(), + confirmDialog: vi.fn().mockResolvedValue(true), + PoolsViewer: () => null, + compMatches: () => [], +}; + +let restoreGlobals; +let AdminShiaijoPage; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_shiaijo.jsx'); + AdminShiaijoPage = window.AdminShiaijoPage; +}); + +afterAll(() => restoreGlobals()); + +// Each call gets its own id, so a test can tell which announcement was deleted. +let nextAnnId = 0; + +beforeEach(() => { + nextAnnId = 0; + window.API.sendAnnouncement = vi.fn(async () => ({ id: `ann-${++nextAnnId}`, message: 'x' })); + window.API.deleteAnnouncement = vi.fn(async () => {}); + window.API.recordDecision = vi.fn(async () => ({ applied: true })); + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + window.tournamentMatches = STUBBED_GLOBALS.tournamentMatches; + window.filterMatchesByCourt = STUBBED_GLOBALS.filterMatchesByCourt; + vi.useRealTimers(); +}); + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); + +function twoScheduledMatches() { + const base = { compId: 'c1', court: 'A', status: 'scheduled', phase: 'pool', poolName: 'Pool 1' }; + return [ + { ...base, id: 'm1', sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' } }, + { ...base, id: 'm2', sideA: { id: 'p3', name: 'Suzuki' }, sideB: { id: 'p4', name: 'Sato' } }, + ]; +} + +// A new tournament object on every call, so rerender() re-derives the court's +// matches from window.tournamentMatches (the console memoises on it). +function consoleElement(onEditScore) { + return ( + + ); +} + +function renderConsole(props = {}) { + window.tournamentMatches = () => twoScheduledMatches(); + window.filterMatchesByCourt = (matches) => matches; + const onEditScore = props.onEditScore || vi.fn().mockResolvedValue({ applied: true }); + return render(consoleElement(onEditScore)); +} + +// Every "Call to court" control on the page, in DOM order: the Up next card's +// first, then one per queue row. +const callButtons = () => screen.getAllByTitle(CALL_TITLE); + +describe('Call to court double tap (bc-cdbl)', () => { + it('a double tap on the Up next card sends one announcement', async () => { + renderConsole(); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + // The first request has settled (pointerTap flushes microtasks); the second + // tap lands inside the bounce window of the first. + await wait(30); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + }); + + it('a deliberate tap after the bounce window calls again ("Call again")', async () => { + renderConsole(); + await pointerTap(callButtons()[0]); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(2); + }); + + it('a double tap on a queue row sends one announcement, for that row', async () => { + renderConsole(); + // callButtons()[1] is the queue row: the Up next card holds m1, so the queue + // lists m2 (Suzuki v Sato) alone. + const rowButton = callButtons()[1]; + await pointerTap(rowButton); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + expect(window.API.sendAnnouncement.mock.calls[0][0]).toContain('Suzuki'); + await wait(30); + await pointerTap(rowButton); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + }); + + it('a keyboard activation (detail 0) is never read as a bounce', async () => { + renderConsole(); + await keyboardClick(callButtons()[0]); + await keyboardClick(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(2); + }); + + it('starting the called match withdraws its announcement', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + + await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); + await act(async () => {}); + expect(onEditScore).toHaveBeenCalled(); + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); + }); + + it('a failed withdrawal does not fail the start', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + window.API.deleteAnnouncement = vi.fn(async () => { throw new Error('network down'); }); + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); + await act(async () => {}); + expect(onEditScore).toHaveBeenCalled(); + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('a start that lands before the call is answered still withdraws it', async () => { + let answer; + window.API.sendAnnouncement = vi.fn(() => new Promise((resolve) => { answer = resolve; })); + renderConsole(); + await pointerTap(callButtons()[0]); + // The call's request is still out when the match starts. + await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); + await act(async () => {}); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + await act(async () => { answer({ id: 'ann-late', message: 'x' }); }); + await act(async () => {}); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-late', ''); + }); + + it('recording the fusensho for a barred match withdraws its call', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + + // Yamada's withdrawal reaches the console after the call went out: the + // match is barred, its call button goes, and the notice offers the fusensho. + window.tournamentMatches = () => { + const [m1, m2] = twoScheduledMatches(); + return [{ ...m1, ineligibleSides: { a: 'kiken-voluntary' } }, m2]; + }; + rerender(consoleElement(onEditScore)); + await pointerTap(screen.getByTestId('barred-match-record-fusensho')); + await act(async () => {}); + expect(window.API.recordDecision).toHaveBeenCalledTimes(1); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); + }); + + it('starting a match that was never called withdraws nothing', async () => { + renderConsole(); + await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); + await act(async () => {}); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + }); +}); diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index f76b40111..6c13769f0 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -15,7 +15,7 @@ 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 { 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 @@ -732,10 +732,17 @@ 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); + const callTapRef = useRefSh(null); + // The announcement id (a promise of it, null if the send failed) of each + // court call this console made, keyed by matchKey, so the call can be + // withdrawn once its match starts (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. @@ -1263,6 +1270,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (showToast) showToast(msg, "error"); return false; } + // The call has done its job once the competitors are at the line + // (bc-cdbl): withdraw it now that the match has started. + withdrawCall(matchKey(m)); return true; } catch (e) { if (mountedRef.current) setStartError(refusalFor((e && e.message) || "Could not start the match: check eligibility and try again.")); @@ -1358,14 +1368,22 @@ 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 (ev && !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)); try { - await window.API.sendAnnouncement(msg, 5, password); + const sent = Promise.resolve(window.API.sendAnnouncement(msg, 5, password)); + // Keep the call's id per match so starting the match can withdraw it + // (bc-cdbl). Kept as a promise: a start that lands before the answer + // must still withdraw the call once its id arrives. + callIdsRef.current.set(matchKey(m), sent.then((ann) => (ann && ann.id) || null, () => null)); + await sent; if (!mountedRef.current) return; setCalledKey(matchKey(m)); if (showToast) showToast(`Called ${b} and ${a} to Shiaijo ${court}`); @@ -1376,6 +1394,22 @@ 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 started or been decided (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 pending = callIdsRef.current.get(key); + if (!pending) return; + callIdsRef.current.delete(key); + 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)); + }; + // 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. @@ -1680,7 +1714,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, )} diff --git a/web-mobile/js/barred_match_notice.jsx b/web-mobile/js/barred_match_notice.jsx index 8b0bca079..c4a2f980e 100644 --- a/web-mobile/js/barred_match_notice.jsx +++ b/web-mobile/js/barred_match_notice.jsx @@ -34,7 +34,10 @@ import { notLandedBanner, notSavedText, writeDidNotLand, queuedNotice } from './ // competitor_status_updated) once it lands. `onDone`, when a caller passes // one, still fires so a host that wants to react sooner (e.g. close a modal) // can. -export function BarredMatchNotice({ match, password, onDone }) { +// onDecisionRecorded (bc-cdbl), when given, fires once a decision (fusensho or +// the both-barred draw) has landed or been queued: the match is closed, so a +// court call for it is withdrawn. A reinstate does not fire it. +export function BarredMatchNotice({ match, password, onDone, onDecisionRecorded }) { const [busy, setBusy] = useState(false); // bc-cse: without this, a successful write's button re-enables the instant // busy clears and stays tappable until the SSE refetch repaints this @@ -81,6 +84,7 @@ export function BarredMatchNotice({ match, password, onDone }) { setErr(queuedNotice(res)); } setLanded(true); + if (typeof onDecisionRecorded === "function") onDecisionRecorded(res); if (typeof onDone === "function") onDone(res); } catch (e) { if (!mountedRef.current) return; From 08974f908c16965335c7280697b280a5e12d1ff3 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 16:16:46 +0100 Subject: [PATCH 07/38] fix(bc-dhrp): pick each team's representative on the daihyosen row The daihyosen row of a knockout team match rendered both team names read-only, though the docs and the in-app hint promise a picker. Each side can now pick, type or clear its representative by member id. The id rides the row beside the TEAM names, which placeHt and the wire still need. A typed name goes through the resolver a numbered row uses (mint, or rename a picked blank member), and no lineup is written, since "daihyosen" is not a lineup key. The bout projection names bout:-1 when a representative id is cleared, because the server copies a named row whole. A participant's pick is refused (400 team_member_not_in_team) when it names a member its side's team does not hold; the organiser's pick is not judged. Co-Authored-By: Claude Haiku 5.5 --- docs/architecture/data-model.md | 4 +- internal/engine/scoring_test.go | 74 +++++ internal/mobileapp/handlers_lineup.go | 15 +- internal/mobileapp/handlers_match.go | 38 +++ internal/mobileapp/self_run_daihyosen_test.go | 33 +++ internal/state/match_groups_table_test.go | 14 + internal/state/team_result_test.go | 16 ++ .../js/__tests__/match_groups_table.test.jsx | 27 ++ ...m_daihyosen_representative.render.test.jsx | 258 ++++++++++++++++++ web-mobile/js/admin_scoring_shared.jsx | 4 +- web-mobile/js/admin_scoring_team.jsx | 128 +++++++-- web-mobile/js/lineup_resolver.jsx | 13 +- web-mobile/js/match_groups.jsx | 5 + 13 files changed, 601 insertions(+), 28 deletions(-) create mode 100644 web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 31252eaa0..c9c838034 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -383,7 +383,9 @@ 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. 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 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/mobileapp/handlers_lineup.go b/internal/mobileapp/handlers_lineup.go index 4f024fcd2..8c5901b7b 100644 --- a/internal/mobileapp/handlers_lineup.go +++ b/internal/mobileapp/handlers_lineup.go @@ -470,10 +470,7 @@ func memberIDsOutsideTeam(stx state.StoreTx, compID, teamID string, memberIDs ma log.Printf("mobileapp: lineup member check for %s: %v", compID, err) return &txResponse{status: http.StatusInternalServerError, body: gin.H{"error": "internal error"}} } - held := make(map[string]bool, len(squads[teamID])) - for _, m := range squads[teamID] { - held[m.ID] = true - } + held := teamMemberIDs(squads, teamID) for pos, id := range memberIDs { if id != "" && !held[id] { return errLineupMemberNotInTeam(pos).response() @@ -482,6 +479,16 @@ func memberIDsOutsideTeam(stx state.StoreTx, compID, teamID string, memberIDs ma return nil } +// teamMemberIDs is the set of member ids a team holds in squads: the one answer to +// "is this member the team's" that every member check asks. +func teamMemberIDs(squads map[string][]domain.TeamMember, teamID string) map[string]bool { + held := make(map[string]bool, len(squads[teamID])) + for _, m := range squads[teamID] { + held[m.ID] = true + } + return held +} + func errLineupMemberNotInTeam(pos domain.Position) *selfRunRefusal { return &selfRunRefusal{ status: http.StatusBadRequest, diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index fd9b412de..3facddda5 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2061,6 +2061,12 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res log.Printf("mobileapp: kept the representative bout a self-run score write to %s/%s left out; the rest of the write was kept", compID, matchID) return nil } + if row >= 0 { + // bc-dhrp: the picks name the side's own team's members (the lineup's rule). + if err := repMembersOutsideTeams(stx, compID, snap.Pairing, subs[row]); err != nil { + return err + } + } if stored == nil || !stored.HanteiDecided() { if row >= 0 && subs[row].HanteiDecided() { return repBoutHanteiRefusal(false) @@ -2096,6 +2102,38 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res return nil } +// errRepMemberNotInTeam refuses a participant's representative pick that names a +// member the side's team does not hold (400 team_member_not_in_team). The lineup +// refusal (errLineupMemberNotInTeam) says the same thing about a fielded position. +var errRepMemberNotInTeam = &selfRunRefusal{ + status: http.StatusBadRequest, + code: "team_member_not_in_team", + message: "The representative chosen is not on this team. Pick again from the list.", +} + +// repMembersOutsideTeams judges the member ids a representative bout row names +// against the team that holds each side. Ids are bare UUIDs, so the team's own +// squad is the only thing that says whose they are: a pick of another team's +// member, or of an id no team holds, is refused rather than written. The +// organiser's write is not judged here (only the self-run path calls this). +func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult) error { + if row.SideAMemberID == "" && row.SideBMemberID == "" { + return nil + } + squads, err := stx.LoadSquads(compID) + if err != nil { + log.Printf("mobileapp: representative member check for %s: %v", compID, err) + return err + } + if row.SideAMemberID != "" && !teamMemberIDs(squads, pairing.SideAID)[row.SideAMemberID] { + return errRepMemberNotInTeam + } + if row.SideBMemberID != "" && !teamMemberIDs(squads, pairing.SideBID)[row.SideBMemberID] { + return errRepMemberNotInTeam + } + return nil +} + // isMatchFinalized reports whether the given stored status represents a // concluded match. Any completed match is finalized, anonymous callers must // not overwrite it regardless of whether a winner was explicitly recorded. diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index fa7a50568..f60037c6c 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -957,6 +957,39 @@ func TestDaihyosenResponses_CarryNoAuditFields(t *testing.T) { }) } +// bc-dhrp: a participant's pick of a side's representative names a member of +// THAT side's team, the same rule a lineup places a member by (400 +// team_member_not_in_team). The organiser's pick is not judged here. +func TestSelfRun_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA, membersB := squads[repBoutTeamAID], squads[repBoutTeamBID] + require.NotEmpty(t, membersA, "team A is seeded with members") + require.NotEmpty(t, membersB, "team B is seeded with members") + + pick := func(sideAID, sideBID string) map[string]any { + row := repBoutRow([]string{}, []string{}, "") + row["sideAMemberId"] = sideAID + row["sideBMemberId"] = sideBID + return row + } + + w := f.score("", state.MatchStatusRunning, "", f.now+100, pick(membersA[0].ID, membersA[0].ID)) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") + assert.Empty(t, f.storedRepBout(t).SideBMemberID, "a refused pick writes nothing") + + w = f.score("", state.MatchStatusRunning, "", f.now+200, pick(membersA[0].ID, membersB[0].ID)) + require.Equal(t, http.StatusOK, w.Code, "each side's own member is accepted: %s", w.Body.String()) + stored := f.storedRepBout(t) + assert.Equal(t, membersA[0].ID, stored.SideAMemberID) + assert.Equal(t, membersB[0].ID, stored.SideBMemberID) + + w = f.score("main-pw", state.MatchStatusRunning, "", f.now+300, pick(membersB[0].ID, membersB[0].ID)) + assert.Equal(t, http.StatusOK, w.Code, "the organiser's pick is not judged on membership: %s", w.Body.String()) +} + // A password sent but wrong is answered 401 before the body is read, so a // stale organiser is not told about their clock or their body instead. func TestDaihyosen_AWrongPasswordIsRefusedBeforeTheStamp(t *testing.T) { diff --git a/internal/state/match_groups_table_test.go b/internal/state/match_groups_table_test.go index 30dc73614..9847dca01 100644 --- a/internal/state/match_groups_table_test.go +++ b/internal/state/match_groups_table_test.go @@ -148,3 +148,17 @@ func TestMergeReport_HeldDecisionRequiresTheDefaultWinStandsReason(t *testing.T) assert.Empty(t, (*MergeReport)(nil).HeldDecision(), "a nil report names no decision") } + +// bc-dhrp: a representative pick is a change of the bout row it sits on. The row +// is copied whole when its group applies, so a pick added or cleared is a +// difference in the group, and the copy takes the row exactly as sent. +func TestBoutGroup_RepresentativeMemberIDIsAChange(t *testing.T) { + group := BoutGroup(DaihyosenSubPosition) + picked := &MatchResult{SubResults: []SubMatchResult{{Position: DaihyosenSubPosition, SideA: "TeamA", SideB: "TeamB", Decision: "daihyosen", SideBMemberID: "rep-b"}}} + cleared := &MatchResult{SubResults: []SubMatchResult{{Position: DaihyosenSubPosition, SideA: "TeamA", SideB: "TeamB", Decision: "daihyosen"}}} + assert.True(t, GroupDiffers(picked, cleared, group), "a pick cleared is a change of the row") + + dst := &MatchResult{SubResults: CloneSubResults(picked.SubResults)} + CopyGroup(dst, cleared, group) + assert.Empty(t, dst.SubResults[0].SideBMemberID, "the copy takes the row as sent, so a cleared id clears") +} diff --git a/internal/state/team_result_test.go b/internal/state/team_result_test.go index 04c9e967c..23258cc34 100644 --- a/internal/state/team_result_test.go +++ b/internal/state/team_result_test.go @@ -527,3 +527,19 @@ func TestTeamResultFrom_DefaultWinCredit(t *testing.T) { assert.Equal(t, &TeamResultLine{ShiroIV: 1, ShiroPW: 2}, got, "only position 1 counts, credited to TeamB") }) } + +// bc-dhrp: the representative bout names the two TEAMS and carries each side's +// representative as a member id. The winner is the side the bout was won for, +// read by the winner's id when one is stamped and by the team name when not. +func TestSubBoutWinnerSide_RepresentativeBoutWithMemberIDs(t *testing.T) { + rep := func(winner, winnerID string) SubMatchResult { + return SubMatchResult{ + Position: -1, SideA: "TeamA", SideB: "TeamB", + SideAMemberID: "rep-a", SideBMemberID: "rep-b", + Winner: winner, WinnerMemberID: winnerID, Decision: "daihyosen", + } + } + assert.Equal(t, domain.MatchSideB, SubBoutWinnerSide(rep("TeamB", "rep-b"), "TeamA", "TeamB"), "by the winner's id") + assert.Equal(t, domain.MatchSideB, SubBoutWinnerSide(rep("TeamB", ""), "TeamA", "TeamB"), "by the team name when no id is stamped") + assert.Equal(t, domain.MatchSideA, SubBoutWinnerSide(rep("TeamA", "rep-a"), "TeamA", "TeamB"), "side A by its id") +} diff --git a/web-mobile/js/__tests__/match_groups_table.test.jsx b/web-mobile/js/__tests__/match_groups_table.test.jsx index e2cccd756..b2ce06201 100644 --- a/web-mobile/js/__tests__/match_groups_table.test.jsx +++ b/web-mobile/js/__tests__/match_groups_table.test.jsx @@ -152,3 +152,30 @@ describe('the serializer', () => { expect(changedGroups(write, matchWire(held))).toEqual([]); }); }); + +// bc-dhrp: the representative bout's member ids are its pick. The server copies a +// named bout row WHOLE, so a pick cleared (the key left out) must name the group, +// or the stored id survives. Numbered rows keep the rule they always had. +describe('the member ids of a bout row (bc-dhrp)', () => { + const bout = (position, extra = {}) => ({ + position, sideA: 'Team A', sideB: 'Team B', ipponsA: [], ipponsB: [], winner: '', decision: 'daihyosen', ...extra, + }); + + it('a representative pick cleared (key omitted) names bout:-1', () => { + const stored = { subResults: [bout(-1, { sideBMemberId: 'm1b' })] }; + const next = { subResults: [bout(-1)] }; + expect(changedGroups(next, stored)).toEqual(['bout:-1']); + }); + + it('a representative pick added names bout:-1', () => { + const stored = { subResults: [bout(-1)] }; + const next = { subResults: [bout(-1, { sideBMemberId: 'm1b' })] }; + expect(changedGroups(next, stored)).toEqual(['bout:-1']); + }); + + it('a numbered row whose id is left out is not a change, as before', () => { + const stored = { subResults: [bout(1, { sideBMemberId: 'm1b', decision: '' })] }; + const next = { subResults: [bout(1, { decision: '' })] }; + expect(changedGroups(next, stored)).toEqual([]); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx new file mode 100644 index 000000000..d8081d846 --- /dev/null +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -0,0 +1,258 @@ +// bc-dhrp: the daihyosen (representative bout) row lets the operator pick each +// team's representative by member id. The row keeps the TEAM names in sideA/sideB +// (placeHt compares the winner against them); the picks ride sideAMemberId / +// sideBMemberId. These tests mount the REAL ScoreEditorModal dispatcher, the same +// route every production mount uses, as team_daihyosen_silence.render.test.jsx does. +import React from 'react'; +import { render, act, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { toBackendMatchResult } from '../../api_serializers.jsx'; + +const SQUADS = { + t1: [{ id: 'm1a', name: 'Alice', index: 1 }, { id: 'm2a', name: 'Brenda', index: 2 }], + t2: [{ id: 'm1b', name: 'Carol', index: 1 }, { id: 'm2b', name: 'Dana', index: 2 }], +}; + +const STUBBED_GLOBALS = { + isHikiwake: (_type) => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: (_kind) => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + fetchSquads: vi.fn(), + fetchTeamLineup: vi.fn().mockResolvedValue({ saved: false, positions: {} }), + fetchLineupInForce: vi.fn().mockResolvedValue({ saved: false, positions: {} }), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn().mockResolvedValue({}), + recordDecision: vi.fn(), + addTeamMember: vi.fn(), + renameTeamMember: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + // The real member resolver (resolveMemberIdsForPositions) is the one mint and + // rename path a typed representative name takes; it is not re-stubbed here. + await import('../../admin_lineup.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + window.API.fetchSquads.mockReset().mockResolvedValue(SQUADS); + window.API.fetchLineupInForce.mockReset().mockResolvedValue({ saved: false, positions: {} }); + window.API.recordScore.mockClear(); + window.API.addTeamMember.mockReset(); + window.API.renameTeamMember.mockReset(); + window.API.putMatchLineup.mockClear(); +}); + +// Numbered bouts 1..3 are recorded as ties so Finish is allowed (bc-tmfn), as in +// the silence test. The daihyosen row is the one under test. +const FOUGHT_BOUTS = [1, 2, 3].map((position) => ({ + position, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: 'hikiwake', +})); + +const DH_EMPTY = { position: -1, sideA: 'Team A', sideB: 'Team B', decision: 'daihyosen' }; + +function makeMatch(overrides = {}, { fought = true } = {}) { + const { subResults = [], ...rest } = overrides; + return { + id: 'm-ko-1', + compId: 'comp1', + status: 'running', + phase: 'knockout', + court: 'A', + compKind: 'team', + teamSize: 3, + sideA: { id: 't1', name: 'Team A' }, + sideB: { id: 't2', name: 'Team B' }, + ...rest, + subResults: [...(fought ? FOUGHT_BOUTS : []), ...subResults], + }; +} + +function dhEntryOf(patch) { + return (patch.subResults || []).find((s) => s.position === -1); +} + +async function mount(match, { selfReport = false } = {}) { + const onSubmit = vi.fn(); + const view = (m) => ( + + ); + let utils; + await act(async () => { utils = render(view(match)); }); + return { ...utils, onSubmit, rerenderWith: async (m) => { await act(async () => { utils.rerender(view(m)); }); } }; +} + +// The daihyosen row's two name boxes, by their aria-label ("Daihyosen SHIRO player"). +function dhInputs() { + return screen.queryAllByLabelText(/^Daihyosen (SHIRO|AKA) player$/); +} +function dhInput(side) { + return dhInputs().find((el) => el.getAttribute('aria-label').includes(side)); +} + +async function openDh(side) { + const input = dhInput(side); + await act(async () => { fireEvent.focus(input); fireEvent.click(input); }); +} + +async function pickFromDh(side, name) { + await openDh(side); + const opt = await screen.findByText(name, { selector: '.pmf__opt-name' }); + await act(async () => { fireEvent.click(opt.closest('button')); }); + // Let the pick's member wait and write settle. + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); +} + +async function clickFinishTwice() { + // First tap arms the button; the second commits (buildPatch -> onSubmit). + await act(async () => { fireEvent.click(screen.getByText('Finish')); }); + await act(async () => { fireEvent.click(screen.getByText('Tap again to finish')); }); +} + +describe('team daihyosen representative picker (bc-dhrp)', () => { + it('R1: the daihyosen row renders a name box on each side, and never the team name in it', async () => { + await mount(makeMatch({ subResults: [DH_EMPTY] })); + await waitFor(() => expect(dhInputs().length).toBe(2)); + expect(dhInput('SHIRO').value).toBe(''); + expect(dhInput('AKA').value).toBe(''); + }); + + it('R2: picking a member records its id on the -1 row, keeps the team names, and names bout:-1', async () => { + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit } = await mount(match); + await pickFromDh('SHIRO', 'Carol'); + expect(dhInput('SHIRO').value).toBe('Carol'); + await clickFinishTwice(); + expect(onSubmit).toHaveBeenCalledTimes(1); + const patch = onSubmit.mock.calls[0][0]; + const dh = dhEntryOf(patch); + expect(dh.sideBMemberId).toBe('m1b'); + expect(dh.sideA).toBe('Team A'); + expect(dh.sideB).toBe('Team B'); + // The wire the server gets: the id rides the row and the write names its group. + const wire = toBackendMatchResult(patch, match); + const wireDh = wire.subResults.find((s) => s.position === -1); + expect(wireDh.sideBMemberId).toBe('m1b'); + expect(wireDh.sideB).toBe('Team B'); + expect(wire.changed).toContain('bout:-1'); + // A pick is never a lineup write: "daihyosen" is not a lineup key. + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('R3: a point recorded elsewhere is adopted after the pick round-trips', async () => { + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit, rerenderWith } = await mount(match); + await pickFromDh('SHIRO', 'Carol'); + // The server echoes the pick back (stamped after it), then another device scores a point. + const echoed = makeMatch({ + modifiedAt: Date.now() + 60000, + subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b' }], + }); + await rerenderWith(echoed); + const scored = makeMatch({ + modifiedAt: Date.now() + 120000, + subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b', ipponsB: ['M'] }], + }); + await rerenderWith(scored); + await clickFinishTwice(); + const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + expect(dh.ipponsB).toEqual(['M']); + expect(dh.sideBMemberId).toBe('m1b'); + expect(dh.sideB).toBe('Team B'); + }); + + it('R4: clearing a stored pick omits the id and names bout:-1 on the next write', async () => { + const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b' }] }); + const { onSubmit } = await mount(match); + // The stored pick shows as the member's own name, never the team's. + expect(dhInput('SHIRO').value).toBe('Carol'); + const wrap = dhInput('SHIRO').closest('.lineup-name'); + const clear = within(wrap).getByRole('button', { name: 'Clear player' }); + await act(async () => { fireEvent.click(clear); }); + await clickFinishTwice(); + const patch = onSubmit.mock.calls[0][0]; + const dh = dhEntryOf(patch); + expect('sideBMemberId' in dh).toBe(false); + expect(dh.sideB).toBe('Team B'); + expect(toBackendMatchResult(patch, match).changed).toContain('bout:-1'); + }); + + it('R5: the representative roster includes a member already placed at a numbered position', async () => { + // Carol is fielded at position 1 of the team's lineup for this match: a placed + // member the numbered pickers filter out, and the representative picker must not. + window.API.fetchLineupInForce.mockImplementation(async (_compId, teamId) => (teamId === 't2' + ? { saved: true, positions: { 1: 'Carol' }, memberIds: { 1: 'm1b' }, sourceMatchId: 'm-ko-1' } + : { saved: false, positions: {} })); + const match = makeMatch({ + subResults: [ + { position: 1, sideA: 'Alice', sideB: 'Carol', sideAMemberId: 'm1a', sideBMemberId: 'm1b', ipponsA: [], ipponsB: [], decision: '' }, + DH_EMPTY, + ], + }, { fought: false }); + await mount(match); + await openDh('SHIRO'); + expect(await screen.findByText('Carol', { selector: '.pmf__opt-name' })).toBeTruthy(); + }); + + it('R6: a typed name matching no team member mints one through the member resolver and records its id on the row', async () => { + window.API.addTeamMember.mockResolvedValue({ id: 'm9', name: 'Eve', index: 3 }); + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit } = await mount(match); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); + await clickFinishTwice(); + const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + expect(dh.sideBMemberId).toBe('m9'); + expect(dh.sideB).toBe('Team B'); + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('R7: under selfReport the representative picker is offered while the match runs', async () => { + await mount(makeMatch({ subResults: [DH_EMPTY] }), { selfReport: true }); + await waitFor(() => expect(dhInputs().length).toBe(2)); + }); + + it('R8: a name typed over a picked blank member renames that member and keeps its id', async () => { + window.API.fetchSquads.mockResolvedValue({ ...SQUADS, t2: [...SQUADS.t2, { id: 'm3b', name: '', index: 3 }] }); + window.API.renameTeamMember.mockResolvedValue({ id: 'm3b', name: 'Frank', index: 3, modifiedAt: 5 }); + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit } = await mount(match); + await openDh('SHIRO'); + const blank = await screen.findByText('no name yet', { selector: '.pmf__opt-name' }); + await act(async () => { fireEvent.click(blank.closest('button')); }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Frank' } }); }); + const add = await screen.findByText(/Add “Frank”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.renameTeamMember).toHaveBeenCalledWith('comp1', 't2', 'm3b', 'Frank', '')); + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + await clickFinishTwice(); + const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + expect(dh.sideBMemberId).toBe('m3b'); + expect(dh.sideB).toBe('Team B'); + }); +}); 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) || "" })); +} + // 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 @@ -1944,6 +1960,44 @@ 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 (resolveMemberIdsForPositions, + // admin_lineup.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, and + // the members the resolver answers are shown. No lineup is written: "daihyosen" is no + // lineup key. Only the resulting member id is stored on the row. + const submitRepresentative = async ({ side, noticeKey, idx, value, priorId }) => { + const notify = (tone, text) => { if (mountedRef.current) setLineupNotice({ key: noticeKey, tone, text }); }; + setLineupNotice(null); + const sideChanged = watchLineupSide(side); + const changedNotice = () => { + if (!sideChanged()) return false; + notify("error", SIDE_TEAM_CHANGED_NOTICE); + return true; + }; + const waiting = membersWait.current[side].pending(); + if (waiting) await waiting; + if (changedNotice()) return; + const resolve = window.AdminLineupHelpers?.resolveMemberIdsForPositions; + if (typeof resolve !== "function") { + notify("error", `"${value}" could not be named: the team members are not loaded. Try again in a moment.`); + return; + } + const teamId = sideSeen.current[side].team; + const resolved = await resolve(m.compId, teamId, { [REPRESENTATIVE_KEY]: value }, membersShown.current[side], password, { [REPRESENTATIVE_KEY]: priorId }); + if (!mountedRef.current || changedNotice()) return; + wroteSideMembers(side, resolved.squad); + const id = resolved.memberIds[REPRESENTATIVE_KEY] || ""; + if (!id) { + const failure = (resolved.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 = resolved.squad.find(mem => mem && mem.id === id) || { id }; + pickDaihyosenRepresentative({ idx, side, updateSub }, member); + }; + // 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 +2067,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 @@ -2683,6 +2744,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; @@ -3005,8 +3070,8 @@ 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 = ""; if (isKachinuki && !isDaihyo) { const { aName, bName, aMemberId, bMemberId } = playerNamesForBout(idx); @@ -3044,10 +3109,24 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // decides which side that is by comparing the winner to this row's // own sideA/sideB (placeHt, api_serializers.jsx). Blanking them there // dropped the Ht from the wire, which a test caught. A rep bout is - // won by a team, so naming the teams on that row is honest anyway. - sideA = isDaihyo ? sideAName : ""; - sideB = isDaihyo ? sideBName : ""; - winner = teamWinnerName; + // won by a team, so naming the teams on that row is honest anyway. The + // representatives' member ids ride beside those names (bc-dhrp). + if (isDaihyo) { + // bc-dhrp: the representative bout keeps the TEAM names (above) and adds + // the two representatives' member ids, the operator's picks on this row. + // The winner's id is the id of the side the bout was won for. + const { aMemberId, bMemberId } = playerNamesForBout(idx); + sideA = sideAName; + sideB = sideBName; + winner = teamWinnerName; + sideAMemberId = aMemberId || ""; + sideBMemberId = bMemberId || ""; + if (winner) winnerMemberId = (wKey === "a" ? aMemberId : wKey === "b" ? bMemberId : "") || ""; + } else { + sideA = ""; + sideB = ""; + winner = teamWinnerName; + } } const entry = { position: isDaihyo ? DAIHYOSEN_POSITION : idx + 1, @@ -3974,6 +4053,22 @@ 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); + if (member && member.id) { pickDaihyosenRepresentative({ idx, side, updateSub }, member); return; } + if (!value) { pickDaihyosenRepresentative({ idx, side, updateSub }, null); return; } + submitRepresentative({ side, noticeKey, idx, value, priorId }); + }; // Each row: [left side, center score, right side]: left=SHIRO, right=AKA // T096/FR-031: manual pts/fouls edits end the per-bout fusensho @@ -4006,8 +4101,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 @@ -4018,10 +4113,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // from this same prop. playerName: resolveBoutSideDisplayName({ squad: squadB, memberId: playerBMemberId, storedName: playerBName }), memberId: playerBMemberId, - memberLabel: playerBLabel, roster: isDaihyoRow ? [] : rosterB, forceInput: manualPathB, + memberLabel: playerBLabel, roster: isDaihyoRow ? repRosterFor(m.sideB, lineupB, squadB) : rosterB, forceInput: isDaihyoRow ? repPickable : manualPathB, noticeKey: noticeKeyB, lineupSlot: !isDaihyoRow && idx + 1 <= teamSize, - onSelectName: manualPathB ? pickManual("b", noticeKeyB, squadB, teamBId) : pickPlayer("b", noticeKeyB), + onSelectName: isDaihyoRow ? pickRepresentative("b", noticeKeyB, s.bMemberIdOverride) : (manualPathB ? pickManual("b", noticeKeyB, squadB, teamBId) : pickPlayer("b", noticeKeyB)), }, { key: "a", tapKey: `${idx}:a`, pts: s.aPts, fouls: s.aFouls, @@ -4032,14 +4127,14 @@ 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 }), memberId: playerAMemberId, - memberLabel: playerALabel, roster: isDaihyoRow ? [] : rosterA, forceInput: manualPathA, + memberLabel: playerALabel, roster: isDaihyoRow ? repRosterFor(m.sideA, lineupA, squadA) : rosterA, forceInput: isDaihyoRow ? repPickable : manualPathA, noticeKey: noticeKeyA, lineupSlot: !isDaihyoRow && idx + 1 <= teamSize, - onSelectName: manualPathA ? pickManual("a", noticeKeyA, squadA, teamAId) : pickPlayer("a", noticeKeyA), + onSelectName: isDaihyoRow ? pickRepresentative("a", noticeKeyA, s.aMemberIdOverride) : (manualPathA ? pickManual("a", noticeKeyA, squadA, teamAId) : pickPlayer("a", noticeKeyA)), }, ]; @@ -4142,7 +4237,7 @@ 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 the judges decided for a participant, 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 +4256,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 +4270,8 @@ 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 the judges + decided for a participant offers no picker, so it never gets LineupNameInput's ` player` aria-label. Removing the visible SHIRO/AKA chip (operator ruling: the side is named once, by the diff --git a/web-mobile/js/lineup_resolver.jsx b/web-mobile/js/lineup_resolver.jsx index 1c136bb93..b1736bea0 100644 --- a/web-mobile/js/lineup_resolver.jsx +++ b/web-mobile/js/lineup_resolver.jsx @@ -515,9 +515,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 +529,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,7 +577,9 @@ 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 never +// reaches this function: its identity is the operator's pick (playerNamesForBout, +// bc-dhrp). 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 diff --git a/web-mobile/js/match_groups.jsx b/web-mobile/js/match_groups.jsx index b58f83258..2530997ca 100644 --- a/web-mobile/js/match_groups.jsx +++ b/web-mobile/js/match_groups.jsx @@ -226,6 +226,11 @@ function groupKey(wire, group, next) { const keys = new Set(Object.keys(own || {}).filter((k) => own[k] !== undefined)); keys.add('encho'); keys.add('position'); + // bc-dhrp: on the representative bout the member ids are the pick. A + // pick cleared leaves its key out, and the server copies a named row + // WHOLE, so an absent id must read as empty here or the clear is never + // named and the stored id survives. Numbered rows keep the rule above. + if (pos < 0) { keys.add('sideAMemberId'); keys.add('sideBMemberId'); keys.add('winnerMemberId'); } // 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())); From a3306ca19e1483660d8a6bcec26d70b37b2404f2 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 16:19:01 +0100 Subject: [PATCH 08/38] docs(bc-dhrp): the read-only representative row comments name every case A participant's sheet shows the row read-only while the match is not running as well as once the judges decided it, and it shows the picked representative or a dash, never the team name. Co-Authored-By: Claude Opus 5.5 --- web-mobile/js/admin_scoring_team.jsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 2111a02a1..ba473c957 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -4237,7 +4237,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 the judges decided for a participant, 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. */} @@ -4270,8 +4271,11 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub onSelect={rs.onSelectName} /> ) : ( - /* The read-only branch: a daihyosen row the judges - decided for a participant offers 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 "-", never the team name. It never gets LineupNameInput's ` player` aria-label. Removing the visible SHIRO/AKA chip (operator ruling: the side is named once, by the From 377e803c7a4432ecc5da091582844a055ba55652 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 16:23:20 +0100 Subject: [PATCH 09/38] test(bc-tmwn): the truncation pin reads past the winner tick's slot The viewer name cell now holds the Recent results tick's slot beside the name, false when no tick is drawn, so its children are an array. The pin is unchanged: a joined string is still the cell's one child and fails. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/vsched_number_survives_truncation.test.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web-mobile/js/__tests__/vsched_number_survives_truncation.test.jsx b/web-mobile/js/__tests__/vsched_number_survives_truncation.test.jsx index 4251cdb7e..445027614 100644 --- a/web-mobile/js/__tests__/vsched_number_survives_truncation.test.jsx +++ b/web-mobile/js/__tests__/vsched_number_survives_truncation.test.jsx @@ -54,11 +54,16 @@ describe('the competitor number is a chip, not trailing text', () => { vi.resetModules(); }); - // The name cells, in DOM order: Shiro first, then Aka. + // The name cells, in DOM order: Shiro first, then Aka. The cell also holds + // the Recent results winner tick's slot (first on Shiro, last on Aka), which + // is `false` when no tick is drawn, so the name is the one child left. const nameCells = () => { const m = { id: 'm1', status: 'scheduled', court: 'A', sideA: AKA, sideB: SHIRO }; const tree = runtime.mount(VSchedItem, { m, tweaks: {} }); - return findAll(tree, n => hasClass(n, 'n')).map(n => n.props && n.props.children); + return findAll(tree, n => hasClass(n, 'n')).map((n) => { + const kids = [].concat(n.props && n.props.children).filter(Boolean); + return kids.length === 1 ? kids[0] : kids; + }); }; it('hands each side a NumberedName in clip mode, never a joined string', () => { From 7302c964697ceba903c4cf58644d5967110f0b39 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 16:41:32 +0100 Subject: [PATCH 10/38] docs: recapture the three screenshots this change alters console-correct-completed (the court's Completed list names each winner with a tick), viewer-competition (Recent results carry the tick and now list the drawn Pool A bout), viewer-result-encho (the tick). Captured with make docs/screenshots SINCE=main; the other 28 were unchanged. Co-Authored-By: Claude Opus 5.5 --- .../screenshots/console-correct-completed.png | Bin 97390 -> 97983 bytes docs/screenshots/viewer-competition.png | Bin 104841 -> 105332 bytes docs/screenshots/viewer-result-encho.png | Bin 106099 -> 106543 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/screenshots/console-correct-completed.png b/docs/screenshots/console-correct-completed.png index 1538f92e6882b1a81743b8ffe3f205bd5b8e9855..6590846b2b6d311bee688fc6cf6a970177c62038 100644 GIT binary patch literal 97983 zcmc$_Rajep&^6kYDimmuQoJq2-6dFY5AIT+NYGLsXrQzOiWhec4#C~h;_eQ?U4mO6 z@TL8K@3}gc?{f}Un@93py{5nhj`>oXhr?xP?5&f&CM}#1&QQy>3+?%(V5~V%E!0CFlHF=C@S^_xERyuYD?z$Q}=gWRqLBp`-T%&#*AxjrrS;#{QqHR}z%} zUJ?fcs6P33H5~qS{QkeID)XnV|1R$Bzy9C-SOh2?`9wHBKYwolRwVxBqrvFtC?P?# zyP?BS8X_^zd1LPiHV1C1vcBzp*+!NsKu5>N49If;qz@g8AOiopJ8StNmT~zX7D`(8 zX-dr6V?(9J$7ci#Wu#-5v0t}P1?K(XnVCEAkpnR;EhrNbnv;cv026Lo=TlPV;gjfI z;p))-;$41+aBBWsM#9p?GSJWh+I4PVpdWc;4o8%${dxY^2IHmr48|FJ)n5CJA{zI^4Pm0_BeZ*+_JR<%6Wm__nk)HD8g0Xx1_5C)w=S z6h0TO&VvNXOA#x{&lA!$=v%*~bsBxd_r#)J&zU4QHy3i&Va48`(90(yB?CF_xeEw; zy@_1s_MBgn42)1Wm$t~s2|Jlu^uAlyS)7?}toH!tFfiaV-8n4s`mDGleZ=@hFH!G! zJ)~m=c84@9vo3IpH->hqpj;;6kDdhulXKXt9o8;-hfzUg!ZXx>KsT_|L*XR}#Zk_H6o_v9Zr>aXk)0Rny7jE<0<|kZ5dN z8Yif?-+7ZQoSfft`J0EH-VC*O71Q;yg=6FSUZczU?6C#=#nwVt)KDqBG_MouNrdWC z76u00teFq`&#nvvJ!(~4&w0(*T3vJBp2WnW1ZX1$|`gTiMhk6Fk+nbBiZxoJ> zyGK^PKfyOV>LtL%J;U{N6#a7g!~1P1{0jN|KAJ9++NW2a!H2?r0)(vkZ?^L1`zG~vIf z#@Eu+%*ox%FF3+Ib4^=ns@x=`WRS$7ZUtB@{{MMXJU89b0P7iN@+ZPGR(|O z`1^~Xj~~S?c$hLeIy==?GuyhhHfvbPDJdzfd)V3B6ARVH)s2jrAzf@VG;OQ0=anKA zs&dekxe;^vSMaKgW!5I@7w#7&ViQMvca>@Oi6V|8)wcW+5^ZE@k%gwzzLyKkeesw? zriuvFw`}IvkB-n$h)}j5_OG3;bEl{8x{tmF?~j5ym=y~UGd)f-%#QDA>HA2y90yqx zOfBf%a=Z)*E}?sX?=29o%1n%@qAM1^-bb%N>^!|ZMu+O^E@f)N!a`Do zJbDbQjj>(J%a1gxZQU2P$8uG(q9efjvx$_$arVyA{w~QT37ltJ<p~kpZXrkaBRVE!Bw8B*BE6f0hmMbr@F!Lu_XbbP( z8Y#}I357~o+2wcrj#gJ&eoek@u}P%LpsgWY;za-?*^vr^OvVfE?Z}=tKMMQ&x+dpO zDW{Y&EByAdt+vu?V0dKYYGaOi!QiT=gfe*@=;7s+l$co2X5JOPGvAX;xlzb~uNj;N zC=w0*yf?)vjD+e<8+#@l^F9ZJt?cY1>%1#Kyd{8`99`Bh&TMojY8%#jHd_rLV=Px! z32lk*5nB~F#^d7RdY6J$w|{ZSdfG_Q~w5J|^bK7?@ej=aSVgifDy|0tgVl z{$Sx#PooD7N*e|E58m->XlQ6@-G?x?QoZw3g++uDR8ai&$=Lcj$?h&_xwy~jn`qL` zwsq!W4E;$=Ezb-78=i@uH{Z-lFa2lq$i{YwQe!gNRLMu@D(k9lDom{H)HkO6j1Uwo zHYCU-pJV`-)es4^Z6O!5T+vzd2EKHd{`JS!=Vl#KJJgy(g57=ZD%VkA$;@%=ppVbc zsI#MH53iBFzXv~UWpM%WWCrXf&fz`1gh}kMo5L{@OQpWS!7FFD{;V@kVNRithnI9g=kUYE3##Ga@$Q~MLC+haF{KHKx9HHI zu({ehRa4XDT-LeQprz*u3JMOrdof!9xRere=a{mIOD)R7%^iLGnLl0D?Te0rvQ~9g z*2-z3vxt{V9uxK7sN$m;h9@zU#zs> z27v?xAJO`Etg9W7;^g5?s_Tfjac{Lxe2ppg*3uF^VX12nf}?szD=I2#7tvn{#)rfp zK;GY&Q0z|-JP2Pz07X50mww;k_P3m_OGncy%gM=QGq~@J&tTG)_U$;o3EFXfdIVF1 zbEIMJ`>f8+mGC=MTm7?G&R!n_KKrc^=v=&suk48rd}Ff(;%Sel@@whKgypR0e)#Yw zLJaM>8@@;lN4N7tHzx1qN47;rYgqd-_WE31S!vp356h931}SEs$pDt)lfG`lHO=Rp zjflppo4Sp;04q{1DKZNaW7N(*z~?W(`h5b+E}j<$HtJQ%y^or2y7`&^+H+VOVf*>F zxJhGnRf?bGits?Py!y1NY3joylCjzpm>FDdWYtK_$IkAQquqdiChhh#IHn@aj=onX$gHF%?O=_S=*q}E#@QH5-~hi*UmVW+vz1WS znw1nyp!Mj1tSlhRQ*=HzH>Y&&@_;|xH)GoUs?bsW%g#3scDwniw!Sf#;{j4Bn$hT^ zVqbSJH`lOzmDQh(-hqLT(BRzrtjfwBCIO43dbXwxw<}FEN`Q-)Jd??g3Nu~1-dpZk zR~Mdl1fyP_fWprLwbW98nFI>B_u|Z;_fgfKaBVtP&zJGBu{kR+JhB`p6p`l!%mE%< zd3X4*Bui^(R4Q80!rz>poqC?zb&3@>Fwpj%5Yx78&EP7R1{RMuL9W{T+d1X79JiJ?oyht&b^eT z_BBnivf4^c9&2OBH&J^}k2)?`$UC-Xt2Fz3fXn696KYr%?cChVqsNafcIUk2;=*if zY&6|C^EE0N8CXJp{@gKDKXWBT`Dc2ZxXFJ(f%?VqijmpHwRC2w6w+uc9Nw%WfyUD zN{!*P1VM?GS>q$0J(R-$s&)1C!Fy|ha4&qD z)X2?`j<(hexEwD1+cSvk@OIkwJ?S}}UKd!>SxGIdTwIk~Wkm%Ay^@sDV{Kh3CKt_Z zB{7A*1t8Kr4IXoO5F;*8e9S0wUpG^<%f|=`F-g`vp3??ZyIhi=X=Y^amre?)d|nY?5A7IFAN+@v$qS5 zFln@~kO|u_na?pKJiMS<$ntR>wlr_Ob(VtCHZ275i- zW|WgwY9nCN`LEloqCN1b7o}k?)izg1Uj6#}f4Bh8^F4XVU6rQuDclEsAJ25CZEb9< zt@AMr11*C<|8#fvSljw{V9S;Bs&=8_KEQpiw>nw!$%T%FR%+#E|AYNT*@*OJ*XxB5 zLaWAW7d~!Y?b*7kMG+J46AgL!?H+Cthf7o#g=oT;Spc9@OY=!oPJP7x+@j#0?V)a)Sb~ny)bK0peFl|2W3~crSL;WPXR04lUA6|K5f6O#h!@9n}!;R?e z86fA$7{a%mauT2bkngTLdcSx{3d<|1w752ARmP>41th8Yw`H1JJ6M`A@56`yb@f0z(rhRcR+=}J2V{5S*CD%IY8%RjEPeHHY&sL|;MO3Fo^wMkG;hM)F5vq5 z`fak|aPKqJ{+tiLBSNqC_5cH6EF77}V) z0YqP4jt#A@uHs;agj0y5B}W$(Q#zzrS#YgI>a#{WYdV)>mT>c}7_NA1(Z3xLpK?U@4J%3b0d$rWA_VY?Nhf;lx zj&T*9+P0wWu5%Z`jt11}zm#Zcz+|W@i1m<|?73s6DctM60y$%i{cOYafR0IS#RRo2 z{MmE7MhsUFMNS;zV{&~~0`nbA6!e!^`sD|q3#Bx*F>_hp`{QX@|B{mMVsmJ5Z=uFv zr7lv5o3~s&K03PAYH_62*dVKveZOM4z2GDq4@}*BCnU0G2Lq-ycgr*}QmJ3EQP zjc@V46|`KuVW^U=9rXK<$wNvjWV3jDkoE`bn#JH|g;|kgxUU>EN9C!&5*bP*xQ4DZ z0iBL2oS!OgB%+S49rNR+D~B@F%;_Pw>$ODcEUc_V^a7BLy+WXv{@IKypw=;Knt)J4 zLw6TZ)Rks5r;;v^os;8nfrMP07Kz^3V446Q{gZv5n2#)$iFZL$*B}3rO?A%Cll|L$ z@obj*>LG?2B$2nVOQBxhRH@ zE>F%%?DJ)NqI3Mr7>GuV96e!jEnAu6SBd246V zkX`mqZdS!cK!2>3*6~(WUS8ia2kxnf`z8`SUH1??{pSz(=$j|tJR>O~GV*i>prz#( zR&AkRqYrzgu0Dn^v=uT-5b${~vIY#J;#@mm1>l4PN2Ch@T@|keO7dSMCB+?0iOpSA zAkmnb$B;luN~WsJ&cnjB^noK5*B5oh?%6@}&Ky(u7aAarpRJ{SV{Pvzg@h=sSxtkt zPoFnPcyp>s0@IcI z2=8o1O;=af!VxWDVd*$LIZe&buC^A|rY2$SyTdWxx%ll$eAg=e67WWq&I#Rf4&Gtq zKDLZ9=3ho~`bN@@#>)x!jRMZjMpd<3Yq&L99tJ%W#K(9d1Ljmw`}}E8!CbzJnSnvk z@S$9=X$;%znRP*Px&i2e@ake}07|nvjh}(sx>LDlTr-C-f=qOJq9gM0w{LCD>*V zvbLOSR2j0~0ewEaT~B9A6(&#fZ$1q-%M+KDp1x^#VW7P7Eczg##AA52pidx*@jM4Fwy}PpT3m(a8 zXh;&d49?G~tJ@IbS1>HtCUs!rC5`9}!XVxTq*tJVcPYECyeQ5iOC}`b4K1P$1_djPUk}6CMHYN7UNn_n{P5ggGotA)wwZZb3*#rMNx-!;}}$S_XVBV{o@qVi=k8`-oG08ljX~o zW3gV|lekzcR)I=4e>=jca8f*K{*NiYgAt$5^Oi^Urwa0l!4IKYi)yjk3+Uga+IQ;O z#-g88#cBj<4wjry9_U=^Scvd^@(4T&3Z;asL<9#xr*omOX3!jRFU8PJ$I5eOx~DQH z$3pD3>9!3BU(c6Q%g%n4Q(1QZV4QUO*{?mpzyS%~qMclbO}dy{KwR9$9J3gEY5o>+ z%wzxJ?&SD0l=aGDAl^vG3quLFT-`F!NTNwLmCTdmH8g0rHD=qSKdR>6s66wydh}9l zbV<3ox;dL?<5iOt z=}!J&jYr?PFP|e0PDBQ*!D-df(lV|Y0o|n^8yzhG^Skb>@f({Q9k2Mm+SmwVi)Qj7 zVK*`j95ShEe|7r&`6)CjtH5mA>x3`%;f$5w^UY_TE1(i9lLDx?r6Xu>{Zmd6R8QMD zF(uAGPtT}xRZTuo0n9`}Oan<>NKLK82~M>ia6;1lO=n)5HlHH~#9($SthlIik)uRB zo@>9BC7-BLg60#JeQ&B=_Z`W3$0rtj%o%m0{oi$AkX4s5+=qe0lp+@P z7UifSVA_(5NpxqJ_W_h01h%(_Q6X%Q0O_0>-Kw60ktlrvc$fSwy zAZKd3pm=ZI5q_z9G;<5_@@d?-&>ORTa<-DjYqXD2ZNwZ8*h9P?Sx|DN3O^bEhTIHxl4b$F`-=WZr6SkAE#qJuV zWTEW|;=VP){K7)aNum(=oUp_|g_b zX)FyopyAQ%9N)PExQ5T!YKTZNTQ6KhkeinG?E0X^OkXKv-n$9I2wnSzqeOr1?5@6| zh|JE*(W4c~(|`ZsGdn6kQxj)G>k+ri#qBvvPoJ#Uq|EBei4(@d_b!Lj8dTMmb@hd{ z(Jz8JI605NHIuGG_;%Q+hvbxmjy5*M?q2+bq8%Odf&$aMg!)pSLcKM&Sp)?=5@!U{ z&MpAz>61-Y?pXP+6uguHInxY8*xw~-3{xq|x1zGMt9ZDW2It1Ke3lJ0-oK~Q*fTvy zn2NFQ>*;g;Qf7q%6fWvaj|aC1PbYY1bM92Cw~~}A!SUw z(sA%#+aST;LJqEfVVlP;C?t4x*(7RYBx=l6uS5SNH*M;5Cf;8g%vS8pOVD7YTGGBk zrKH50{7Q{hZ0n2(J3ae6qsD4ho>1|=(z3G`g0#qGm-V z0y=URaDsNX*9A;J^9RqmeKT(}Q?EDbC( z+W+VXJDq_6@Nuc(=ontD7?`Ly+(r_T7#nkZ|2rc5dM<9HT-}sa zSX?Y2xvIB7ckQ9Aubr8dHHr!f*EKL0`3|a0V;gsxcx}3E&PtIRT@(h(DGy7`j0+%( z-PnUEDfBU82lwXx%*(@o=#ok6gE#`>iQMr_|92y$yR}+6TE3`1MKS>jD%L70k>|}b zTVM9}i?0Tnc+L}xii;JL6fFLFBm>PcJf_mut%hMt)&A(k07D8t_)F(t*!t%@i(`mU z@tpH10*U~0kd55B0IM=DU*ian71x!SThi8<#va#IMMH&RgKv8-{E44w+(s_aL8YY@ zO*{uR#|$l-bhM3~oyB_<3qXk&dXjem=V>N`uBPFa?H$<9{G{u4fZ+6@hcKASlN-@; z{re)$7r~Q|lH{~}tbAn{dfNEoi+=9ichz$h%J-rlW(hSvx!iOx*XCb#@@ebfpl`mj znKVl7#ttPX>xj>ZYfOruWBJZLs0y&w-z>BJeKzsAPZ_1r$tK;uHTrs(xyJ9S*Nrc<3|+X4Uc4VjtEv-B=Z_wzPLG6h(T^=%l<&7tLip? z_w4c?*x2NoG%v?TCvT2*gm6v7Q9eN71h- zB)6|R6)5Sbs%Dks1{(v@p2uo^`GVtG#WQ{zLdXsX$P+?GwQ0rXCvn8;^>b%iN`%53u5duPLjR~tW1CQJUrv8tX}YrT@l!t*I>ef_;X*On6ieSI>$8IMKu=@j&QZys<#<$Voz z1C>|hJ5Cm-i*)=j#wOOxWv4h&4=GDjHpLYqB_ZVU)5r?V2j&kp} z#Z*pZU_--cy7EDqk;D9P9*fj=pSX#RGPqKrFqw2~x17+{R>MNYGa1jbcUU!@fRd)s z*}_6Ac7+SX#b*u5FH&2Jt{pQA3wu7(p+1aSzXoC?5L&BycC0hcFekNOuoKT9DY*iR z2U4v%T^%C(QLY_84Vh{#ijNNriH6#LauK$F-S|!dr>DC&E>H;D?( zO5Rp7(ccHt4@@fj-P+Gkno-x3)hlj7PTQE9@xaLE11E&5%hd&V0FtC^wxHDqD@pTd z#P$i(`n`o(&d2y-#S|dtA*%Y?J_kN!-rU&OAs)hMa_`ksLOotxdW$x1u0b^yon668 zPWA89l%c8&>afk0FrJw{9Lx84mX`>edB~EKr%YT(WwX?=HK=+c$2E+MFGD9{Ftj** zzQ3R1@!z|f#ljM?N8Q9F>o5=nU3sT{9a=qc=lQ)ZYQ+DF@)5+nq~w+Q3^dqB8>il| zBrdl>NTuHURa%2OJ+3BGXhXq|Qq}^tk5XEpbrnbmUQEAqrXotLq^eYr_`YmL*D{Dh z`Q#0Qp^*{#x>UQ@z~{<&_bk~>_W6FgiE2z-q=l(vR2qg=Gx;ry!>7HWDUJz7US7^n zMY)9v^gY<@?Ci?z_u*wp)ab)odymY6}W!!5nuM0O=gTYHdVWqwgp3_96D8gE>?!u{Of$E|-1Yspe@xZ-NQNx`K5vGIs*Xm^6NoL4`h=k& zCMI_5t8S=sX||@OfVbZ8gsR56t?jY9a&s6d&kpGip;yusxp@^ydI|JvUe`XDFe5TR zogu+n(=$iVz;JTj%LsT4Iq6POA+|6geLxv&8|Ozk%@cIXvI&+50>h03nFp$^t{t^q zPzg)dmlQVWOG-kP=L-}zDTOnp+{Dn*FTVSt6)48bJHBljTFB_$Qs#96kTSXyk#zc&5?lcb4;q z(7!jof}j6~GVc9ve{2<_1>pGaUWP@TdYKDf|M!n|`e)Q%^!}HQ`2SL%^uH^2`rr6A zd(QEWb~>q!_Awe(kq}Ue#%9S6X{jlj`6_!8Q#H;LrTuv~9$wbU7OU@V@36kN#)@jO z63O(PN1kiGC#Vd=|JF8S(x7ImkaMkWi+Y?}kdu`arH$&3_|Mrlf$mCkCl`~=mbo(C zqVVG4th(&>wgFJ@4-D`xR8A4$=I#V>KdpVA7($CZNR&qT;yycYVhB~I&{ zV{uz)U)oX<9#$=_;A#2c!w4o(jo!X`^~%a(b24dqz8!nim~(MeAm&s0mBZE*GxmsxDXknG&8^IPYJPS^4IkzoSM(01BCdqD!xi5vnVBh= zDI`@wDI0}yiZZ@srlk#j&)~3@+#c4!gu6ymKMML-J zN$AR<*LZ17;MGB#vCeX6%+WUq8qD!+7C{J5bdHbfVq`Xv#22kJW);rIN};$Sh*V7N zb#blMviP7Q8a9ehbJ;XrnpTrkNo`hotJmD~mh@jN&vGI2(tSFsI7!&9&ygcq1_sdq zn9*ct*mIS%df|NE(z2ghl#Tcr#Z2s_0xo;SKr0|ye52<+Dr%HV%WtQHxy9p=G@HtK zObf$Zc8=AtyKqu}M~@%Z=(=s~DnYcCX59$qkxuC%a&jWMZTY6z0;sDQnj|hhr2g31 z%@rFvD8CxJ@CB8?7t|o)>u3}gz+l&z1cN45OEWW(UVUQh?C{FkK))6TgwfA1APajb z1}qg^YpUCLN_{GhhNjlUxjYdOG2HsoZ4>m?RBc(UoNUJigVJ(!w?0}a7g-8WFe_G? z+)f)B8W!djlD#HKi%LQwH>94iatIVh$CCrX>J#M=2oJmHm8b^~B#l!{{@$GC?-;kJ`kydq8@oDOmNp4(W;SRgb z-DG1P5TSLtH<=g^_WaChC?O%;#Qnl&c#|3~FCP)LMzVOTzsMIT+u`9+#{jWe8@^xf zakYE9P*Re+vGx(*Z7p`ylVxM`Z7QBM<3XyQpRu*>I0nwnfXj9FmN$fLF7Gi7=VV(r z%z0!W&9JC`Od=?qD`j)~4BQShVzktjT`sm==GZ@qFH-#%|5d&)k{tzw-b?Sa!osf} z?=*FxPe~}q%C8pD7VGO6oaayy-XNrJF7q5^b9PWNh?j;OjM<$IQl?G|l+R(!RBgvI z&`T`GdbN?O`HAr9L+w`^b~qM-NwnO|d6l(ZFs(Ae={fzep}l!wh=|eoj%aWs%G2}A z-dx-Y@K`R zal2!cyW1x^tArXqv?j6wy|RDSz>vspNlIcWNr`tlEtR1GjJat75XQHRT0Uk>|H8h? z1V*%Te2jxfAzJxqL@DLsdV1I>`NyMk$P|+xXqOP$uy*EJxD6z0r3QWY)JsN z6Gp=#%%-zl`8Osjl7q^Zf8RE5Ic*}pW5T>LvqY4L{cifu@ooXp89^q&jA@NNBS108 zaPJuGP>IN`Io{nhR?Xu7TL`!A=`)Vg(&Z2ny-F4IRW2`Aq{r_#!P+GV>A1j>0H!ty zXZQjOwbdHPxynj`IzR0IN87dd5}9b`)^O+rlKAo>gJbhMe}>Icuzj_NUx@dyvD7 z9TBSK$xF%1#Ux70YQ8akq*@2RctU7$~4 zQNfI^0cKiJXH~1hGg~Pe52YVkZ193tRP=WAEFVwI;cO~z7UmaUSwM4hJ=N5<+nw5W z4BkX^!Sahy5cFhLl)AG9Av?Yfn@-0-htvK+vWrXAF;1`yE_FUEN6(FTFDRacSL;u{ zDc0>m)7)=ypw^rG3Bn)>>4EL&uJKx0?z}L7=W%PTSP3`Ja9XmbATTq9pgb@5kv)#d41=lj>x2NI=^blZ1kO|BR3rI+ zRc@a&uPG}SB*u`iPC`^s%a&o5l<6mm8xs?!7Fu4xv0kQ=J;BZ?s4T0fVUh_~V47DO zPd4V5j9pOimX15wbkN-x}blq1oO}tWq~1?~~zA#;7_iKIOB&IK;p@3>P>f zK|ND}{u-F9fnm>MkpF0zdoFhSEaM+opMbg&^wXdMq z7rS8dS4DO2UfPrYM|qv_|Fcy4{{_1oCEQ71G-aBaRgr#v{yF}m&uR|lA-D67$X*MS z-Svf#@HSNe#jb@J851AJu^P>nnwaDY?hIb@DE@Q9mK=77!fG_PQrKC4D6Rkn-q>xb zfbZ__o+6&%nX&DC+Ob5u%rl8px%b%Ec!J=)cFgd_+d?44> zHa5NVDhdk6%^o3-?d*!ljlNi0zh+H*Y%Qq7J&}3=ikr*_ilR;a4(a!h$n%lFgMY%_ zd-h7w7866h)Bi_JEUK9ob_G7#!abnDRRh9 z=5^|hKY+L6#p`H_J`KynUZ_D^g(zfqM19XIB_-Qwsoi_@SYO8WM?=ior>n!{Qg|T@ zc4mDhyilCT&1T%>y?snfh8yxTAJC}FvQXu*>#rCOeFuGx`&^`1SO$+;)M4KG>Y+j- zUqODpz?ajyJM^_v`Oyexc`h9t7+ix9!Zue~`>k|%DIYNSzZ(vU&Z8E7nr#&l9L&Pf zk5Q0cpY3vkR@^%tO>W-!hs?|f@+|GFG+EyY^Nn|ObRb$*r}Es`b1hVRoDe4D{0{S< ztgJS-w;jyQA)A8;483N5d*k8Q)MAGDk7%dm3iV$u5#QL^|J~bbL-u;Su~K*>l2g*F zB9o0z6t+y8UsQ0uy&)f=7aah*T-*FTtZOr0w|a>~_;^M)fhm=ixprx|z&11TS$R;w zww?!H99@mJC8W`7AcaHM_>O_K$o*{9#5%)`KVqF-rD>bGVRh*$zb&DRs+t`gd0qD$LS4d z#tRC>8Xg}7lpZ9EKy8!{m!BM9-1Y0Ri;Ihm2rW_Yg8K<#%xbU#DlOe{^z0eEN=h$t zx33e@uBO~!iVh>U$02E^ASGrsQ8Dxu7GV%l=a{Rwa>Q}2L~Urzs`ZK`*W8p@!Y~rV z+V=ML(bfJLX8?E)buwhKzrUC8m{Q0khVr$TLvc%vZv&NGa_JsJyoHs*PR$8N*%q;11hre0T&g`o<|LXES!Js0 zivK)?wv^Q5#@0JoYd)Aui%Ig>I<5B5kmaZVa5h~`czR@MXstH_<~nJ!*GXPEBHRKz zCT~SPhX;r8ELq#?sT&VkN_<&bn{Y@q$_*p!t}4U0Wy8{vS)0Y6)&YyAv1)ITvT-Z3 zkZEf+NAUuQV4&xj-(LTEi-uqA_-Z0BMPk-%X=yXvS2|2ku-?@4<-$Umf>piiTm)st zA>U_lU+^Vnpw$-#m%*AUS5QPhUd3PR;a0cSy$mI>9MgXE@S%D= z=;o61jMa3}PjXK@f(kK{v@p#Hg`-dgN%45yBhW6A{`4+k4Ms-B#*00G7fKfIBVQT4$HFnb zxg;V}W2(X#XU6{a{Cm7C-p+^m@CR#RjVd>Frqn0JYFQ`X%DdZW3l(r2582D;(-Ak{ zN|5vJ0ypf%j{bJZ%Yzp5h9kVVy1YEHb~x~I&h6&*(it6cVv;268qoAh3umJJ7Pae! zS=8V$#mN_Cjr5v4-a(%0Om!Mj>9UPWOy zJrEBW7KUd|ZNTUFDXDW|kdI#$b-=;OYd@Y4rhl)b4xJB$;_OWYWq8nb*d$`$iNh*wKXA?VcI zd_>bDi^iA@yeAHJ@Ills_*zWHaH3}b%+m(R5v23c_Cx(8jFCog1C6!>j;w&|Y)zmy#tj_mF!i+&z4Do%{9Q+Y1agzqjOia55Q=>T% zDn>atCP;d7 zpQi|eZi+Cg75Y5B*XOD0>!V5TrluoD=7IS!wMPfanzjG<=_N6BW4#kn0|tV#y_FXC z^Vihu@qqiLHQ7MgFY~LrJf9nj1a`d&g)e>8Kj2 zYDT5Rb=0VOz>tQJMloh6oi^mlLE0T9+-q~tNKRSVB3;B~E58|r z2qx|$vQpF0uu7Bj)JJXD-!0VdlVEUw=J{irtfrf!gU2xo;zLWO=2#YO-vh(Tu%NOL zs(ZGd4m136bLkQjM=y3WC1o+?#gTfSsHyfrC6e9CmMt{ zT}Ia{to3}R{5)8!9%C56BYghc^(veEXejVd^E|Vw(GgyL_dAo?7nM_`>FKl6A?fGG(CAL$7Mol$Bw&Q-heb zRGBtR(cj)Ct=@<-Dav>0y*k-Y?VI7=#O#J1o|MAVMLndPUj41TmySJN5qpb$2IU4s z&Ki=1o>vRe=LFO*qsLEGN#a$VaURl0t{Y+9R->meZJw%d#KCq?(KlCsw&t?vHO}y5 z?=eoiPUX&y&C9V?{ney~A#KP9(*P=inp=ZiMpxIfEA~O7`eRD4=J|+Ns)rD!I4Q=# zJ`y4@r#^xjKtxDPMozimwaPW-?b*PK@LF4E4*FRy$eXuMMowP9P!vij>Ya^I!cm5Y zho3cjC>}+k%hIDT4Vgw>x`mi9JFD@=*4`h{T;+5vw{O2Qmg+7qZ_(sKKA(^j+XP_b zY?~ar^6Afzkdy7%9JkW6$jA|$62l_Lk_HWJEiJ)%clEV29#HF)iTr_p2|wZAUAW)A z-Sh3gC^^Q9AeEe0-;?6LwVLIEf-O?5D%wPi2VLm}JdVz1!>(tHpD((DcGe-b&=SYI zu30VSguIMAjWJwVkJQTFYq4z`g`SrMEhSN0w0$g|?$M7$L|AkEt9u8!G}IY?b5zWw zvg!-Kkq^&nYK~1sL?9d66kR>)t1TcQlM~~SNs3d2WG}Mu^F%X}Uk`fzhZ)R3} zX(wkrK?HK{>n#n$Q0wRo+HHZ8uK~0CW&hz{4agFFeb3r;IY12eeV)aXr-F||w%-R0$F4KP0bu=tI6(+3 zj(CQbUQ%B>hpF|=HP#9UR}oSp`+*pj?eoB{_hIDcy1?sYwIJM~qTV9GNcH#L#jUSM z;|&*CX6pS(^*k);B2@N5fhzF_b>G7TG2uiru2$CoHbU88>ZAo`rM%K|P}Q4HgcV&Y zHdWaM#u#ppHC+;~gmXlRs@ zXyvyu)*ac_CefyiJ_v~rHJp)O*7m!=da&qzVozzmPd!y!;t0l6%R+mmnx-RE(doxe z^jfzk7pT_Otg2I&S^XN(tKA`9BW*Sx&|8sdQ%TA14H_!Rd>*Yo%3nsR;hlpqGWBZ9 zOsnlbe^2Fa-%oPsCA+nhIexZo51^|WDf_ml;8XlyY;Sj{pdvPbs?wKz#lG%OrLpK$;Ro%Y$$*JrG3Yzy_y>;|l#Fnh-NItRw701y5Kp5S-U^uYNF3v!x9LZO^SAM|xn za^mC1k2K_eHIoWSXN;K3UBdE{AU`LABjDGJ&KqTd-D<=nJ43Pk_ zB;8+IS5X=XQ$m_j51z<|NUcaa`ij}#ym8dIzJ5D$?7p$@AXeo#rSH5i2u`FrV;w`x zY_v^nFhCT1($-ME5!esCy=*Jjs*05xft7BFG(n7so23Znn%eOx+tEPaWWT(m8+9z$ zE;3rD?r?vi?FpC!TlzvW3n}u`&c&FSdle9R8Z+VxPVFCLEvw{$6~0PsaG@6zDe|j7 zPp>0LPMC!U@&~rHzt2r=`kZV}J-HYN@_@bE=~q&|yF|xN8T*C|xLzEHWym6WHU<(! z+|HFY3c|<%s3BMKX)5-q(nUG^8bo{hw75ySjTFxfI;k47R@W8Iw>{royWoDd6Km^q zG|FbkZrCIhp-(LUh6K$U+~RC?zg7j zqkI@1jNMU#FXn#WPpZLg)UtCxt*ULzXVzXu?Q6&i#~1i(TXEKOc;)92z~bfx2}bu; zrUej`grxGOvu*O^B{zc1AD4Jq`9RQG;nX9Wx$x6(boz)w-sJUtgi!qtsZJej?f6)o z7F16uj%BF2uwbPc)nHB4!uPzKW-+Ob9EOFquQmaV;f8h}7f9&va>;1soccy8TY>>! z0B?kOX&R9%F1l4bybRCAB+C))s*XJ=PD*{YImr&`5gJyz?b zudK=ZK1Ur^u3mW_4>0AX2#!u_jWVN|=mjIEQR^gkm1R{QljIc@cQ215sUepw7{Ol$ z7?Wgz2)hmt)sXF|4%bbgFM9$8ewtdu-3MwVF*o6BnvaFO&V^4{PIsr`!vz6*OG}5x zE7H4D=<`+lBCCNEKdFBLXxz`v6@q2UCoPEo!`oYiMY+CzzlaJ85L8m6mO*znC@mc` zbVzr1D+ox74BZSlbT=r7NHcUJAjr_&@m#om>$m^UKAyeb?Bm$JSTA(VJ@?F9_jR4| z`F>BzHj>ns>+B?B#p|Z&uX!=iBSI__J+t-R<`bY?Bvi50v?HPqFevSXZzEl}+NbRn z(67eONR>dZ%c|9@X#Et%ov;HepM%BTqWN?sY7tKy+p(K@t=%b|1M8B~_)`6WjaKho z#E($N}C^$Je zS!pYfBKX0)>J+RrX0}WMpJegVr2rzP#@< zI?ZY5#xzx1nfHJRAc|yVom3n{lalD6fAg*nW{uLCbewR+i@ zBe37iH8S$QhyMKtlqle>4F&4)d%HYKh@MuJ%t|sS`kyZ9yQqQT*+feD-jQ+0&e&Ls zT~k&oo^WEmIJZ=%zGm`A{NjdCL+1;=tY1@VQ&H8`H><15w#p!PKUuhWLP``Dt2W}9 z2M@gaPwQl;_Sxtd9Se*4CS?_fNd^WK|7lvjnh93c=osrf128bvJq&jI(2$S|vNF*z z;mtobEt$qub?n~GdyuC?p6fccqPaJj@ITxX8k&c?HY)y~9theH{{N4#$^Qq!h4NtG zaj1k?jONRWj8DNY@i73{rn>0hA;gkEIZVZH7;C8q9cu@XC0+?SF4ju zh8!F{Os(aSZ5tiocuKY6@C1kRXFPuC!Z(;T}1v=K%l?Z`+EI0zaRRs=@8{_xv zr-H7Kk#gBrXIC=tmK7jXxS{W}-YXtRF(RA}QrHkQY@_xj)y>%qWmxcaEA+r%DJbq? zO)n$E!G3s(fVO^;My3r!Z|qf>C){);?yN_OdlY( zD0n|eRtkVohwBmXuy~RL7_!WOS;6%j^P1{ADPBu~d=cE>DmQn5ldO;3|3ZCp2xyVa$i$r<+O2vrJ8A* zX}`2)jPA=Q{pRzvl-d`|tG3ab*9Gu0QU(SF*Td@E8vc9yXci%B?%W9-^h`{7JM<_) zl|0j-GMV%ClvcZ(GJ907)9remW{c5G%`lJ;@AE9@r@<_6}P#zBqJ1*un}v4SRsSv zU~_vOTAm!kh8SEr7YLP5)zmby$m{?xP%S9m4R@{y4ABFaq!k?@7yB0RuI^y3=AcY>$Qz3Oifn=yad)r@1qH@ofG93 z73pbf4+wGFGQ0ZfG5`7Q+5q!cQQzsT93CQ|g;bFjeS-E5Pr)lVNOK?7w(XEl0xlZ$ zVciuvHYSf_y{Ak|7LYXK9bU8_5({!)45_AvuTay9V0d=>F)0G#;~OPrmySx} z%SN#zbw#$z@1YIer;KLwYE-7h4?jCi!vZXvNV7F-6(u}&H<4Y|?Z$uXyO!BF@lQfy=nHU(Dm{f@h)={W}q9PEm%gaBO z`*!TcvuWY?+E7t3y|Az%s+O3dxc>B;b~p#uDDTh&BLmAoUmrUU&*;dQ;mq<+=P`au4Z(<@ML6OTkT@|gk)Ks9t$bpym?jPRIQU*c` znNCzkJG^Y6Uf6&BLFWR0zhs+m`hFBw$*w6SxZ&!&%~|xjzIu7oH68^7a>?#f{t_me z*LS;m7#V&s4=6_A=%)_&xwe?!AOR9HDB>6H%fR){TpnH`-4USaal=pRmXR5|KqwUiPu*oV#UZscQQAbC4Q4QP|?%{{8}C z>TCB(Z09iF(BV@E_*81sk&C)XB(UgvdY;iL8AL>dwm2Sa1Dqqd@}8V?Rm=;_Ug<*B zF0TH1_80Q<52g&UT_2!T^BPs~J!9|uI-iH6Z*tj-_n(2N>py<-#K_PtkME9fQtIbq z7)&-u#F>*IkBBG%_Q&ze#!*S>t~glyF=`I9aJRV-hL$4!+lTh*H|IZJ z%MggCB?l*0#R`x3sK=Yt)~bWI6Ne3W7%1*`?yocnnJ@D@=I-l1k_!RjOQLeaDwoIMxIe) zr8mQNqs0vQlQ_@Vu_Uv{eVb zK#W7f5II@dowyBH+z}S6}iGdX|<+d3lGQ6l zp4?;7dSfWEwMcUIf^nll6KPRr(}l0G?b%D8|!{6X3 zw%F0+Dtav$4eZJ;NyKEerRJ2oRvb}vcLbT*+Qdf>t`Q#U{(_F2~L3??)Ic>xyHkc zh={%~naUy=_V(t?24a5tUmFfIR^U85@ph$($H(Kw#B{28Ab{z9N zp_Gv7?2!(6u-)~$u9JfX?FS&-BdG+9Ufs+ml%5WY?$Xw8)CD!X|J!B!-&z2wU*}|a zqzdTh0>ocTN?9}FNF7J2E{M#PE$lWqZJ9+=>Lrc`L;>?bG=aq5rEo&L5KqVXh$~Fs z3ni&iZA_NHlx}{o>+{1<9Fv$RK|-=~ThJpyikYbofdw`9@44|G7^mllL#m!>yvpT%NAAPF`hsa1=LNZjHvXoSOVW^{X+X6iclUMS= zDlm>yDFg?}nhZk`Lg!rF)s}0t!s2(?#e5-MnS-n2xBK>JLo7n31!`)xdz@V0<}$ef z%q(O;kvl53!ZoW6XdcEjy8GZClI4Q#puOXP*XKllHS-QHhe$y7^}n<={r6zz`Q@hq z9{12J#H(u@Zbgf)LR7WJD)kIF3cw<(rmxQgEKz1)cem|4b3!^AAah=ksX`*GZ!zsm z&)Wcc`jHC^)B}gU{u^WiVw)ltY%T^vD%jw9n1Q#ybvo;x`^gM^ElrPxHc0a4HE8`~ zPxB3kNr>6O>+f|^o6?Pgn{;_uGNdAF{Xsy^`2_~r&$Ca-=^fzR(1c7tSg0~pnJ|_G z!ufYR_io<>&4umu!avUfoLRwZ1)khqc-QC8yPyr}-Xcx5GLBfcMA55|VQh-rzyCFw zBs3P+%M1EHUh1F4`G33Atdi(egyox+mAKO-Qb4bR+XVz_Un~P1)7Kq&3pMZrm+3aS zUTph5<>BG@<-o`FDJ6l5m=cg$Z=w$)m_jA8;U%5b5u??I4@IwdggJZj!+8%PeAoq|WZi0U zW5;LzZ#tK^H}f@SSXkkgmm3+fy*&Vs-Owps+Ehr=F9cSTw|++APsvCf8|buO&8TWY zOQzd>bAKk^lJ&{}BJ%Fkp&IEjbsO!RTCB@x9UmWVcIN$=vf~d;Rn=1{QQEPiYQ0F# zTjjh~4``*pdw@4GF=3pZR}a;36>czR$(Z%J#*hdl#OrBU2EKRMhbpA8Ab*tFg^O~*9e#Cd1GazGaXVZF-0|Z8~8r_ zGHDOR`3M#9>&PFT2val>*emVyOYb#KXUM%9CCJD~-S*vpY^yS| zhPufoMh3L&ei+M1VX?P45vX+5M z1*{O560P>73b>6byCtBefL{uqXZza8f?sZCxdm{cI-$Bp`|>3j8QIas$WSWX?ec4RITrMZfHTA| z#;nA;l?7p+I|kQQ!#ZnM(QU~*p0k4XOtz=}Ki}Fn`Ht9!jf|lC+Rq8Qt{TZ`zTEnl zkU#UGq60-;df~_Cw=Z<%Z?IU&%PRWrbg+WZnobbF-zr{)$@DBNdtC#{jA72Zq$p^yWQKp0IvpW%;z|2u!^nM~>%lgAd3o6wC6KW%c$|z}0VNUX z)4pVp!knB#e}gNT6j`OK217U$pXG1>9BAsmq6fgB-`b4Sdax+Wzp+> z%_{{xh5jjI`tU>mwf*+2b3rxaJY_wFRYdn>!=3Pl^)a=`-eH3rXP$zx4>u9_rCGto zYMM8r=$Qc9%VH2D!=CM6-fKDdO5%QX*II!lMkXplU=XT;TFCFpyyIK2@(p$H;EOXx zoB%j0a_Vy)bP8C!0Gl-G_uR#M!u4N~RAoNKvulR3`T41@M5~;gh{y^bNqAbW=dcro z75s9YJ5kj33cj4O%5vzS5?$LsseUFEcEs!Cw-WN-dn#E-)Jv=!oTUPE3<@)DoItd$ zry$87`p6!4Pq_grL#tk^TP!aB2e&FD?)A?(4a~z0$&SY{$O^|$V1YS} zeYN0o)D{P^iHHcx#m&TQsf1Lg0)MzBF0sOz?V_neUU9t(JaTx3sLxelNE6Nr;ZEK} z^yAjRE-#mjV!kQcwu{5kjhUvVyu9zH>@Vo1()nBsyR#c?Z@Aui`2BT{d7hTw59&+z zx;xBIwPL#$x^uEQf7G694QZKb*>UjAiZZ%;1<}yZu*pe$ElPja#Kfe6B9_igNsRv! z2il94WJqjGdWV*zsG8~sDnBrdSx{kcTQ8YxUB;aCq1(GHrx*OxdMJ}y*8yw46ZE`3 z-Ptz#XhnTWSlOi7E3G&5!zN3Yd%J`;8+Y`@S~t z%3}MidB=E6o;_XU%6^PS>8 zDUWcP*yy5PRdey0!my%00!y9@CG#?7GCvW2r$OK_qr4oM;{J;uF)5j?Qs?h-ILLFa zf)&sACfb1h@t%*Lyp5Qvt3p3B({tIz?A4P&sIw)Zo}c)LOy0t!RF?y zHjd$hwVhp$;_>ih(!&6;WqkY<0-+TQ>8|fBXY+_Mjhpsy<0(>#t*t~F6aI$PR#K+^ z{%=O?(EC$JWv#!e+5Bw+mC_nyRzwibD@o(#*r3p_TY}ew5~S~HpV8KJE=2x`)t^ar z*5~0(V&S;rZG>DTRP>nV=vj89T+a(lx^<+w+kAbns!t1Qti787N z)G1oc7gY50wt$R7Sx!pbO~&Fwc$K*C#@1YiIXIa&c(3JZ{$}VU7j!J$=CUE2s`oVa zFp)P>m7N>lY}LIwSbHpvjg768Dxg#dPf?=H-C$s5oT)Wl=ks9a6eX)@wrJ>O^GoqlR0ifn0$VgR1?P1f& z-)wZFa*NkCneT+HuNi?WUH&~HV#+rmX;y)0&BBQSajLn=7iMbc+(d%dL(dNX!-jCe@~?DSrxFo*>5F45!^7!7kI7@H!z+;)zq$bTP7xFGJSXFt+T28;ByTQX96z-j~qKI@8h}g z0PC~nLc-6MktuasXNTcblUrG0x*7U&7Kwh8{C;LL16M(kojYw;JtqeK`y=*blUq5} zJT4Axoj7KUEbD_N-jIK50hT<8=)cOWTQ83W{kP{HW^?`EaogHFnoFQUlEJrEoMpLB z`q_-vPm4Fn!+>Dn^nT>}n#Ev-pzr<%Bse6++QGiwA~Z)xKtamw!`AP*`BIFcnh2za z*g#bC<_0fA(@sAtx8dw2a(1h>chQ5r z0X2-88ZLnCt~Zr3iMFw^JVVY81aH--D$)^3b$v@oNm0#;u*@^Fwepfu2k`?H}zW(|}%)xxC zg z`+$J#h+^`m<&|u}k?K*H1bap3{z^e1LR&j=|4ittndWODimobfl+&`Fu_YP{qg5JXR@Um%{4dKnRMo2Z=m6@v?>_P;OrckOA78Z>JIL267PVo5U8=~ z@Yy30LU3@UB|5K~Hh@7MYRYxHFOyQZ7dy}**9}6 z%#VkqxwlUm!h?@AQvYTU@av5Vtf}4YC&D=s(l3!@AGRN_X}!9f+usjsREi{Jx0qe~ zTEp|MYq_C;uP>D+Dl#)HOpcL=U@q-lxm@eX{(ksus*QOB6#{-m*NHUg%!TqjB*+Y; zXZcgVT2oGkN2wOX*Esg3gQ_QRML618iA_zuJ{k8tJoOrz(N2B}(y}ZG;PcAq+Ac#t z(9v}LIwU$H|K91Cxqa1r-t4SWN=l+$JZY&eq=lAe!xDo{F|)-Y*Q@YGpRMD)k%W0o zk+#JW(CwCcNM(Ok5En!bUDww+evS(qV}MAO-*rg_$64)+=Mkb^;i;&uh-_S=Hkq=z zI^`om;zv&a@LajSMIBaDR7#u>Cn~VaLoK{fa&u*5Yq-8`QfJtOtd5u+oYybl^R?89 zOMch;+xb6Af)Y_xX9o)E94^VRTG;3V1}l{?`1h4?bL)a4jp6PzJ#@*uj|G#(n}9ta@*2;t`hE39xf804iYAu4dw7t%GdDYX30%m5 z2Wpz+;~D%X6_U6Us$`awWLCsHL;s3?1Vo{1J{yrat*!1h9BfHwryB zyVtz-6H4>C7IsFOy4u894j6^HT9P#^pt_3rZ#fr)Yq=#K1bB>+lAiYJm<>Krdg#X2 zmZ_YA?N%w#Wt897&-kg1kAG6R07z9AouD2<=}AfK1gDb>pm_Bnd&%PG_SM@M841q_ z%f4>T1k_hN??%D4{ryxa^U$y;|E)RJNkeYv7%%`=Mwx(C$gQ*&n@~JW`A~C#RX$fJ~b zzQR#OMW$QP+MsR?qPzAN#<#vLgM6ve!Z9zY^SfDXs_LrJxqZn;p3q|INPCmHO^%L! zv_u!b+p?`^JuvxeZ$r2l#$Y-#=-I5&y$xDu^AAI_B@jg=&=ak!zq0T$#X(JSh*J13 zY?8lDDeV;HFI0eM(%i{>Ju)!KyremMzn$7ZyE3fD*f+wU*{OGCr1RV4C(Bl;Xot5B z5>B$`KMb$#Gy9_yd*3Mb%gD%+4%Iao8T)n-pb>&kV{S-O(%kut8kcl3#g^Y`sXeH- zwT#&t+28qfw(lhajR+@W*JuunRwwmK&Pp{FB9f)W)itU|VmGU(1Z_#vEgke9WrgtB zT#rJXySjSh-y@#<;0ghawhtBJWa(1R4JTQ+nEMonX80dSw+pRPQ**x%WTf?XSSUR_ zJwCDZx0x3;J$$Fl|H;e{E%Pq;pt~@jIf_o@Q=;1I?P2WekB|mV{@uNCo>tOP87HW9 zQ)AQ8!>P<68D@pZW1{u2aJfOfMiqmCnZ{*hU9sV=otEspyq;QeQCE6uX83z&x9$5M zAnNKz8(V7n`i{p-N5~f#P* z7$C~C>0vu1lvncEUf)Xldx_wk8gV`0t}03H%V10kmahjRDq&TzEomjtw+^Q$XN|&$ z_~dxcqu`AA_`JY0Zec;7smFG;zM153>{CYknA^vxzxf;1V{!PF5`*j?)zTrv zNR^0dl=_Gyqq&=F3fPEEO=&7V1Kx;BNH9IB)d7 zJQU4y6%MiYuuvIPk*0_EYqxsvTy63DUMFiZ<$1wcO2$CRY_d(fbl{k^Kg03x+u0nZ z;tUNLNO3hvM@6ygftHAK4IWRp2Qs<(Om%%x|@Ze zVa3kYMO<7Q1dDXE&D>LR`-DN4< zz5P$~8+3oOMYMibP*liQ!Z|z{)jK}(K4`;ibUEg8I6eGfc(?JwgF?`ior@`1*0#ml zg_OOT>IEuoaLB>ze)!z4E-T07#y7YXh81J1O;W#lUIdbqBIYp6(?L?FWOG5c!X_k-W^S=jn--b@_}OLK5I zBM?QXXh908TpbzA-NUn@_pcWvJE`>qy}?3a5@BEYHN|*#U((utVH35;v=G71D*x7` zCH$t+juCgLN{uEWr-o73Wxo!yCO_X?vXiFy;#^Mfgu?qE?*69Q;rfXCwn&=rS`8c5 zPaE@do#WODqxaa-x$k)&mc&?%F2+=>TUq&fyP68l%PIB) z+iz`<6U1b_j<%Z*#5~NF@-kM&*dA-)#Ldq&rx5mhk6j*ivAwmJeHv_zmOkw~s)CIi z9`!Ij^EnTaGZnybj@33`9Nu~CHB`oSUSn!%_#I^D%14{yp_H4uWuiVkcoC%R%6_Xk zrBQCa3wv|}MFlz5Ld_Opws0+&eAI;p7-N!{BMpB0JtY%PuH zc;lt!eB1vjCTY*)YT!``o{SgaTpXc@pO7n31)z)t zpf_}4OzEN>lSA)BaP8+j9aeOPXAWjts4n*dNY!YggxvQq?$e%UD#ev9Oxy^XF3FtZ zgB87#AX-4U=W4 z7OR|A`;#R+Hz!2v1O%siPq#VFYflnHYlrsfCy=+U9`pViE5PvlDnyang`7X4rNtHG z$H0bf<=0y|9Y+7)&-!yUBQaU!68_sL&5Bs$TDzy3<<`R0DTysVh52;za3zP7Tf3s0 z-TWpg0dapytz$3cdAs}dW_SL8UP(d0<*zFZ4vrsRW3y7kwY)HMtfif(ot$#L|2rPIU4S2fU69v*W{##!lPu6(xuLA?=7=OPO+kg-b}@VeT{_59eQOZ{b0#1Ho=wP!vQaT^0z}B+#+LX-w$dc!w&KHp_Av;Qu?WFY zA5|Vz(Jl)Xi_sNnv}l)*&)OK z>DYHUzQt${Zr2SfO^b>EW&gNx8!G)RCCvDCqLPJyaoLJ0JL0iS3$nExktAJl8$ zkml-Ao@Fcvm7tZLNu5Q{O(7&FeiJgM>b0zkI+<|sDPWU)rvCS%AspF-(23~>s6r(- z^@z=2`Q+qvTylZ<;9vnT+k)IC@w}mlZ``Y7r9g_;Mwx2*fxvckno$g&wwpMO_EEv& zU5i}E+}P-7rbUU%fyFB&C215aqF8P3d|n8$*w@`p#cRdZ!mO9Nh1vz!MX=PjCr{T- zP*ak6dXZ5)+!#WBRYlsrE=q6r!|6~SNa0;6?NUORhI+WfXQ}*Odk`+}d$+kJNR87& z2Swx6+2}ehD;&Fk6~(iit-zS;ETjo0--D3nTK#ne-1#SR7Cm4CC_8zWEfzU!-EixH zo^gtL!8*1#X-GlAd}c(Ak20uO`mD?EI*V)?b?z6WzUIQ^?x-dWf{Nj!1tPwKJ*KwMz3-F%6CUg8B~>X1$-eNUnS? zI_!Uq425`4MVRiV4XcmmAHKj3-fGxfxzFm;Vm612C7H^c*_|driOkxdvf@U4)mx8~ zJ0x?37^snKE94AKuBcyM>C{wJYp*l(qHAD0%PE;sy!sl878Ci~)+P0x5O{2~su+A_ zg@m|tSRE~T)+_`A0Q@TGfQCa15H!xjI*a?Zo5QoK-Kk2;U#bO z(mkcty$>ubt<}{bl`)Aib9Vv|&eO?HiQ=N&;u*dirWe<|>V)4Ja=NWa9i~mL{&lL$ zXU=Stxmt6bprUu8~^RpJ&A+!~2yq4#ihi5b2etg2GxE~Cr zaP-cgsyMu{vqOrEWh@Ard}MtICD(OeO-074vQJa;KqhlDT`kDRwGM)uw$d4?6%s+6 zUj1nISNH@8r`P!lYHQg{6LksE$%uC}aNXoYn6@UQ@5ecrpyN8e>40K-DeN3!Lh%q5 zry(KL1^iCcbYU@Bt?yg6=Pq7vu4NgU59-!`|1m)J0K0JN?LvsRh8{EEFv_d1BYv+g zvH3VGtWDl?t8T4Po05I$x1Z7_+l24Q=dJ3bZQ|0rAL-~;e^BnfBX|@GTqA%^r zBQ9{KX;cKGcVS00woT-p`YXzJ3Fj6|_AnxMt+W?t2vxYj@W z0T3?M!19tQEN1>((_^V`Wq6q5q5E%8v^u7c@r?eQo{(R?BXAxOmmJ^CGY_JyGag}| z4mtOi�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 P2IrBys$9N|iO>H6ar50t literal 97390 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 diff --git a/docs/screenshots/viewer-competition.png b/docs/screenshots/viewer-competition.png index 0ec21102cd22cac8538991b508fe98511d64cc17..bf2927a8010ca8808ca22972070022c75d7b9ca2 100644 GIT binary patch delta 55816 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 ED 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 delta 61264 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 RFTpE3_jL@kOSJxZ{vV%+i=O}h From f38a15893b275a59e4f566c075ad624089421b33 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 16:42:31 +0100 Subject: [PATCH 11/38] docs: recapture realtime-update, whose Recent results now carry the tick Co-Authored-By: Claude Opus 5.5 --- docs/screenshots/realtime-update.webm | Bin 287595 -> 319740 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/screenshots/realtime-update.webm b/docs/screenshots/realtime-update.webm index f9b8caad52b5e87de33f00b2989a050b755fd363..d6512b90b1033ed0e240fa9408357d0d0446ce3f 100644 GIT binary patch literal 319740 zcmdqHV~{4%^Dg+dZQJgiwr$(CZQHhO+nRPyd)n4Crfu`x`Tg&WyLaP$+7G*TW3#H_ zRL055e4acfGR~YoY02kmW?S zcxj}#f~caHxQbZh{{@Vz>x$`Mtm*$5%W|UVe>P72N5u3W5!U|@A-1zN@o@68oG2DA zjS^E=5mu8{R1k|yWn$v_&&lzFi;L;M34lnn!<|gM>LeIYDq$ZC00hn30P;a-yFkLg z08r53;ZfOPQJ^7U!Yc<=j@myl`oJ!KUA~ZO(0o4JE!{uddrT#s3#I|n{Yqa*$^bmGp)Jn@jhqn1hY0LA}-(W$h~RgwRH5V!#Q1eer2 zU1owa2OqfODoQXM$0(A)Pf%Gb(*Cg)`yZAY|EJUc4>AAWnf3pF#+Cwn1C5C>+Fp@b zT+Why<@~nHc?a)n&y%fw&q1@PXEThe5aGUH?QAIxQHwL*Z{eF7J58CDuzldw=~K0M zaWE~w?fiOeIwLw>x@S)~nJrk3>qnS;UL>LqV6*EdbO6sXw7$Z~N0!(5!IwYp`?PW4 zAM9&bpsBWZDj*hY?&Z1Kv?W@_=AXT;>BbVQ7azF?e0FY zPIn1fNlnL`OPp^l%O6F7{DdF&WZS-ku$_51YEJD)P|hv+N>EO=X?fN1*aEFtmLN+^ zAlRM*{Y#5^=_N0HtZlMC8|0!i5pNZ5)8BEB4>WJX<6W9A%5iLk35jGpSwa%21zh4$ zJj+shFB$MZWz2*D!L$uzz(Ys^2Qrsz%?-KAU93h&4yj&BrXRK6y^KKfn84!W%s5s! zkA4MsC?m?&+UG|_pk0DY(?;G|U>$=PTz*PvYNzZk{l`AtHZgJ#*OK%Gf**2$mJ*sm z^eMK@Pn3<}`c5V)T#Uq+nPbN%nqr@vxm9$vVBz1oS+rgflm0JC`L?9agVS9hZf7rV z>eQ6>X7Ack6V#QX3!tmg*N*i|ri3q71MRGL(N1e=*-@Rv+&XcnsviT@W97)%rf&SnK*0*`-8Y^?V|AG+Q&}Kp5P7o9Io1~ z>u2YnU&fvvT-{&oH-6R;n)`K)zdfD>#B>OVl6qs<L#dgOo7dDL|jalzL z1N^9Gi~=wyh5HS{*|VNb(kgMibLyw(^r^FUkZ4&aZ0ty2Uwmr~jY@^zg8|flJ8l38 ziV=WtU?29@$fpPZNKuiNrXWxIf4xz+!o4qzP*+!6l$NG4$TEIe+aoVaX>vSClaZ!j zL=``Y=q(lM2=I3LT~40X#+B$v_7CGC&)PN&d0Cct_O?EGS}Kf4dhI{>ZmqF5|L}K* zWQ&!hq?}n|{qvOS3c(UBO+m3Z$M{oPn({BKIckcGYq+R6e}e4YpmC>8p*;!px8V*~jA4L&wPoYY+d zZ`RHf4;KKuTBTg3Tqd1X?LRmFOCtz?dTJ6BkEnrgelPrx@VO4K0av?EuF+ax2z7(H z4T`Cn0k0l2ms_MYoK)GTtswn%?QzmFN}A)#aLN3}Ix}YcwdA z8`#QC(rKs6)y`>)WYqreB?j<)Rf8@I1|qzJV)Mdp4Zd6Wd(Rdi=>#Y(2?i7a?m+)> zqy8^$kMM9`U;x0^&VQr=@O^^}C@l&GOaPMq*&x6Ecboso;8r9EP)aNl9}Kt!BnN>I zAkP2@&oq%pc<+L2TNl>-?hXZ;2&P#2fQG*s&jQcBAOqUAK$wvs(Tomf3|HA_dy~1~ zpOl%sF4Y}pmF(xKezRAM_0uKwKQ!JE?fYSTuYCJ{WF`n`3D|%9zR|2bc{e=Z6Z1*D z4txU6`b2)7+&bO|js$%9DSTsnuf6PHeX|Eve`BpjL&_!?OG1_H02Kb{nUg)f2wK;gn=;5hIeDE|Qj zZk==jfk4G@qMrhbhCP{aT$;Tc=ySNNJo+SHdCu!P6yBB6Ar~~<=KXwx`yrdVvt~EK z;^(*2J7Ul+AmE z@Qn5^Q*QS`Bu=4LJiK89Ni>A#R{yhYqs4$^+!rl6dHJKHc?$_~jW$EfWe$xfn8UXT zhdK@T(eX>I=&F>Z0QPK8Hy2ds1m6SM!)e9p{=`W!ov){3%p`B-zhsX7Iyjb|1F{4h znfq_S+4&*_>ptj96eb z$Z{uEv&`Ws#emYUAF4Ew+CDRKkz+WsU1XDBb}*UCMS;H5aHXOE@e54=UC%xoO2odw zdl)D&1``r%66CKedQp;bNPzfZyh07V;74JN0q22RiFfG>RekhTNqx`E2ty>s}vej9bSub&ch2ED!Xm^VKX`*N({T9Cem9U7pZao(5Sp?5hxj*V+#Op znxDK3a@Fc4h;RHQS^9w!P;6i&1@}1x23s|GKJF2KRRnPi+!qDxD?IJCA()i6MbDd2 zV5c5VMeJRo9~q%cu#FH8P58$?QLTorA)+h~cUB|BDwHQ-Kc1$h4*fX@KL_DHAS#(o zj42cc={imEE*nWwi;jAz9VdiLwiD(%FiT(8l>00pm zqZrp9Y5e%2s1dJ*gLMUcQuQz63)p`Pdyz+2n|{e&O~~qvm}k!63T&+;41vr z9N@@{#NP$9zW?<9as6L1NEyEollZN*HGiFwQngQC&j`u;l(I4((#$UVMuSiG3$sdf zhhIAqWs8Oy>^>jh&G>Y=pn?3pIA6!YPT0o!-(jmr$;++3M z_5rcn4O8GjJrgBaJ(|Ar4~C;nlQ1t#(YLNE&o3%E8l3~%)_WaiYlmV?e4i+=p4OO= zj$^AF&C%T6N}?m{FDy6$8G`d~NHCTth)vBG@srAGqbdNPx%w1+HQy#JY_R#zPg;Pj zah2br^iP?Lk}m>;mEK>}PBNG?dX0#*j4M!qp6G@5kyiYO<0w%hu-=sNW&H*3vuTGl ztjdOAg?kFIu6$Og|b)Tb@RjYv^36_7AZD@YXmB5ZMw zAY+Wp|5?>HsiQeAbGl*WP2fc~$*BMuKrv@kKLqvAl)D{n2G82xl`lEVaEjE>trvVc zz_0Jm{HKbv0YDB@G_hf_+>Qmo?rmSHF-M22y-J=Dx<_kfw(lfOe`7nVAS`=Fg zb<%kt;{l-el0j~x*g7!^5aKvIY!Rla5@mlg6&-J#gj?0{mY~x<;579VI1Rl1BvYsH zvFGCfw}Uf3BwYRRtnyZ|NhUv5V_wGNBW2ZR5nqOJi6y0(n)$D)F3}V%g|rU>K_!gO z#KGwMaUo2eFVVAJ3|IvH>oIw;>JGlit?d2QaT>5Cc`hmkMpFY1xRpD-=;qTW1$zx> zZs6=mrg4SrvZ*U7g!%wPb^ug6daDxxEyNm?Fxf$*?c(O#r64WXOh;rWXi z1c>BnCdC*@ICT%@q76C3URZfST*|$vG_^xpc>?PUcufRJ0sYmkyKoB9Jd2h9o9WF+ z)?P*G4>&{;T9!3$l{`ogJ$BTlin}GZF6mtn!*ANEfCjgHb8~T@T`BCmJv|T1Xq|%| z2#|F{kTnOA%xD!IYuF4Z;-^I)t8<+N(>~PkqSx|-fjd7CVqAYdX0dg3o%BVSyRF>=NqAp~ zCzY53rzzC9Of+R7bZ3N+*(JWnllfcUs?tlg$jMt2{wfgin|TqMAr;O<>QBCS??9q# z<(s^xg%mUeFLjgqiu4sD{TbPQY~PsPtT|ymM4`dN#DLDN(ldv5ToWgr`#Dg|Y)o-WTUm4G?A@~km{bpYJVE8; zWRjN^@p(Dub|oN-;Y(?Od0?=y>3G`Vme35b zW;dA)sj=rBAX&a}Q>Pj(;!MAE?cb|``cHB&X&4gTQ3sG6!$P8Y^~b?z1Uowr z>g+rrqli4L#_;0?WR|dTu1s`s-`)xQq59$>B&~C zi{ILa?`MpC$9*=Owb4IkpS=#mXe80JwPl%d7WwK055{|xa50NMHaV}d6_ZPV#Qask@_Gd{4qUqJHc-95qdYY#!0Fghhe3_k@E=Wq6MVWbnQ`-^kh<2+S}9u zs_z9oM|6U`TAx2@>{tgsi3P+7E+Dk%@kWF%*?;pIOWwyAXmr}NK^SW?Pb%c2tPKL71VQGYt(3vq6RTQ-{ns>AwFb?VFZv$W#AIvIF7c*%*X@20r9C-( zs)w7;ZT{GTS8;K%a&du*@#}-%fo4by*er;UU9Wq;XHdt>%Z`v@umWzSDVwwPo!d9D$#t#ACP(*TpVyFSzg3 zX7KKMV}MW`qiZ-ZD6~w1%hPihhP%QuI1!d5hv!ToUY8mSdg<@)Ami zVI{pB3EN;|Eo2RC`EiSAa3WTq8vJkHn=KyDiIP{7?U>?-1xCaW(t=}za=D^VbDWUt zieMO!CeP|oR!qktDYglnEc(7>9GcF8=$?W)wB96iU%h(qhQPqKfDiWFW;HNElhC1$ zNE<4`wU?Z6&q%uCS03}YvyL>uG4wHz&lsAB0O9gmi<#!T!>CUqr{!6o+MeQonWgsy zHWD%gAAVhn9KaRr#GmIfIRC&C(O0%h^EQe+6%KVV#ZVirpPS(e$qojw!4oj*=_sMS zK|`&$#+1gp?Cf2v8L($1_=$3klb9 zXG8_o5ZkM=rsZT@G98s|${{;sxY|z0^zE!VLW|(0v-KMP&XkH}guB%q?I$Mc)o)pj zP|igP40gfDO}!OA%eMf!than#+u&WZ+O zJ~<#EP~&x3pY>4qE)XVLovf|oF{F~Iy60?rYmri%U(Hw%Y<-wiQB#D=HnO1r1nhYM z%dH3EVPR=#7bNpf*gz}%N77;L-~Ql5y=uVWZFIXW7pobFSS|Rp#QNBxssWn@$-P9O znXUb{N%9K}doE8W#IgG7!7r;ZbGu(w^;6}$e$Ot|Ft+?l03JF$JIxI)ao792i5rC4 z-sM|9LSqs=ck4r87Rgz+D*cJW({?ws!byKTB4X3V-!)l?g4zgV=WMUDMl7h^izqaX&sC(d6^B=A)vYY3 z)GCGxRmjtN??2MTrZTDED0cFhz+Ss*$|y*$5YW6}VRhLlZ}xY*cR$#P&=P7-UK=+Sylrl$17Fs7d!<0u^K2-mj@u^<*p5mA-*v@xx zL=NN6u7DfJjWmA`nEjv@qU_GuBR33VoZAX=qW>hVdqr6e(66ugo)>1j`HZlOuMlvk zq7?6zD2j8E8RMzu*3BY!zprr1nHOX1C}rd}DN89VSjMG3 z(B1q+p&FZ@x1|0N-g=jEKA`XC{vd{HUJ8rQ72Xr1dRZn0bs1XnOcUBzV8AF$X(W_M zH#SgZ3f?ko&|a6O0a_9WO&!vp2<2}|mYf2+u80H+nFTsW8Cz0P{lYWx-q%4GN7eE` zR?)0;=zk(t9;_LO_1dt+R1v+ar-pJ`iVn=QS6=tM!U|2dVZSf_Ba%&V{{@!}^J@+5PCIPJADr(oONiI{ zdI}(KlmzhkCS*INMqQ$|n~wHkQo0HSn&ks>McgM3f(1aCxS&6_?t%I{#7!fl)zV7b z9ZHHsgVk-p@PdMk^7(eASoi8sZJB#DLVHJYX!v-_c7xghhH#u-)rGalnHaWhv~9-^ z*NTY)b_tex4I6l%VD2PSD0O1rUI|wh+e|}q3;8yDM~}aNag|oOx?Q;G(dHPo>Vr$oL0q0{0z3?;GQ36~Ap7|znVX_OB>n%gyn z1?;>8x~q+X8S0TLlQ|B1mos|%iy3yJ0Ay#eD;Gx0vn#%UxhU=<;er>IMCcvq&AJ7f zPuWMR=NQYrgt=?BacuwQ-2-pFNdyummqjr~EnCM%?!i{Kn)+X-0GWZ1g-1~aC1?Tmg;cU&r6jLPu6(eA0On>qjfu#`HDh{n- zLhXP$vfOnfiT?gDI% zv3qHRM%L1Q)dMmC_VDWYANoi@d|U$Wg3g zKmsVYj-Df(2&6E4@+om+I}aywvo85*&gB>E4;FrVc+3%r_z6H)${S=}zR3{rGss(ak8wsy!P_>KpX zrq<~TIN%1M{m>T>csQ=)gEmtR*lA__`sll)4;2%l?r?g9aJdb68r1i2!AjBfLnZtg zp68#c|Kin96y6+VDRm~VqcHGn%Rsdw@X`VhJi z;^^U_%GAvdcVZ9T#if}&QvNOCT-g?d8$J*XlJ;IYryEM4fWcp+!lDkHu6ycy%PhKo z%4eX%e-^D@@lH~$w5TiOU7DYjT29o48fyR3|8>bG?1~5L?m3JgjWguhgcIS|!rS_o z?y${!9*er@oU1B?*?-94xs|g}EpyAJISQQf=wkSjgf=|1KY|&S_aCBNZ`{ckQYs9XyHB#~{{@o+E7IJG0AxL$F{@!qFh0Qsb9m zV~L>7l-ABl)kq^*A$)0SQe)BjM5tu z_>n=nfbl`uW{&Q-TvN;!K?5yyW=VBjC;HPM7j$-&8dZK>;?&JABd#<6^ek#|FsgKA zYUD$@S^V>o`J%+>B=%kDH5vFQxD*JOlPonqD-kgtCYXP*} zwp`hS|4tOUBFXw`?n|+Vjh$!gH|9ssgH!WtXRMtFYs#{X@OCjQ+g`A)BzmZ+omp>c zDT;cVIH7mUYjrwjm~>`j(&+Kqz0w6hoJ@NRG2NZpK@c{ZC#B2e_p@1{rqiE>vG|4r z9R*x&r?M5Z_94K$Sf|!piuE&j_o`2~TTMt*et_FOsG+|Fr zL+P-!*x8};A#W&MgjzZ`w%LmU-Dk9p&>%Oq7xPk~N~`I2Du?T+&zyYYSAB0I!g zRm}QLy9zm?1DAbL-?|0@(%@*2O*2nuvXW+(Bq{!@pD+-%)qA6b4uM#!yuVeWrVtL_ z3n4~5)Pb76BBFA4SNhiJx@y*TX3*L!Ci*K&88^lQ^+t*tYTgE++>law#7G z=50v7@%g^aXE`#>*d11yuJ*qkRcNIk^+Rxc<2F*XTjr1@7S^11`MpW)=t))%9lyOs z-vka{<;GyEJ?^j{=!6E7OriQ@i9u+71@FIfdSf&XLvI=$a1T$+n+rHim{}2@6_~Ma z?X%Ifbrd5X=OstgTZik9d`f3TO@@ZFfxVUO(1JqOf;&uf#haD*&CUxed@NjO?f0i= z+7ahRm}cdqQs6*Q9%GfF%->gI*s?La;}9qMo=F#kTX2U<8US z`FpQMbVPc3?C)$Elm2><%fFyi{{9hj3)(0sU(%oFH;Roa?yZ^^y|PZgTW}z)P+4Kqns>VSfnLVT!u(ncwL&UO!eH8jLR<%m=xaL>X`iFNe49y`b?FDL(1va&IV|iNl zP;jkKr%va?19vbsZj}pwO%;i`WiI5*e<4s9Ba(ggcv>;pd7R3mUQNtUma_o z3&++QKsZ?Pcm8{wwY8?PF+Ym>mT2w(G3QzoP5~{ar(cjxv2aO{ z{^g%be-rNz5dhKzTCmqN$;#x6F)>!)KU7R=Yv>PkTC{X}_c`k0T(KEe}8cYf;cT9GMu@;LR)yTl31-8XecH(|qZb@5jbtz+;z zPzl5+$%dk!V4ED5*B}1Q@;ng7ubwj_C%s3HjKL=_*lOlayBN*JiuqsbG zIj0dmgT)4HTpcI-NMln<5ar zUZDta2#Kpi-`P9t1duK=Jo6ns6C812t@Eh&XZ6>fdf6j zj&}u`28phu!^ox$3L%Xr-EWv7StVSWuM{K^u56wOj+~;Tm;hJcbl(9 zZ+7f9$n;03_;NvrP_`Pay7b=UA(KOyHTc<$;M~1p3yKu11uzdEKWpKHtUt=0(ENNI zzbbcj7tFl<|9VIvKqpo1G#s&e-npZ5f(q0x=Yl^<&JOAzxX-uUi>{AAiuBl>2R!>} z@&}v!wO*Nl6WsW@!gk(r1ajDqf$avB9xT9U*KM)X@F1WvUi)?73 z2WMTB-jSjGOci_KZ0&G9=xwASwmWUKISX+xb>=l@mV!iP5f$dfC|*^61tE33;6a-f zK~6BT2Ki&7KitGn@K|;_{Fhr=Vq>(b1bM_MjTrqTy0Cl?GHjOQVl~e4dk}i{nbP~0B z!myeWcUstL3ciMvekfn3I3x15fN?>PThKDkYK`Tu|5wDmrtf@CCJw!+6^b@{#y3mB zox?gw$~Ub^kf+Ej-rDG1 zU+=MX3sKBgCTnItjI=I7@1@M`Lud7%4YMCcFzCoU+*8X1aJ5#`Da{=D3Yy=@K58w> z)R*Y;PQ_`>8$n!UMR*pwNMm39p#^BJ9xH@{iTE?wSqq65ZNQ|+)cq2%hS+;0Np%66 zyBQWnEk5meNXSZ>q4&4@%I}Llab{=n_O2|^KS04>#HcPIbUC15V95B; ze70e+%xl^}A{2!bxI};lD=Np0;C0U{spj;w`21Wjul-G}I8f_1BMD=18Hq->q-CSe zZsCIRVCWFGJSniz5TAX1An$~M_>g-}E7 zU=vG|JXVsSnmOj0!SsQ2OPxcI#m3`>`I=*=GvnwXZ*yGbQV*lb> z<%UV?w&D)j)??X`IBI}oU&cVd1f?j;T)3397EYD$Z!8n7#nmHYNLlMQaQ}%~eFiP* zUJ?CK&ka2^vM({{C1r)A_(rDKH>493X&egCE4i>5jc>ukn4{mv;!=UCO=mYx1cqfR z++=QQR4uS2s6jI1u<+mUtU?U;q8a$h2;Q>*qg?Y~Y=u7HGNnvU{UgNhj{D|5MeJmD*qX7=KeR)Q@Xtjub%t|r0H`I_R8KwC zl@Jg;T|Ae+OW2z++UZIbDao11TDF&c=^~LC1R>rujm2AxnS#=J+s6iT>jGd)X~jQ4 zbFIncWgKq!!#GuQYm>|&W36aN?gvMfedR%X~*>r8d5Ljhwx|@2r2hyJ8`ZLZJ+FYtq*i!VBjn1=3oZytWHmj^lAnFb>j71p&2J+Pa*O)B2?9*?Hp17xWFy+7d)gOaDkPq z9Y`=W*l`9H$&necG?2pyKYze1jPwW&x78TFr+9Z*g%_V?xjw~2A7|zgFp)}fD9lN& zQ)0hRDWaD@x-ZA+_Vr@~;6RpM5Zq#+kHuaWh|ZYgo#N2&ENsGsu9k43^SZWo2>Cce z5pSyUq(G31K|Vg~J3$`==*6&w1U;5CTT`XM@)<`R+Erb5BRw>5Iofe5+9b=PsBa0- zT&vjo)8xcy?8EOQH|0}gbMMHfi>IU!>c9Ba#)C~~R~U>lKR!?_v28)Q0r)aZ)<}4f zIIQCbsg-i0&@eqkjeWdNnT9&h_X_%acAG%WKh1htWCV(_d7aTOO!^=BG|X=WBlJ*QrMa@#rK zyN1q;uA>|)9>s(-Ulh26xFKX6LpVIp+ia2tE`PEbIuZ-r*doU?m8}Is|6MJ^2D=ZN z_Gb>`6J70ggyCW^JB-OF+DFIzQy!iN}KN(n%G=)im%-i!^!#RjD- z?%9Qq-G?@z6T|F78iC53=FsZfbdn0wO6(ULZTC=d$lEC37Wv`nbRZpX!FE}R;kDwn zm~QrJ2z_tK@8K*w7^ZHDQ=;NKu^SzxIL1Z!#Z1!abLGzCxxJUkl4TC+ml?62hVVZ$ zI5g`fX?VzvO!9%c_&T`HzgYj)Do*CZtT{Jhl6@AHcp`Ki?k>%&=@B58iW}oleM|_o zw;d+7Nc>|a5AGY27FLp31P=9E7Ski{zAFke>0yHy{sRNlKm*+1E{7^Zdqd>Qo!2eBel$QrrQDnY<942pIfb9p6(Xuo^QY) z4O^}}w(Hh4pCZThS{FEAL5m6KC}4VFpIA?o=e^VS5M9~rdY8yN@N7UQu^WlPX+|J9 zEcy&n4PBAf^ebm^N|;Yq|5hpP%le6({mp!W&YFM16^DLo z(vQr*1ADPw3kL#9eRR6V%3+1pFNd?WONS+KDP_$H$-0|wP2XFSyfuHB1tgrH90yn& z&F`1sTOH+y=k7%mMZOe+`La&!;!R4HLKDvh=F`ZxJkk8mLxv}T=igohWN z3Y9kvR>$@*&ZgTCK8dRM&nS^12bXaB8QLShqIG$*!<2PzV|k{@2aTaisUAynx(qb9Eonbshvq@-9#BI%Zbab2=C9-H<$ z*s)D)j!e(EP-#jF6AERs+=KAun?Q<^r~yq9vAUFe^lYjof~y$8s7>7=%+erE5EhuK z?KexvDL~>wLXq_>^s>Q@sB#@TI4Z;}&O)L#6C^loiOlb9&?`9YVswkDf3@Cn_aRqE zaGawHVGGTpivZU|AwF44S&!V@fGwhIKBm5oTG{3VDr1u`$xY+#xbf8&Y3bQIc}7@o zg*tiiC0TBj4$nIM2~%(O@<-%S*3oad#o(KX0*bpd|G@ci3)?l-TN20FpEbW+72I#c zJujJJ62y@hIDSd}&}aM(bYD9GnZ>*reIRM4EC&`@u%k(@T(r?Mo^cc^?R;qZSnw7*$({_*7D41eQXDq5*-H_OsaU z%_y|9p1Lf#E)3_-_J)|}uxwMLG*+n5k8z#%FyEp~+8mm{!*s=^h8ts6}M9@7*i7C&`nM=*}UxnHa zBo{3g#LF5cUeHxY2amf4&3c(e5outm=3(~2@}bf%QQ28YU!zzV=bO#TS&Of){e4q7_;q`!r6-(;#NqDFcp`_AU5ZFkdKkxruwz5cNkR2f;&g< z?&sW%q{&jvx?yz!tygzTI&c9u_P#%p@PKFQO*2#QMIgco8z@aJ8V6;A~z^M`i0^-nm zNQEc;^m7_ocJ`@qYCxpsoxar)EAtP%flUUsBrR~Vq-$?Mu9&2WA%KIxz=i%d(G)cE zDd@cL9nRty9wi2`OmfUeVe8 zg9frroW7a8BmQ!ofr7jVgeg){UF3UH(Fw@QH%4IRtRU*hANOh}3)z+IaQ)gZTD)Ez{psSk$FWd;%<}v)5U+J|FH6lc#QuPFptYdZ_ znbB0oCKvvdL1GNg0vk(+Yb>i5^Y={kVGRq;Sre|z=8a^U>=Oqx!g!?_lL3x*^HxdH zr*Jo+no2ZlsYBd!r-5UJ^2mjRWWu%|u;bTRoGvOtx%VfIag?`z6r^}bt(E*YG)x!$ zHrYlcwa`wf2M9RWW)XLx5lt1zHC2w>0=1GV>R--~dx)gre7Dg7Ue6orS?2}U@I}1n z_;m8>vwo;_W-oa}z3E3$3=hOZ1(K5EL$Co?q3T?Xaqg@_;Ap`)RMZgnlKQi3-KX5tb)7|)pLY+%QfxnG)EGD=)!d;8xxX@X+rL_F^hCo# z%v{l$GOU%`ZRe`30OxPYoK$hSU@1-Onx>2?Or~P(@pMB=#`HmMg!nwtm9BWDou@tS z{8knLqZj0?)iiQa>LJ}CKC;i-e5)Z?Nsti8nBETcaV-p`)EOa9k6rnK!fv4$mPbGt z%GTqTKrrBn8S7JE!;=KXxYdNlj$fo|5-jwI#?s+2iVM2mGz`xm zSvYi6c12d#%T4{y-GWj#Se6pSTaC%htNjaE40U`;GD)J2j{zu6DP!N}hrUHk?ssg^ z`j)g!`LjS=Il=LVmu@QYBP=Zrl}-0)G6rAm-@NQv&#=P^aGQws&%huN&CH|N*5Vhh zwmS4UqWu#Np5UW0f1623oBQ-Zz94ac1ppyv?0SPUSV>D?&h8liU=RK|=n{c8>-$J+ zlD?98nV3Glsy#us!DA+@wcykFMO(#lxj-!4 zi@fvB!8^3yQP-d#HAAn0Sk>wZ;JaH4jz;I+v4g{Y}sHs1#R5ZlC&1!iFDzDQc+CykcQvg zbrA_2JLJzxpLbMC4nbyGJNtbnO|pSbjF6OzOsqOSJwpZ8r#>Q6j%AfSzUPls-a+3%fSe_@r)Kdg!_Zh#x;bB$CqBu zWe`<$k=`V#Q(^3>VRpQ($*yVq*td2!pgH&R3&fC&sgt#zIb_vpz_8j7&#G5b2pts#EaT3iF0x%0Kkq zDyh;Bjc9(FjT-JkF8$CZvOYU03)YflsV`VCYq*ek@)Q_Tkk6j{Z9(WSvuz2+U=dP6 za=D~{{o_=^RH^+|Wl>H1b4Ikx^us)BV#GF*(wd0!rM?AhDNZ_TpzgC6z8I)sVAZQ{E=$5k#gT>kY+b}qo9JhR4D67}8)2@Nch^(hO zHqrzv>d2H4IlY~3&!V^`4~E|7b0o1*qho;ge9Z&BDil>SNVqnKmi>U!{eGqgwxi-1 zp*{|*R#IKhn3>f&+-I45ofgLoT;G?QGP(Q1`2u>Lw28pvS?y4)tOOL#Rl(s{eLiF; z73vmU^L8FqD;55|A(gx31?kXA?5X3a^XL4rL4{~}cqkHrl#ejFv(ua;XTbrbG>2Lb zFA-JJJY5r3h!*KM{M~N_SR-XlDL$0SV8iE*n^|wU-(x`jqpRJNfnO1k&GP7rao*oC z!VDAIVhM{R!itS6zbn>u_(`-AVAX+79NW_;o^C=@EkCC9#*0I78bFQ7iI|yg2RMXd zMqbM-1Sd+>B*W59HXZd{x;D?sVZ$!fp&5l8fmyhdBp63!W%pjbO$^!7hm5~*=7|30 zmg|G(f~&?;mmu&e9iTeoYj41Oq*x*&384tVLDxTy4;_M_FJhO*b-R$D_mD?GM$`6S z$+Nga-n6_|DWvqLkAhmE0HonZ`(#g>hTDFo1nf%zK-l;<>sj7O-)S#zxl@RO(OKLm zRp?@?c}A{k?;3=kr6RXcR;ODkio9P=rwOVp(T6DS#k^iIh*;39jUVa@|Jo~zQrsI5 zs#MaTmY>Vp5h#X1${VPC9`s2#8l^=qIYAJHxCRG}c+RJoGDyFtC(@DEXEM1LT@P7stvG+-xT2ebsbrR+TxM&;~9j=w{(xP<_obZ~;llj5+m^mfd z1bblEi(#^l$p^Gg7&gv2>Y&KAEKbCM74Oacw553uC<&(bX@H@Aa!D3@a-*STiKBT* zIR*;nkHGSun@}81VBRHC0T~W{u-f1E8>nLwP^X7JMUHbguX1a?s@ytd+iQqf2dv!w z)Ptx^&-bM)F%u>2Bfe}L`zDF%KL9zJd^FCMI$gwM!lOQNykS3CGCn7Z_!!PVU;Vtz zUiq30r_$g~YqP0&n?HVp%m)&eX5yB8`?hAYuZl6{V;lz zZPf$;M`SJVrn(9?Xyp&yF9C#hf5H5VZw|vL6i(*yZ}&mktIzl$x)hA$fXfaMM5h-7 z`v+7w-kjP(9NhD)i(;>S7rnrqE-NH{Rd0XX+75N~MfG6+fNkHP9cR1mODg(#bw^M#JL+njg5 zK;8VYu2@P~tpD7%G0G;5rBFCUw+S8>P5bApu7CeffAp3~)XLSm)g{Se zgfwV+7+mu?e0lmZ%A==|VSAgh=?V+eh**iW>E6`}!S)BYJ$`r6b&NpkBH)mX79VCa zUPH(~;mN_Onu_U)&0H<=`k;BzCxvAZju_&&Nr}H%?&IJUUda*GqdEZJ*e`_O>fbvG zF+C7d5I>PVeYq4sF4tseKi@ui1ab|%<552649a{w;$$|5eyrKa|3TO{25A;FYrbvU zwrx$@wr$(CZQItgt!bOnwr%&$cW>;CjolwxQ9ts;sXQkuqoS%JE1#$ECpf*z#3*I5 zz-!({bvsX_utOW#7Z?FIg}G$Vdx8){f#t%+<&u;Ku8%{T;>o6#La!Ws#?+aggT{h>MZs0H!(GSzC$N=3b0>s9YrY{wOAb;F)S6!%}_<1!CkN z5CCxGYuyZCDXi{kK1h6M=0Xv(vFL}Oj7VE#Pq4kNU3!p@G?LhB4(<%d7fD9~d#FV{ z4rfEM?AJ+XyQA>|_mePz61!)r8XI;YX7z;6FJAGr#1;oJdu>VISKI8nqVGtNe%1}* zLnzaby7F{<;WdAASyWiiCe1|{PY8w>TNXOCO{m%BW@+-NaP&jb23#8wd~JPeWiaTx z161>ICHk8Z7xfH_&x;F0d_K%dkdik^r{LUqIVQJBYqFRu59Y3;>FmPaQ7YC8bwq?ric_4`uoO{^oq@koJAwJ9 zWaWK`LDG6?ZQDxrqBkGQSFt`2K|%!X3e^BT7}%kS0>`AJHhwcFx#oUtrh7CWu6)Dt z;`w)(b$5QC$$9IK&Cy@o{b(h!&4yYn(Kr zk9p}c|ERv>V!5|uBrUU}a9^q1)iJCuOO+6&5ekuO_!B&kMw{vMr&Ia;*jcU;CK>Bp zCV?~My@@s9Wa_!}yj|Su)LZHfq9%A!PNcd@h;n8jELubt#oLCDd+bk;rx|ZJwwi5i z7({~?+u)vj(0IA(Fp0fkH!Ds7B6jVzvy1FWmtYJFI<=Ttml7QE>z5YN^iQSxo>R6i3tzwB8tj9l9eHc>Qy262<{LS#Ms9 z#pB!t#mZZ;WWl4=w1bFyJtoQNN!c!-v@bhJ@`_MhJFE%^iheM|nmKG;98C#$lr_e6 z%va~K;V%>^H3tIS`Dfa0J5V>Aheu;M8Rr9Hkgz1WWxl>RC9onU0Zk*F1ul+o_Papl z84(I{yC@x3_DZ*aiukS~46MV;EUR)1#T2?Bm~W6qG;9A3ud}8;tpyMe+U|^+X{YY( z^@$+1Uu)W1Q1vXnu4>6K^E@yH)YOW-yA`8p#*T>4(gcI|eK@Ny6vA6I@W-5)qlGwN zv6os7N{do&5luc7V=igVuZW{+kiWA!wp^uEB>*a-X%J%1vmw)Ll@DBmjHAK*;dQHF z#9v0Z?zPR6wG6gC!dW9)vk-}fU#EW1=5g?n!6}2XR)@xzkewVo1 zh6foG$JbifI#dP_YE~NI6)dVm)%j@jZr+&ubfnlD&e1{eE(`$W46;1K0kt*T_##~$ zBKG^xS{k&_n*4m5OYx64M{zAKsHhq=DF@kbJWG0~`!BoBT9QCt;G?o$`yf5&Bzx}n z2~kZ>o_smO8kQlQd~Uy>`Rp^m!NZm^scCiI8m6U6PSExL!m~i|JLQsVKE@rP2XRRu z(nVOjT2X-Lo+lfzM-l9X@e1b|P*I6c#~irL0#`Lk;)tAMQq@MC2nF8%t5ORqg8^S( zU=iZWGaE)gueYxVQPgVi%oD6cH!4fQ>*@>jNwL0dUKzywAy2c)g+dvV4A|{W6}O_E zI}rfL1ppXvbcXIaJLW9vj={V|O3wqx$Fwm+>e~%lD`K9*cw|7aaPJ0hLl25+)1n#= zH?VWh*a|GD`+ELR%yBww{;E?IX5J@T^a1`Y&l-73o`PH3$a*{4j?-V8BC<;RogKO{ z3G8c*WbzdwK&A?+X~8O`4=ktU0T1k}x*$4i?RvdG6R6gb+Y^zS0BZ6WP(3+naH*mq z-n5>nJfcBXC^S5WBY||pJO+SfzStMHM&8AJpe~#gQ;I8&Qq_EFF_f_Zm5+xbI;A?ug3f1SAI*Ad#WT?+1@4$Y?>0~7!kS9!BS5aCzm{F23n^cwZ ze#XO*i{;LU zx1Mg~hqG7eUvek5NwHTN@Jd*RP-P6~K-EPk)I@0E)i4k>gvF_|vHg7SKeVX#{xtiU zf_Z5(s}5xL+(^AqQx&gou#v9kZu1BN-L)>e8t35t6HTtWBwvO=2 z8HoNC{YX@(;^1NP=p~gs;SlhDROGoqAe9xBz+Wi3Yy+*6+4h7N_0NCyXUF7@1;E2Q zqpTsZQTklc_Mt{L+Eycx0}!6;vA|Dy=o@N^kO{aiTdU)o# zG@ra#gwi6ybX1fIp_0_lH>oyn=D+E-le5Y)&?pxEVf=#Z8;w7-klNwT?v!x8Bon_; zrO4iJiAJf=cB?*%wyF_hCYtfjPu4@INnTu%DCT?D=qWl(A~Wii{T7g5H#o=JeU7HF z5!}r*wSc-AZ3fR~(+W9uv=|O7^8(%3^h5bDn_2C!!EtlrY3Ybp{r+iwzRh1D@KLynTs%EQD;`qxc~PG;>~wV5)V zAizNNS}(tJJV^JxH08D|od$O+C>gm=RR=mYqw&EL?th-Sea2KV1}^63E>rqr{IH7h zfOh?KW|yV3^pvvBiG2L(C-RAFhQsVgdcD?tKMD*8n#Z>1(}asNA@#QzUXi_+v8QVrJS_5-2X!X0g}jay z*#i3^X`RiJs08*?6giS(EjAz$s%S6s@`E;S#7pArpd%yVL}WeX@NNdonNA zyX8&fmewCoFkY8tYtBSkoIkC9VUD5TMfIcz1 zVwWL*88sZL6&EBFe)49RE(|!qpz#UlE18$G^%{HW{L)_Q6XBXtgWo^onXC5u)8fZ> z);@CfvJawoN$A&UWMjUFEx~MyZub=RvEl}s?Bk}?wN=&apN3iN4U;H3D`e<#wh9R* z7gi@pjg}j238re!NBunvLknkCDeXfV%WDQ3@)~b=W7brmBY$q)*#T;r!Jn5nPs;Az zDE4>T3qGv2mzs0VF#^vwoc9!nsGIM1i2-|tVb~>a{%8W|zNP3~AoT*Tr@}X&rSTo# zlGcieZ8?SPVn64+epWAgAOSaUx={!iZnb(P=|xaTHkePV6A>R=we7|aP@XDLD&{*C zDS5wZ*RB<8TjFswcj$>9Nn&#r&1z3(35gyGJ)|k3x539gs?yjl z?R=O0;lWU|r`~{P8vIx}AA4Ie9wh zf%Drvgc3A(?NLyjMRAzZ>;Sk{B1z3G*+#oKz27kkY7lJt%WTA7?>iAf1Hhuu?^t4H zaT*%hU;^02huQ;(CWgv&d`}NV*<yik(ntpPA+djv&iu9?#*~kt^JJPgc?ZU!1$DAR3Qv(KF<0TG*f)ZthQ}D;`mxPqP@dm)XyN^w+mbwWdKI7JZy0O0PGys0VK%CY}X=C=kLfHf&9ZbaLl-9TkcDntY`K}`Cs z)6mIf);At|T$X66PfIGBz+^^(VLr~9@;>XP)>moK$$B$;bKpjCHGP3ldgpQl7qCzY zYDyFulPez-OV0Zf7GIo(Y>i=P{JV;ohxFVcQGAoD9)JEke?D=HM<()n-7iU_y=QzI z$>%O%db;%NxdaO!WzJP^)70jc`cUryYI{>G6H$ERc`Wj=&c=`FFrvhC(24b z!z!=%lO1ry<%{4+p`tB>1!w!DNjv@j{M%_SU8qm8f0EE%TDA z+`^;=v9OX_976eGLd0PRDyuMu4#AEnj9w$1)OX0f<;*Wb*;^%^Pj;%&Ax9EkD=N#$ zU+(9{&F8wbw2DHcue%6@fRT_fpjHynOUJrGeLl;Sjjq%KIgKF!m>5l<0*xJ2-#9T$ z|3Py`by{jFU6@arG(KG<`VqMVhfFN}>HdTLUI+OuMIrV4MivskAHE7KN_LN9Wmf)@ zz)4h!_L0IE%`}CUw6DP{H_D$f^*dTXD~V+04a&sa?eSIl6Q7`U@FUHaOSzz8jzVKG z!_y23r=WwbP35?lgP4y`91aa+3PNPP9h0Z^to*|(Xht}%QTM%Mkjh72j{p4!0$iWg7cdVky%fE8+SwN4bW#qTrJ!})PV;7y<<2aPvPxa4f*{P zq^Dk@IvR%UL@~ z0Jo21qT+)d34uVnfK1q0S+@JxmUd`h;(m5Faqk$WRQ(2n#zGij*zX$5-BSkJ)&l8A zCyDSjV7NhkvxB9&;6wc8*=8In!ka=t+eW>Cb_U}LlJQW(1+e+T`wZRDrdKf$=^UmtS@ z>N%mIU?InhXLC?EZM3 z1I>aw9nYhToWMU0hyN)q%?bs%dvXTlUXO`f7f0=x2U}2`qFW2OIBJHrz+RUpir>fA z4WdGrx8^<^x774oE1!K~EA~g99H;;7RMg}Da{ND|49Xdm0N-r`5xOl>!Ia_0=+u-F zJmoGF+iy6Y2unS%x6qmhxF*q=uG%|CU&{zxm#1AJ!)D)?j4ynIQk%aOE)~-L?`#PZ*zcK6zMb> zS{=rTs6!t&P3XHs+o!MR;4V-vQI|r|ZKKTTD%MV4wzXtW9T(TS44grvG;KGC*@g#4zC{xH_JCgf>wjGRKLTAz zw88&jZNRk|TzzyRL))o~I`V;nI&zxg0oy+^I{18GmBHAy9M!+%tSr(#zzLD1*plQp z51hgbFM4}uB@`>Z=KoK%ibAtO#AyEzFR|0SHB|#|UIALorRy;_fGisEu#uafhe(x8 zlPc0NihNWwWQK5G&1KlXUGq(cW+ZI|b?uL9$Mx~5F%Z{*G%*4bh-*h$1&OcPO~nqn zB6U>S`WLZnBUZH6A=Vui{byFVg=TpJ|`{$sv zh}|va6_@$g%@Ec5&&*$Pa-^y-RY(K?nc5Rfw;x((m4x~HFJaunPB-# zRfWqe6oD0I*D3@-5kE9+D8;nXszy91GVg$7shmt67ypu1X0Rn?#Hk9@g%*jtSstE$ z+{ryR?*Tl$>7qMDW^2E$jh&z0uSNbIk! zF-^~Nk72Y-<~}$p@M=~jRzlk_U;A{$_=@1g5`b{5_&F}fX)-S({L!Zez^MhzO%XG4 z-CR!zAvL=>Pk7Q%#Y`n*|?R_$2nw}&KI!6rIt*wXnV>!Yg^8Zl4m-PaqD zw0ui;gSx-B>h(s4wT*xijD%c3y48%W_IP@VjSozdjgA)7eO6~JU#6S;G38Z0@@lbZ zv(uS`Xa~N{RH8(ugJ>tbHb@RuW@PL4XNJi&8?iG*VS=9^%@WN6q>_Gsn;3loXyauc zU^F5v&j$FjGl~Sf!WYqQ+Q$K&4IOiaM@pK2d0$OpYocn*$MR3W($#>% zwmth;om;&L2-%>YWplQ9VmVruff4>+Wb!df*LBj__0%h*4#ne)&Hw$-WnoZnlCIa+ zTLDNI!xz?~*4m_di4F80w|~z>*6g0ti`oG?LNPw*`{SF*LQzBTL-lp5;)Xx5kV6~k z$oJr2ND3lcu>$SGmU4L6#5-V*{5u*mq8>YsdWfzB;B7fF)3xFOY{yiotNlHvTFr#^ z>}My~dI#(5wO)#HS0TDej+t`5QRwrB#o;sqj>qk2_q=Krt`c7JHwE9BrnyWjaZCZ* zPO)w9C6;S&#uym5lj}ssw(~VUR7-i=9vd36Qp>cNGS7)F{kum1H*xUu1w(jtzpxHw zaULWly#d~i;50b<4RW+c`JX**Vr~2YBVrZg!Sg$8DQ(ikIULNAyo$e|nFA`*Uu$2a z&KGI{CBKDsy;IG(HQW+Be9Og%vdmI8UdtkB40rOPVfI49c!Nap4|fsQInw7r4ptTx z-%q{nMQm9eM$W*~3e2-^WVvBYm`qiwGSzGD#JVqz8y(SGq0Ye`xk+I`k z)mFxk0jN=U&Hn0K?#PclH<|w-&G>8n7@xPq6&i5%0cGSaM$)4ZfHQLUGzl^J&Po2y z^fXUZgAb<;vQhP3=R%LE62F6Or!8A(@r4csXcX;!FY^{tas-v(YgSar1ba+Fukb5Z zfv`OitQwMYq0iR=6m?i9~&J%e9D9$HS3L$svQq3@wJip8P}=FasNAUDBAptHZScy;w-q+OTnph6(Wu#5{;+ zM!}_O3EsV)EfR$QGCy-+1n#D9vpk2`u2WXhk_8$ZZ{$W6+O5J0)W;D-69G(l9oWV; zXhb9QA3?ZEfi9vJvUrdstKJEXc_NIzDO3U0A5$2kaGPR+gzt>tpsNgfgSB+@G}l=0 zEIwBX@QD-e&@MOEUoS!hI~#;HrdAP+skW5wl{*vQ7DX4iJwiq!V%=g-W3(Z^4sc4*e;#!O#tL}2ez`m&)p6nCe5K0)sJaa#D z;ttLo!!&`VFDRB73j}HhG?D_)5NiPVGuvIw13!-)QJy;+Q-&{Ot^PY_Aq()DdF*{- zKR?Q7qozr9I^{pa23YYTA98gn%)DQkN^Oer^)sWt+NHA!}Z%UPPB`DplTr^D1TNqy-;3$uwxiruABG# zfQsAg`$9N4?h#F&AMjc3t7Ey?KfwY|9Oq#X>PI8 zouL$gW~(sv)}UEW84&ycy|?GWeGq=ke~y>nJndd~!ZxzsKKf4m`_K~$FI(mB24JsJ z1=_rChB0x9yBA2Y2N)d93sG~X2)dge(B7h>Z{hCq*&4__zH%h)*DV?N2tJA9zqz6T z@%A3J>ym#5^A{IUJgV7wmkrIwtDUT_vX6cPlI%0Kux@pKHFGIflAPc9tM5HyL`d z+!tT-LZJo5j6vpRG`4nGq@!Rzpj}j|I&$P271G_0%K&`e^h^~iG)5GS?V0}CtStd` zLn2^av%_;>PRHfM)jxWWdMp1}A4%>+{g1DSZ#&fGld(%OA+c3zHJ7ePx%B|HXZ1PP zZR~v9tGSDpcqjZ61yB&}x7GUb$h7S0lED142R**s1b%$+T4dn0!wZnSgx*&dOuTbp zY(Fsapdj`B9?a^lkHWnk)%%eF6A}5|SxG$ykfrM7n@UHXd~bfml<@SEMB=4tIffux zwc*Su{rAvjqroWRQ?9$87SW^!3nwbC+K6h3d(B5eA5~?m)(Rqrtr6>~?}3cL5@f$q z%{50j`t%QZLJ-qy1`Soa4ln$>KsfMPkerg^=7-FmEoJL^*j3m!B0??L>XR9r=RgN{ zhCG(l4Xi0ZfYA5iHaG^vhQe$?y2A!lBeRhf-F?;U>hFG0hs9hhE9Ztors&V_6(knN zBsL&8qvjtv!o&bQT|o6Z=fU6jTKF=RfPCty98#$siBxhZO+& z?R%MHmXJU3ErYeFRubI{ybN)AO{r!C5>G*DA!ZOUS))b20)0d^SgAF1Lz^S~jUO_{ zT=GEuW0#V&Wl3n2D6>@+&}N&`wQf@dv4r`5izA(0KC_%LEQhmqLhy_n{f!7nZC)S= zyb{&k;6El_43gheI!SM6FtiCvZM%#;Q*fstWmA#JXljMsXk++$$K0AQ(U&rsLh==P zO}oXWrU=a~vlTu|`ru)K*ax+rk)% zFxFh&;ak+n3`FJZ@vvjp$&up%E{`)7hr^jrmcXwW%s&ug63Won+Qo};_C@5Rov4DI z5xC}QaU4FMb=<=x{M( ztd`9Z9(v!Q_zFL7`x0XDkqH01Le*I=IxH}F+PhDk5W6H_TyjEr#Axb@#- zaFJMEl0m=CfAnkoiYSb)W9PDG=eZDkBejfZmD&%G`^^WmFXX*L~?Gi-rmLnkoK^ zdk9ng{Z*I81(V-7Nb9Z^d}MHT{9?jt!e3;JOQh_o3J0_j^ zk7R`d%5#5_#8TadZ+vm-E@y>7S?&h-dkl1^n0FRAu=u-#vRIOhkZvDc z6VBZOf~f+U9b@%~S0S8CX$}`iEDnU2A*lUcZbXwvLOZe&F&dD_+KqEnZ*`z2U(;6d zM#_5Ta8idHA)h2s&L{uJ3}wymlv=tN|keiy#5rG4M^5UgqbYO;Bc-&rl`r2y7iy)*u6@#{$CU82a3O02MX(Et@;Izg<}$9&S=y#Re#T(_1flGJhYNsjH58Jj8YP#>^>qAEkZ|7093&!70WnXbN%g z--gkFQRYtzwwHIqxBOjK-9)V=5Z9Bq&EHcQO`)8*NTQtM8dJ|tc$!9d5hRj5hKp!1N)A<*m5InYf{?Cpc(+MK#%Ey%o`%9>BIcZn zvJO`|Crg(857(2QgGkW|{bCJv5g?jQLT*0`fWS(x22P;Xr+nr3jhT#D{%Fi>R^mhXJGAW0f<@6=ea{%lq>N?bIn z@98hNW1&wA4azvmJ{RIT?n45+-H3gVWqKSl!{2})f$fB_dsH6dT&=GH@0UyO#sN-* zEY`&VJ~uaj;vAkxepvv^b&*fOgMF9lTD;Ekr*2JHf&Y1B))pN)a<@S#r$KlupdLq> z{>vD`-2qf)P(PYYkEf=7F28J|*vYcnCGxH-SDdhmD{xmqL5|Z6p*liEy;;L3ZONqI z4(r$Wp}S;_^+u#}_-QH6DB~61;93TGa*s53R+#5q$~P@|!fFEV@3iY|+$N%_jejG8 zm5guI&&9TzDgCjat3%wurxSaoaV4WAQLlJcVmp|!K2GKv-bcRr=E9i|i}+LA{t%X> zL*cOXpqC`EAEl-6j<3Gg99ALKSCz*r_?d?11)umIj2-tKiJ=G!b(4|tzhvg)xhamZ znz1wM&`@4~CpLkcAliHP=o9s%5-B#scf}i528)d@Q=43YOEX{>{0!1DEkOaA%S!+t z0hBQHMU?n`lS6a~vqZ1Li05zMB;Kik!z*OXCbOY17?+}bSq}(z{2A4+ITY}>IaZO1 z%l3ZMx_9C-Vshbh9LQ2dxpU>gtn>}l{)|uWk{wgcUC9Y;NMrB9krcI!8ar@&M9uES z?Suxz;4C0hf5nD%_L1Wxas9c#KFmamw+6;SGPZf z=041-6mPu$@*Ia&)RXWd%d(sNi>H3Dn1+o-12%+Cp0y&VIo9ITYeTqD?H(4eV8vUb z3Wx(604_Lkajb1~g_1odR8TU=vJQ19FCb8>Sus$qV9kceEU30vRPY=6gUm~D6>x2-riGJjWY>M60 zRb4Xg|1PH>^TD%xjeIhgsyj?C877+@N;5U0JeZsmIK*Bs2-nEfnv=X^O;@kWphf z5NT|u^%6Yr{X;_Zog1HvSGL+%aCfdN#vH52mazw6B37mb^49C9?&?LRCO1eO+DzG6bESA#{u`_9ilci zG>pIrfO=O}1VAn-=U4(rC2Z~k^v~+a@I>o|ZC{p{j%ugIBCWLgH0r@T;-H7d+nG2XU5)1la(y&>H=>RKh>OFakvmpqCMa)5{I--(ucRpVpRm$|xj zZYI{Mr9mtz(w2x?tH4(b7`RYio_V*=Ukzi{NGVd8J*d?=uQ^XjV5bG;;EMyc^siVn z`W_kwpW5tmRqDDct=o(U6~7#Qwzx@uvvPHr9aY=f{`d?;Eprq}sMSvq)5^DEr$@Hw zv}gBo^h}U5W7E1pQm_PNUXq|LMHpgazWiX#lI1In5ltu)<|}9$NFX#`cNK907B=P% zq@orbMp23L*2Hi$z$Bh|CoJa2Vn*)RFhbvQOK*pJ^#<^4JG?z<0cDRqrP8LK#MF(45p zCQm(u>CV7p?#>%x_p52I<$Yi$7ZQ-HwwI?S{ZTkuxPih(PRk6OwHO;x|EgelM_$=| zB>LF#p($6etfc;y>ANHbIhJ)Jw!Hv70aEM=*Ifi=uw6N_wqE!Lo@17^$eQv%)Z0tI z9YV{r7`fI%v}6FgWi@3E4@F^ffSi;rFB{X4wdr#s5lj=%6#{)luPnO;8Bub~yKX#D z3Df$j%9Yf+#iT}jo$c&w@T>NadSnGX(PBowo5zZ365*R1cW8FIgal9a;LXNgNa`4K zzYMPrY+7(T{=D%WsMQu1Ii*9&YPAfasqXbrp)~)A*Jjit5zO-OF;mN=N2vihYXG`j zJIzjEuU8KAn-6nU44ITOD}raYbWh%)3DM807GAofI?WCE3!5Gla4k5jW=hmz~kMl>P*R#s~dQww7n~UT?^4l zR-m{`9hwap{&wcT=!ke^1#!ACD)Wq3K$5HjI;w*)IDjW=IKr)YMVr9ff?xN%Y6Lx7_vw1`jFiAqE2_aQoqJ>!8cp4b zdylZkksP0~OJO>Qj^OtqEg15Ge|lZZTphb0{ndCBNB8uK*npAqCi8$*#XjX-fiuA-tKim=er+UM?J7nJbYV3uGgQ|_IluhxP-)uJm3o) zuzkpYrx}O4R1mV@Ly7gD+H=*^{jO&YCwkvh>VqgxV9h9>P7-qcDhj#4gta5z{S*8` zkd{84Xo#0LBxK%j|Hk-w=FPjuocWl!9N8-fIJlC9{@CuAD*;W8vNmPJ-8)AH8PIs~ zBtq?3MQVZF5U>-;w6I!UlHFnBSBU2rI~Yyyi=NIaq-r(z_QJ4P zxR{0>Y}(AT$1?UklTKAyTFnN=mJOVFY`Ktl`>t{ZLWG*kzM?qb)`04$gpvx ztPP~$vxOEU0#I_!B;MR`FfCeLuQbm}c1r9cCiT0jZmChw zYo*G(7;Ac*s&+?uWPDPWBn)e~&E}M~5rP|GbAu6@3Q~{f+s=fBiXN0_6+9Y>j5``n z7-zih?t#$Zl#m#ln6oPtYTNk1C08ZS)X2)!~+4hfQ!PJg>#X5s3H75O_%(i=torJ9UU6R4u&;Q5kBy zOIOlQEJm+qD6}B-VE$XS-A;D>T>8m=2Gn8X%9ZTB?F5+9=(#iLOZy=Zm&yBI>)N;e zILw~9l0LQ1U2#~w5swEoDPdnAkXdTVgv(sPDE!m0~3BlcP8iF9N5(N3e*pCK(#C_kE-wKbTo~gi>&v861xf-k<3bk+<3TCP1e= z>3BLaEHZP^Ta;T)KcG6OS8(1M9=u+3=miIE0;6<42@Q8x(rWY#7vptue=pkJF7?1= zqQ}~_vaM}c>M1rz_Tj6+Xdj*nh=+MVC?xKGlE5=IVb%REZv1L7)rlo*G;oZ3NBlP> z@Q@_0?=^o*9MhcTfc;B`ihmljuF_)isz+@8<5o^kaL>b5DARQdjj#0D;lA1>f{B%Y zwk?g{Zp(((!1S3oJ}P}5uA82AHvDP%w-eBN=dDwAS%YH~05%g-)46ZZ(qSlAc~pyN zW^2lm`=G&JA2bw$@xi#m3yO4abpi4m#c{m3(f&H z4f9V^tsQ`~TrmVHVew7umkZVE0wp2v@kL%Z64KT#3Y2vcq6BzthE<(it!Pn$y6Jw{ zcx^`S7e?~{th$Jvi{m8-%6M1m6&Z#^z?Sj{B|ZR9ET<3%3<*#q0$?CU0C17|tpfU| zj_}~cgd!ewR>dun5of2sxay%YG9%sEz<%WD+MJ|fPKgeo2Ht(b;<)a!$`hkkU?06m zUSSu+7a(@*Hr51yIY9v^{ZPACn@IplO`s66T*8j=3nL`CKb{Up!~6jz_q?2K;GQ4K z!8ho-Z__kW+e~sE>;5%Y8y=<-ThmWVwayCEUQV#3p4oRY-mTL|tyfz{ENynuluml` zuRuX0$$XQ2-c>uh4eUEY_@1p-e~KO*BY@y31NW9Q{eka`&w@R;W5+=)9IcZ0?qY5< z2SkjAM|3=P+_u)$GY1UpK@taY)_}*fQ|)#Yc-(f!*gV|)Bi`d3?)klzd<7Nq-pL%& z$Qqn7b?{83MVK;hKRBdq20n7&8IpC@Af8`d2V9yT?W0#&FXvTFf1gm#5Uy5+qm_w{ zNfqlO5|d9>=VOR(7oSBfw-CwsZ?BwfniPElpBlXd(0RFb$33d+_f7(*isv^W+f}ox zEFQla#{DTYG%4+lLzJ&LmXZLvgfZA8eEvG~YZz!&-ib{#L&w1?N#^!&5oTVUl;4(N z40^{B{Sjn)gA;1_`ySz3A}6-2;Ga`yGUp=@BIgEkjn?qABCF64CC2*Ran>u|~&i?GLCn7S=u*I!D zWWtD#`pr{|Ztxs*MaLOdc|oO*tpry50LqZy2pzPw{uD)rBf3r(5!5)?tWIBHD4FC~ z_dJqa;1?r0Qi(r5x{nCe_=Lv@0IU1db=J5F<+lz|gS@`}=&b&C(aQ(6d=vBqy1#NG zxN7B52~Q4#LD+}rCQ8eSgS@q&5*^U(Sh%|ytv@>qc_VDq#c1e z(dmqOUd91vERHf*Q)SR!l>wV7gH}~WZR!lVR2lS%G8ohU&kO0j!mgr~r_|3}BIn{6 zTL_<$BZu9oRP(_2}l1W0YD>goGU%X3SFY0u1F_;7mWMmjLP>Nh+M}wN>j?T{`A-ItSG}1YdOC-c)zBgYT>uJmt^RC5 zGReW8YQE)CN4M!XedC2U5@C2aWSYW+I*3?Vy%H14nXpC>!JPE79(lqyG7<)KAvm%A z<7||`*0-f{)FWMB0c6)yN13OXYJ>0!#IAHY%u4n) zEfWAxSV4%5aoi&t`}KOQ#K6`_0z|vciaSV~nDa7%ZlRkuJ4g7sdY3N-)ErMEu)}T5 z7a%|g!KDzn5MLm`XZeyNfDP1!)``?Or9_?X$nqtB=SrZy;XPDsh@lO^=zb#o5*wwg z@H2w3@|dVnXJspURS2v(n%qjhbZkC3k3jRP`#2#ps5_nFLWw(fBN$DHlRJk2QrQt-F}Pzax8Q3-0~?u+HdE^{7Lq0KZM9U zA4eLPld327m*A`~Faj7)w}&b8cGjF&c~4lX^NExP%~LMcJpMu9i+~#M)nYVayj@c4 zQ=__PCWO&`=QT*<8d&RqjI<;~u^I1^9}@cz_#NHeVAYtGILg7&&`if}v2wT9&|@IE z{ka+3f%JwCmpf^mTqMKM-x=0JojJ%!j+vQXavKZRCn-YeGItGf2JvvVO#{UwJ#=j` zj189cWA)H3?8^o`pKZ=WnVl>FMP;?DwU~$|YyGvqn6*SbHKp$IY$EQUS?ds0!^96} zw@_tMk%;O+m+Qt~CJ@H*qCtLyV0P~0SNa5;#x(T^+7vHx`EJMdH7l~h5Mnq2bm6JU-5~5rO+T`n1YE{bQLAGuviaPy>6zhbVoB~z4GVZt z@_`ZIXb-Cf&%`QOr9S%3^43rUfu{JD+88lW!{#^P{Az%+e|C(9?}n^ychqk0F$4to zCAWl1QBP>$g)y6qe-6^tb|d1KuPIRbamx@NE1VOT zup@S86<*pAEHX;y!IMS8bX9RTfgn%8kE$~p(vEbu((X*FO&hHuf8ntau?UgEJdd?W z{sfX9uT2?usi<8Pm!?N+s})v+Q&eykl_@LXOC^=nQD*SiK*0sD;O}6I9rX=sPCHst zIkcV7v+w{5Qv21+!KuNY2mcRlzN_G(Df5*ANkuI(OT^x)?yzfcoC}uLT2~8#-R;uc zJ)GF1D^&6B+7k=W@+26%ikn>1xyr^GSp>5#`6eQS*_V;;s3da1Zua$#HU3a9jXusv}g$6v*keJSgf&1r#>K3!{0JqiX(8(+Ty;piLoWHM4 zRKdy-e%B8f1e292wy=JnXTgQy7?#ars~{O^5wwpv9Nhhlr9Oznc;~_4TxF3l*uH`=zEzg9i1j6AHdzB!?~9R zmqwwy-JR%PVp?j1x$GPEEL8oy6$}R`^FVdevZAwq+y{L?;gw^C?rB>z(k_qouT>RS0LN$TQ<9q%N=iu zEC}*1-sa1s+LYQxC7LV2&d!|j88b?S=d?j|XFV8Htk+U8e(c_%~&h+Kpnq<}n zqsa|*=ZLJ#S*`ITdRwu5J91N(Y{F&VYNLnoo3Hz!$Pa%H3!l<|G`{@L<@!%!E-^Bi zz6~N%93LbCq2RbK=Sw;%5~$;8xfQ8B%X# znD1PPOEyz}|EL0jRnUgSCfjO!OH&43!|gr|iX zf5=PF-K^vF>e%?TF&Ye3F|0mv>$ysttUV9!{75mpoyAWODiqeURy3iFjb2M9FlC+R;`{3|FVwVMGzHr+%PGcUf1Fa$CJ9D8=# z_{TkkJ18BRsGu5o%MrVkyC3?QU(YvxddhAcROc6Dx zE+h-(QL(+=?YRh0+vkFvYeop)=If* zFl%Sc;cT%awhFmD=!n2Hvy{-$zc+wtp4S})1sGShNMIl!a}?neg88s#&{7*W|`sF`Y@&7S5{}FoS`71TGWf$X!;mE8O{In`H|jp>&PU zrqut{hDZlJ2+bMB?pov)W$DyNdofjp*Ym!4swmL$$xTweK>(viS%CsD0P!CH5)gm@ z94U;x-1HVfB6`R@licXR%0K`A|NLMjD`myzfkA4ueg9OKVET3-?v|RTp>kzl!)n!yvVCpuXy7sOllrxN} z1||t{ljhq`Ba8K&T3mZtwQ!>MdNSq$43<-9%f9cpW$~0!OOPIFuC}^gLspKrCi`Y+ zzq7`nZ(g1>tA~V5emVJut5&OM7^qsIMqnI=AW1!C^`??^<1t)%YTB7u@ZF^59eBdRo62bxy6c(SFm6{r(V6k`g$`jBKzC~yN0=XZN z);bZIYsvD~F{`|ctZbfihy-1RoL!{yZYQ5W_*+{RbQ}_Zy~6f*rMVDR_@-rABbPAQ zX%DafIkS&u;%{>RH|77!ee#Doy*#+Csh7eBW7NzjuQ@ad2$y~!e#{Q=Ktb}*pa4w=DSc`79o!mc7ZgU z01;VUvfWC4{^olK(3d(#x^wkaJ%EsYE0NR*`(vlUPF3X$0dYM~#}v2lcrEg1R4Jag z$Oe-~YQ1axyw8s;>{6;?x2R_qKdpq~R%I#01NTq-i_B9fy*n?2-gPik8x379)gADx7xOshJ&1|LL>Nj=b$rkntoBrSgI3 z$Gs!!KR%)e3P=Nhpvn5H&}$TO+W1ZRT<(D3)f36x%qw{33Q()K>iF?5a7V|dh2^4^ z7P~3t4h#{k7imYsq>8|8+_{+HmW0|XYHw_tp_w+%OxuPGI1OO8m#}XCd{tENqZZ0& zeN~WZ>9h%EK-HPA8!@Z0qL8ExWi4NYYY3a3WyIm%qe5-sgeItBAR&A;8O;A*Fo?b? zMy8#tsAa+drW1t2;TeT2qTuun%K^V@}DTf67G*^&}((em|d|+cVmluU!{fCY#ZJ$39dc3%et(H zd`=TVUYpDg)En^Y_5^ZM-`|3vCi<+UW^Ja2N|pEh3dAR~OnE#u^FpGmHjME(ltW zFbM>r*SEiu& zuk)?*v1cUen{2c&hQ?#}g<=XG4Ij{oHT{{J6u0UZ=qCI6Lx@EP#b5koOpo#v8EVF?L(*_%R;+2AGkN0z6H5x*#h)Yg ziT1QbZ&pbkZ69T?U&>Clhrw=%Mq0pBu4bp5%2!3^IwfE&3%kuS%_Q>@h06%Mplh1O zXjA;^st2(D7g4P8e~?vWP{Kyrfvy@Zo=SB+%K~L9X%>#sk8Jhzy#1QFgac-c0YkBS z0~0fIQ;G7ZZ&3E}%IyN!h|&W4?ZXaK1Ed5+>_EQdAi7ucW8vYZ2;elqG@g2B7#1al#t>(M zmb9h~Nj$i15ask$tKJ7z`T*NBmgoD3)Fd2vUP|yqoa<`=0a8hNY%xD#HXJC2!yD@A zm@phteN(AVdQ)*f_sN0kozVcKFKRr8A@zOZoM}XWzhn7G&X&GY8 zuIN&suS}m*S~Xc*+l?gU%>73W6Ujbxq7t~~dQ#WX@v*i$ag=Qm?_-9Sv)s@1d9yT&W&b^zy1_!>ba$d14LA6HnLRius*8j&%N zXN4N;dQ*+<4tGNfi*;;qK0yFD4KPUUa*Y6dgXj{*1PpG-JgCsqE_C*^*isuVfB>Uc zH-Q3J0I?(h7!YUx90==0bN^G{|F|=%Eo1uH|Ky4wkBH}Vf5ZH8{+{_qKbW9j(aVOJ zxZ`l+jyS_0Z>yVM@#oxT6A@{7gvWjFi+cBTgai#m|1)jegI@z#I0bwROixBwg8(Rz ztaE@28$dun9PJ4*KbP9pZyBK1hM=8+9TvU@w^{%i<}~;Qz5f^B08{(_`15zz?84v% zMh}<31UDEfV3d9X2@2mS!3Kfn}1EB?RGsGk8^zxV=ewGZ+|a$~>x zSQWq_uYfLanNt83)|J4zvyImv4W0Yz%UeJ89=As_yHi) z$N&Hh&$&xqiFiy3Ko`E_|M}Pp{02BIZlD90Yv5~<9snBer+@~z2%7gv>%G)KVTv~5 zel8aa{@noI;iR3>NPLKbvAmij?fyT4q*Lm2s(OgRg{GZPO~Vc5ZEh!DZ(o1=>Po&2 z1imk$SftTy@e8}*?UwRJ-IXSX=!xf8Ox#%!Fs$8(>8#@OsOD2Sf>2qPqh`W7O&zr$ zpj<{QQu=X~*4$&TpsfOBe|NKxoyB=vTNl%=0= ztZJXYrz}nQDl|49vP}N+ad-w330kpGieb&%BGnD#2H)L)ihQ=E5vBQ+9grKMWHq zR%r#njBhdJK)1pM3=pnfv2XPhK56=Mp{KA-9}3sHif2!C@W_}BCGq6%SbG;)f~?-T zuh*-B=Bios@tXws%I?d@9#MH21|5jEsi}k7Fw15^Q%)Ee8}hT9!($>^HZU4puxGPJ zMa?!A&+l<0Q3iV#VGg>vM!V3gnct%$h=W+D6{yzxmHur8#?Qa;t&&+cTBv z;nH=N%(`pHrA?|b*iWO02W&QLV#}R$km{tgXrJpqnPI3%sF>%=H<;8H2x!voupI$1 zPtRkh$-8FP)VWabY^;g3d;Cd2mdiCFW*#PB<)hUpJ;^$y7v)Tv+#rS=p6e9d^zCdP z;Hb-`G8^0<9C1xS;Q9zS=fTf0g=iR%&h+}$)mS-=Iv&Cuh_xI5DA^+F>gK}9A3^-m z+|X2;7~D}7+dGwB#u(`M=^1?=qdE8{+AvKV-LTVAWStUIo4#{ygvkWIK{+MBk)y}lRJ$g)_Qi8``bTW%`RYt8o77QL`)P7|)>*N?@2fH(UgKza zAfd@bpQXc0nJ^k0;;r`%EPO5xZ3r0A{4qgydaubq_(G^Z;ZpMvW)x$-&pBXG4zFw?@4orIrWW~ky(i=9?)VT ziyxdNxTArng`~?Lv#-wg4*>)3$X%!k2R)}&W5v6XtkpFcW%4|ik ztYWC>&M+!h?MK7GqhY0q?jXtpdR?zd)l&(B@iMvflZ z8YSInJmvQrAA+SZb|17qR#WL~%2=Ycwwg8nC(tHl*ZV?L95|x8(Rl%KepY^6M4Ml% z?|9L^0}_YZJ2=sOMEATmt7qky2y>372QeH9H3Ws5T{JFaWOlxRaYG)!a-xGqX*`B# zWx;kqNE}JC82~+ErkWVr2{o}o6`n6mKyIN72)WrUQBhY(F7ByA{)rmdt-$(aOw%2X zXne=$gTF?9nL<#JZf3-*+L4jB$y}nO+yylx>M1qYeWjQF8VJ8+ig*(UjRWt=_|dgY z;KV}{BPGVfvq5cj*=_V>vvAw#B|!-g6D%hPY^W~q5T_OSg`jC+&_g42=aQxg4ZEu0 z8Lsb|*d+HcrWyVJNmIPiWDpnNI>m zPly09`tW-uFZhK8Nu(0I4#sY!F?*QI`?fr3|`%@_6KGhi<95k5?yT_jC zfK$qFTay;}%L+e16QU8S7kV$n<+OJiNGr^$EE`7>a-nrFTYpyFXCdf+Ept6du;_*< zsa8iM?=`62E)khU;%xEn0y{kTy_Sl(GVJW7+W_#!JO?SI@u_=~&I!~O#@`XjLQcoT zy-UkOnGfA}e4USpdY6`mG9S9``8yvH^)D?BWIuJ?@^(HY>RwtL$bRd)1R;UMg(*4tbSc3_ ze3ymwE#_>7r?vRdx+wY#hOQe7{WU<9Y7ctc={Fa5&pX`NXBT(RJKWi47kAG)+}URr zch5PK0DNC9#6)Vv1zc36OQ{4y?zmMUtPGfe*gp^eca7DC}Uc z#)%tQUZYA8H5su)t!2mbnY2DC87C&)qh8xawHwXMT3)sUn(>cx++9@WX-&i9WCwt@ zP|b+fRpvpkuxV)nWv=hIFNg&kgk3}WTYh10Mh6_Y*8#14Z&9)xaES1CAnhZ@6{p%7 z@O%2({DKA`yB8=_{W9eQi68&P07$HJui#MRIEqB0Tjn5w2Aw?|;gP5dKn#!t$Of+; zM=dhGjvj}IdAYKN+KJ-C?dtX#4bY*+M11Z%3WaHg!faKF|o+b~q{Yv;#X$^o-wt%+~ppmb7;Dn~s;D1;doW$XA5gG(0Q7CgV zaM5Mp@#4%pY{*f z_@KUDwcBitta2ZK`g$WDP7g6eH?tPf_O4WfVAs+mvi8l@KqUCuaiM3?Gl-b9-s_<<#_Y>A@6dK3<^$^6lWib)CvDJcY%h1Jr%h1K@*_4vjt(ygG z)GwyxWEb9uAPQzaEMFEJNfEpv&h zRCvm%8}ua*oY{VHao1oNa>?&wss5<9uvDWX;KL`Hr~(6uR}aHfL>sqMU~F17gzc9tp&I=GcUMk z0LE*7V+yeAwf`N5x5mxA$VgXCy&Ur0xz|C+a-FhJJ zKY@L-`D`YMK$s>K~?<$5sk-x1fh)22ajlF9PR$04>TQX!VT5$TN%a%T7oeHso zpXke^E}jbguh_~O2IN(NgIyXje@hK>qGoIYW6(4VscDN*D|kudQS`liRI+{ydZOHwfkg`tROT;4wZ5yfXyILFSo*liJX{^S3$HZyK8_Ec4BJD3&* zYL*ecfXye~k=IFP+k=!~Q5zS~a7TzmQ#sySw@7E{tXwjA7@8qh`1vQ|zF^a|glSZ= z0F3^K?vt8!7T-Vr?AT23h!nb&a35s}L%u zLhY44rbf8vY)u(xVS{Fnl)MC>E(y;p8^5u6gIg{o=8H}LhScq^FlvALH*h#`;cMok zLHB5bnsu|>%lXBXYzVj#WVvL~Z47JzE&s*(PL&(3lj8kq+4XT1YX#V~YokKxwds@X z-c0+M@3Zl8V>1lJP*wB3a%8PGC|DGk1oizgnppM! z_9CO-8j@B%5(VERk=2Guu|kW<8Y8|*0`a%p|1M9Y9@xDrY>4ZhxSijwe(4TA5VjWk zN5_)$F9W_wQocG-iP%2dUhPcD^`FGN`FTfpGj6OPVu{}*fvGuR12#}ofF=WlE_>zg zSg=h8Q@BGCwnB}*_)G;!;{=<3Dl!Wa-lcPV!#BwNdWu(F-8fx%!@X*BAX|BE_v|kA z_~AxF|Dy4Gw5Go2aRBP)Hk&0btf3cy5Q z9;JWxOD)yjTe?U$?TL`OT=r(Ph9E6S9VTwY+-p3wV@V#M8njYaOGIBvBnQ)!lxb=8 z_^42_mY&vh#(2oiiv`}mTeY^2qtY|!Y_10V1g^M)X<7Qp**E=Rw79z|2_qH>*@(Pj zYTq&q|5>BS$0?8RK4?-^z^Y!AQOzH(Msl57BmI^pQ_g-$(|wW2x(WKIavZ^KvPzhA z5sF@lo``PYiL5h@&oj?JPGfowphi%?Js>Td7^idemShKA#Nzc?=tR&+MjI79uXW*X z_RlmiQhiA(S~x^j(;8kCS z)c^K5SZKbhKIXpv((#W3G>~S+$PX@&wnSCt`fZR?`mrleoQ9={gn*u~lh+0b9@>)l z4x7R0n`@xq?o`Ig>(=1#{N1_nE;`(5j64g1KiSD9LnF zTUIjeOX|O@xVomrwSOcQC1f%3b$ms)vh_OfGPOk8r>GnPtk#Jdfb+eq9iV+|rh}~C&Pm459N53xP@rr}U*Kyt0)jYw*&{C8R5Gq)7{U`PluJaz+AsZx{}2(Z z*BPlCAOTSrha#_Y;*gJx3>d9&+iSUx7BzQG8DenEEh9)~6c1FmT77*6aF+1G$zgd5 zv{i2$>V3&5(QabaC>L5t8?NXLO z_|wrLg;5-Al69v`XeYSY-vj zNe-LKn@c|d>N*Wg2#Z%AMxWb{Sr31JnhX8Qk3lwi>MniVthouVAil*65qhO=m&BZd zo3V4WkL-)ZO@891$3Im@RB#X=lTIJqq(7~)A2}f6O4N}cK;O7a;3ttwwII951vW!SM>2eWLm{3q53qkw99|~!mw}B<`@UGb_*iLfmA2^jUt(WpsAMKzpQU?K~X9HJyN^QU-ya#KeR+F*7KQZ?SCwWd=FrV5DA;20a-3VXmRPdA z$avy}9u~)YyT{s1N*IZE1={F%Wx8==gu&RnIuGX1cXNFO*7v5l>LXmy=kr?#b(lit z?o3Fu*`s@h4IdB~$NPhm{lOjoa4EK2_}a_%*)@~1saB;#iD z`WliZKC8JN``)bT3R{?08#K|G*u2q>Aj~uwG|Z7vA-MEaEB8}FvZEpmxY|qi{Pezg zQCQkx!?G>!6Agb~qyT$N5=i(n&Bvtd(BL+@D_qXqn)nZoNY5uT6TuEqF3>XRJP=&M zCX{WRK#)iB0dVgm>Z_V=9-u&7>iIp=N%`rn`_;k1zXhw+o2on8>3?L}W(ob!B6B5; zKGMtzrzA<<-ReQeOhQ|OM>~yK+m$@dZlY3+@T*@Ka-o^5`}&uVI;m;Q(I`M`{|txz zG^S;JKy`!^%=@&nH_25>7OI=*fxkhaM_x1TJ5aYFhRjH8z59LUt0J5)lEM-w9@}82 z8N|TOB8yWI9!C|PMHr?-T@xMfE)->q86HdWC64I}hTnArw~3i=$Ba_w>Q0+67(KSw zH2iJ%MmwnMaEqU)Mq>+rqsoB-fB>-v00$846k(hi23Aab-~u2401{wX6n?0W)Ie>S z!sL_-B9GM(`iKp)I9!r}WKsH}00000000;*$fNZ{asd9VlqSFcqd{?j0;mAd5&#Df zbO0XLwu6tQ%+w!mjvNYvjD;Y1QcTf8NQ&6V=UQZFYXW6~Dr#;g?!yrvCVkNi15C6v8I{NIdiolW-v9IE1Eg&K^Q^L^RW&L!yY0 zPbk%=64I|CU^8MeXHJfN;oZX>m>u{v!;bY(^ z&qHEvU!6$QyyM7&4?pg#8zpLYd3^^CCKoA3Ome`Ouz8Uaw=YscBy#(B@Guwvqgge9 z0?+_)B>)EyQ~(~iOr4KYkD>ESkjMB9|B}2MJ74=R&VM$EE??DhZU5h13JwhXz->!o z^v$vQW_|%0nZ@80b;=otsY73PNrh_>yW?1zW+IV*0FXyr^EFqrn!Dpm*Z?WZ;E!&ER=t+ua#N<@065)${`}e`fC7L2{nU59X0F^_^EGzj@0qKUKsgFq*GqUi z3@NJQGn+D@u1V5anLkSVOBUX=xdxMwwV~VQ_3~2uD}d3lfo8&RMHd*|ImOuV{%4L+p|p5 z5Q~nqa`jU^nZz6tcX-&*y}?y5CsttrZ|M6I%4ZZV$OwV+wv+xx9+bW?o81-kvds(TmaA9X_1CCE6FiAN0PJsx#@h15GOZb3C1VEjJ8Iw)q77$$dW= z=OB9azc=RriyeD8czJwF8 zQAm>E2adn2@V_)M5RL_W#B(N(*Zw7c+H)!Y6EbsgpRwa%z0Bbb9OYnK70Q88gC(_ov&i(#Yb{Wc0gBS4uyQ_4j=lf-mk+*pGo3`HTBv|;dAM}q zJBCL<1iN1@@$2ynlmoAfR=87Oyx$7VuKat9YYR7p!iNFpJE7*ul?It23G_|VG}ZSE z|98gZj}qcCE*JYxZ9gK>d$Fd11BF+?(LirtqSe8R&#;Xj&zD3irNuOyAz}!h=Y{lC-p}7@RYtt@$2LS=f04rnf>RJ-Qcx*Gnr+kITk0WE7vIj z$8p?tBUOcASW@bvpDG>__{{k2W>c7Fo*PHKLv;F z5ZVa}cLZl+FNF|XUoPL^I-E3!c6+R@yu(tRXLu!1_U;4(V1P`H16*_wbCFQSbXfac zad_nqa)p`;-~baC=$-jgeY=4H7$6fPf#%V+v;j)NQ*jSJu$|bK)VT55jgJ3r_c;{| zXGM><7|_2ce)irG3vTQ+OK`*r^4ixZj2BkB(B1Y6~LQ-v!wtv zUjiy}Jeh+Ej{uoj-NoJY!~~n0Ev(SMZGM0}lfC1nKFuNT~W+~)(~u|AC< zU4EEnxM0GzqR?G6krRB5eq*&kW*sn?V!DjdpCE%Eq?>aXYY;xcQh(pA7RZ5f@pN_C z!Y|VT1bEP_PE3*bSlw>a#`jmpmNpJVM|2NDF-xQQTHN804E-nYPkYtX>SUbN&{Tx) zL#uj6B1{iw_0M5|UR+W6+UGz2hdilOQIZV+Jg-py_$2gsm@Cjn3K0D`heLYN+H`1UuTp;+^pp8i{2cotE;IxX%x@)v95QOF* zYPetq)h7mA63PpTiPZ^@&`_iIZe-?=uOo|pB@;$oRSpUBmER^oCx10+8zHIY1V%B? zB)QZRUu3QB)LS>g;h8*X!AjEGa}@4gC~u*KS1iVHoqk? zP!(>DK-L*u;k*JkeX=w-%@R+}?(d#d)!9Bugc3$`s*5)PX|%TH8FeN;Z#|xt7g|ihFir&oMWujZ z+Z93)wwX*h7u+y{fyAQ5Iss+VHOpQF*+pcsaZtrPAK-C6E$!s&prh396Z$9 zU)h3#3vDR8ON->Cgk7VY$1r4`G_KDc_f)I5%xh$`;VbINt%0voRdn{5$#{9d6kIIZ z+5QdO>4!9MdEIxsRqKO04*@m~k^TC6op9o~-Xk|=Zutn@lpY-RJz4h%@x5U~i3qE^ zenLdtxl0;kTRTZlvliJn%d#4F@qB)}9X25Si1kGKGkw@6NF*JM(wmNkj{Nt0YY}XP z$dOc|)Nyja2j+{k+L7aYSUTu_jBbgAb3rG{ z%C(vs*wBsjf?A2r>T#g4Cs3V?r3pI#=UNMn$e7CYwUwtKi`V0dL!{kC*{iRJv6cI0DXr*wAC1!J=JbA<#cg zH9&{&Uea_A(~VFe96?HbAw{%`@y@Qp{#4Y&Ac=Vw|4Z2#|2Xc%zfXa^h7DiJR0!oA zVsjG_9{&>Lii_C&mi|B(FX(IGD-?W8r=wmnkR@ZK?|O^4keo>dG$NQU0nmxyVUPSyw ztq3<>v5EeS0vGMn^+k;Xe9{zb?R`I$#uWm@rNX(_CE_P=h}cL=Aurysx!gjpO2?!D zVn`M2Uut-)YNP;DYV1V?Ixzd|jTTXL7N51&Gf5uBkqyH6Vx)zS`&jA3D+adNjmpr& zrjH?KeRBm)Wj$d)1y7#oXQk=)hFf@fY02-%1GHB}Y6&mzq;dMHhdbH}RUg2XW_z&z ztJ5YX5Y2B)VlLI}738EV&iKc3xoc8(e=r(%zD@&k%2Txf4V!;mIa#}O9nJ%zE+`_W z8PZSEeeG|uh7K9FeS|1<*ZIs?(E}bX2*<-oh1_Z`s!(+4LF4rixv;&6khCI)s=g%Z z>vh%}mj7u9%@gvfqeTxjrmv^Cg3*1sKQa03GU5nrLtnwlS9YW=K zOK50$?b_=hh9jdZf21Jy8BjR!PS;j%?}L5?fl+g943-s9S%P*I6`*w|yXSs)kBq%! z2Z-|3Aus-L;*Ev)d>P2Hpr$rGyqjp#CX`6TS_k-ihsb=6GNzAhU3&66!3iT^-Rn1< zOBKuBz+R-O#~Wh@>EHBE#fs%scdRi;Ql2$JPClvLe-7Cg9Dbw_y5)?mj2y4TD*_}A$WIjN)8pQJ$Mrjb>cle2(dHx55LKymn-M*FE$=`v)t&ZSeN$&%YRX&1dH zsTOBM?zT1lS#3Ihd0Ck^Dtzc){65l)X48P|r}76_0At(PKw85bOq5~wlM&L|iD|M8 z35RXMz}$tS!rWg$R-PXoyvHDofpRYjQ4|?ZziF+PAeuqw5FL&r0LdszxyhGC?lIRA zRj^oE^E8^I4#{cDQvQ@|8OxQ6B!%9aFkD4Jim%c&2i5%LD^f765o9XyEig&nU?GHR zR5~%MUSl(c$Y2J0btl}ejR}LR$qnd|bJQ~N`J?eB9!EG$ z#!mf5NNpYmN&#&FNhz_J$9bk;(ZYcz2NnpxF(FVgZM+u1+>DhEi=w+sPFHBd zmB&Q_DbX)P#W)ax5J;fzyn|=+#fsQ7buD1+*1sda)-nr!~kEvnlttp{$+{0)ngX7IDgt1qE#K{ z?Jso7ns?Z}j3At+q<>J6X6|WXY`T)hFA7bh)kVC6@Lew`Hj?Xv@(-nj^XucRt9tB0 z%*2ZN3VX-boTq(SAcOHMo@f8*6wn!zC974lg5SCeDpunbLe7L_jtV)_`GMFbSee&N zeL|&n=!>%b17f8ndcLboT3RsLMDOsn*SJ*Re`nTqlJpB88_-jg3$XU?5YO_KQh8XR zu8=-75V-{(#Q6Tf+tKMca_p!;gRs8Tb93ac?uOh*TAvl&4dj5Lo+i7+6ax?d3Y@f> zst}yVWU4cOP0V5SM*4GFF)E<+7nm?}e3pV~k^-N6WIK4oQxuPJfpTYzRk7S^H2+2( z{>BpwvF&AQYBTQRAQ5jX?JHfWW$y$m5wKASc6S;&TL`{|%iF6=J{v4(H`+(7#{jM- z$~JQ)=)7sI2LN};)UOON0E^Va^?iXG00I6=obr^%BC6(!N`4?aTL;oUhV&c-rb=r+ z@9fwPnq|>#ZW+t6GTq%uAJ3?6w!2MLKq_|@dkS+|VEN;HmWED~!!DSpyn(WwWpQR5 z9BqQQFWnia)X{CjW%+>P@V*|xt@5WAd6M&$;^T(Od6mVIT(;1YA<=N)T%>i&1YP!! zh;rF8BoIe|hHh7yhil*1Jm_Svh6Ppt)y%M7<43 zOrcR`Wv~**KDJjE?6A-PeFhvag}zPfxa|FX<+~O}2+urih?2RWAFh2KnuW&>BSY|s zKp^2!BfHW@z7ma3Jkp0#SRkLOw>kKBmih7NP`bH)k5!+D$=sf<+?iP^>s>;*Ui4waASQwB~x@=-X8fF z4cVVuadas1*zY&)9Z<5q2r_uPN07|Pqtg?wuy5+E7gPrqT~ zT1PI3MHbK1dh!rGQlOK{M3F%aQ6NtssES3CnMS~JNLf4025uKJWZLKbxW?!120{}4 z;lmg7eZzv;`^BIxnvDKPNWS?&mN(S5%9hZK5ZLHl0k$y_Z))REVTjB<5a?`1g&4gB ze}iae(@!Kh!SAGjvd|0#JUJ{oG6+?Dltjr;!D-*EaM@{hoqB{8T3HpyG88Lzb>>i9Mo|UR=f59 z>4yy$#303v@@$nU6nS)rV6bu>o4f zYy$K`2{ia%ZXG0)U$W;AD*LfewwofR7#|VlZayxO;XOwkiLhH?<9E`QL!}blu)r2x zfekBoG@XA)yrc|RmUkvzVSUU4s_05L%vNmVi<&)Hpb}xNB&aX`E_jBOSR$DASsrtm zW%5Vbs1oZz(9YYC{^?WySi`sG0IDVcWEzc8VIq)1JK4MdTKVNQpRiux^#kv4pEkAXC3a#)z~Y+RfkXN$sLMgZD6DEjDyI%0vj#I zw4K-vNY+nj+!}i`|9BjuD;`arvEwPqr%8` z(+r_D13Jl@*jWDjH57>)r4y)jIZVwV_$O5Xz>Kt=XtZhV(Io%MC3D9EkZqZpBbKYN z{&CWOidYpWUbMc!YG9N#T5aY#jAZ*|mk$*;Umuz2>X2J}DXI5i&*M06u~E3>WrS`n zW3DF0-UvBSvgi~@&$(O=1O~|538bo;`Giv*wR>TYPPQ_R#Mg+6fhGEi&Q1@FSk@ra z=!8YTArTERYWcv&pxq7yv-epsQed;>XjGT{BT`_Cijx7;)onBFTUX9!Wd~*$%ib*~Huv5I>M>CUF%M)f+24{heuS}W{dR-`CMxR~b%A{ukG zY~oZ%ZmMQI?g3%zg^pC3wi6Hhc0qtrgz@+n;3?lZq$~ns3?%BxQA20{N@C#ZWx4c} zhNvxUiyV8M-Bx*l5>bTz>>yJs$R3ITCfZXz$RU>l=SE9In?!zeF22Va&>|$eXshbr z%WF3GUB8J%fx}!MSebpUZ~70AoF({FbsSUZcebmiCae1kxfwWn7C|Lxx&EZglkl-+ z0dSFmb}RI55zyZ<)SbR18(P@EHH1|G)St8~jKd2WI(#Buj_~$vOo80X{d@!KVn~~s zXkha{ii?Wgi3_mNCXUNWds~=o>v0dT?qcBxj|0N`WENDIbLW`6i&;4GIY6@3K+X>G z(T5`ez^hvldxQK@<<6hVgA~AWuYn|oBHp$hVEiW`Y`ECokL6JC1Cxk4b9r^?YlX98rK*PH=5@%jrYfm6$7be|n|;O*=zu!_f7Y1r=2(ojr671# z)D;V<9^(?@#yNa_^}+26(@6>^^gx>?=#m5Uyu|PTM5Pw z>g3?U&f{Kwrvlalt_gBx0O12rIHvp#_WK%?-0UV`-L=xWbXt=Yf(Osl##GZnYiBO2 zIOL%YwhMBIV$GxG@JG0E(jq-m52hnGd@};h1*=_nyVn&E$Hrg09kc<60oW+$&@Z=E zCi(-A@0mXdyw}v+Vywbshj3<50591JlqJ455D4j+C7834%X*eU@w@yczpP{tstix` zK$lyJEZ(%W`u1pp2dUTz+U;xYJFpNflUPc7gAuR8z$8_}i5p=fyu-cW+BLg?OEnJrwh+<+h`w;p0&k~NlDL<`_ z5Yp|o(PM*Y#o}`;bY>4aoZfT#XpqiS$TBIwNX(KeZk^{MK{f2Zza8%i!h|^eiWKx>7>`Z$Oa0 zvsFC?YK7vSVt0e^Nss)(uX=*K#}7%$ouN(wR&bt3dP$My(aVmy;bT87q09z@+*kU6a`+0 z4w(bJusy&tW@!cAsV)n!VTd!S7|4&WOOGxcU4Ka8dLN|Yx-p8mGyv4F|L{BCf#bFq zfKu?Y=%Q~WheaZV8f>EGMc%1Z+9&=O1;@W&<>ZE`Rzd*UNgp0}^&1}_q{=?!kwBwl zU+s0py6PN4Q2_}dY+~V`m(n_e9IRWjWV*FwU?q2G5+9-fR+OhN+BHrz-U*On8GXxb zM+B68dDo3223drbkx>NT-G`-0WdJpTUfNEXp)3`IZrNt;xY-erBM+rX!!R^P=cln< zoW+*wR8BMgl4XH(W^|8j(-vB{TKl0odx_2P9ftB7? zmV@jVG;*DaX4i%)T;{nCKtTiHmRIqoHb0|XTUq>NzZCpYtcVok#3ow;wlctj1jfCH z0Mr5c%sV6}uIuKnAX<_2MDEy;V=P(xB;la&7TEM-Hg5yJ5IC&*oGijpjY}~_*OlXH z#kk8^GWJ~i5CKf3-M?z7us`Q^G>zKesTY%8rs-8FHpmFUu}HdCjH6Dlux}bji+LXi z=rH*Ioz7Yy%Lw}VSHU^#PO%X>UlZi<{9RaV55@`<#q-gz-(m5Dl`gK{fz7Xc^JpF{ ztccI!4^i!={quwmkD!f?G(M&=MaoskFwx4H868MyxJpxz1f)pRB@BaHAJszhT|gV( z7x3qb)IJqF4A_bdB?bwG2+UtIJm2Oi;BkUpFY96CxkIc42dy%@w^l9=>S!lNzS<7?5pSrTpd;yW@4Y0AK+#RTZ{ND`G+&DWd;o9F9D;7?T|FD} zRM**tefDV=B|k0FGV)k+Q-0J=-LOa0!4&A znIS{K#b0rgVZTXS#hIR%rG@Ti(G5)@ma#IuW!Ts08a4S^Kj6Vn_?L~~je-K1a55R! zRZ{bfPGjCe3r`ka#w{L(cPuB^RCP#4li!fuCrC_}IwO}d2NHhc{p`_xnQ(>5 zFAK{Rh8J14eVkvVO}<~lay;-6GT37A_+8=sIv;(&@w=Zw_mQ$Tn?b+iAv1nPBdYX3 zrkuiMD?X23vdAZw7T-hIfPg{}5Bk)2au36V{3u8NC`lt${vn&~6LO16o(K6ZPuGhcrZ*Q?INdBfQjAl@j?6406NQ?93flyHTiY1~V@CMQT?L2bUN+FG1OCT0L3#3yfs&9|%oaK8 z0Mwwc6&Ki5EM1G0->0j{!?`34Olm|EYF3yM56w46rtg z{p)a~sf!^)kK}4)PjjRkWAQq!_+G?=>cjZ3pfC9%y!7jiYgMZC`F~8|-E&llMX`2X~erTfk zRni!X*eZj?>p8oBwe^Ex)eRbHXzMj56HH8^W#BSX(;A|r=ofaoh_VCvR~Ci~a13rn z3l-f1|~Wj zy!dn`wi9asSK9TN0~H~{`o#3JjN5zL_mEjCjQr91W4V1IgnN1CG>Al%Ca6eO{K)3S>{E2q%loje|{T!R@-^?bre;~3u z7MSmDMrl?lrUP`RdPH-@;alqeaD*LS=&hUIZT5vsC?gstH=-PdP(YLJ^8Sm`-HnI{ z2Xxymyf{mP9J5RABa{9|EV686-whd8;{)GzPE^P>nY&20N4v9TBF14DOIm?#Qu~(b zsAH!bxg%SZ`m4}9VDj>ypxae14CnCai7M$CDSD zRSGnM*A0AEjH`iEiq7nitxp8NU$1{LVWv)suqCN(cRgpGDR=A-(P-3dGILRu=lCMW z(Wwe2R(gJ90U+b{A-L0B#cY0?J;+dyWy-1O(sAr(A?LW;j=8O0n0?Dh4WPm^Z8*u# z1%9DWoEpnrHMVQ8^<)P~6(vplLm5KfTSRsEdS8J72-=gxZi!=$NQt4Il{T_aRi4T~ zVIF=q`*kP(yN^z$)IO~DSzKugR6<3Zxm9m)5cX{#Yk;*cd4ndM1K|j@`k76+_I8{! z`_60GAwFhG>Vx*#`o@f4b7fk|O6e5M^^vK4GLL$6#j4xN1|ue(A(>w!N(k=mdp-d| z796UDo!m%vS^-Y4Gr!=+wduQ?nYmGb6cU?qD(I^Z7SCA+$45X-BWdcKk;IohO+GIr z5!sh6@UZ0huh6*#p=pNCzRfX=v_866g+oc_wx~GTILf4Mo{}Vz5Ye_Hv&ToV#niAR z**;eaQB$~X?_76el4ve{)6PBDfIk0GyPYG2-Xn_u%&aoHXckkHwH98xsN~g(@ak0* zoiezhwRy;?+Sf*4>oC_r;y0oCVQgYT%ELMpr#VF}PR!fZ`JDvN73|MG5&l`7eM9pt zS`)dewehh2x@c<$7F1T2F+9q$u{eT~Q{#8SvU0Y3?&3792W>H}6*o0VY^UP=C~5>W z@Lbq(91}R;jhn29K8ZL^8({=*lS=??-?gQp`$<817{f$oMjf;~_f(#y`tiM}nh{!D zrD=6tezmUZo?4@geWWhy_U*AM&iu4gXaEH=)gQ_?{jN2nc5;aoq9_)N(TJ36v(ftv zsBg3+V`(pUN_N?V>V+8GIr*bux=;mb=jnkTa7C!Us7RroPpD-3Eo7rxDZe(d%i!kZXgW~+f)My0|enIR> z7WGrJdYVVP;4IVP3n)cBoDdTb13a!lX8>9DzoRWnPUp-jA$swG9f-1bBP+^LP+ZDyK+MYDg$96jfwBM9;Y*F;1;c6OwU_Ttaz(; zq31yk6k3>ztO=*^?tdrB{piEeY0CU1z{3!|23E$uwy}QB49n(V7DB_Wy=MdhQrBoS z4-w)PM`nKZ-@9s3d9;4d=%C^~QxdJetkRjghTuUPei>+az0l=J_1G}RTsGLs!hJ;_ zespB*PsQ_~<&)1~gU!@sEA=Zw1-kai;f|N1D%n&0>jYiGFm$NG7airY_aWUN&%vTB zTPN$L$gGC(UAoU`;K#D8#Ko_msIH(~;Ff5EhaCCEC412C4Nq9Y_L*x0tSPC&xeTU& zdPV{{Nx(8H5GWu9N<*&HOOjVkguXD=nC7VCn<57hrcY=Gawh=9T{f`2L?kyw- z8FS}>A||Xd=g$7) zddv=;Le`^HA+iYaGpoSzgh+uL^KMgtz=q6_bm|thAr3D_=!2(Fi44_5rZa?5$>a!t zRk=WqQmAjxEzKg|Ni0PKU_#b88+My!~tqxe@7aG5s!XmFqA0@KA$> z(d|4kheGl_E@rf{0m%`X$SEobddbzca4BmdW2Hc4Bx0wEyH%j%F{P-wA z!)W%N8AG9Y9+w^J2H+sC|5`nkRL=Uwj=dw*(ZnmqpT?$w&+>s_nSkN=$f}J>QgH}N zu$+gVZGws#gL1)^@;YaGlsZI2x=r?j$}eJ>52|bZYBRgWN1Xi=L_7esQPmDCa1$z> zom;0#%ziEd5)B8=w6eVwZ}(haiLvzJWp~QDbT%+CI(%0;FrA7L&Ho06`;~;N&e*?v zyx@rsTj{HCwYUj#)$w^FSWzfupnGUySC$c}z;JkR$VMrxnIMq;L#Br~ht)NxdnPwL zFZYn6h0Vxs7Nv@3DaFm2?7@&l^$O!~T$t{@ZfhzmS~9&)ez}UGccuUqYl&3isPSXL zAYIn8J4EFiyye`I{>6uT3rMQhDmYo{KB4tpwIq@fRz6`BtSBq^ob2s*rG(MskCfoH zcE*XFMJJKTeS9_xDMt4rdi}$-^H+Vceh`IN!<)&KVm4-K32BKb$-{3>ykoTQZCP3CvvGA=AfJMUj72% zbCSqI1?0G7jlE+N@D%H``_eDWTMyGl6IHd$UJoI7mRYCDo=nLc6fgNU7px?mqNA^xBqyuhG zgsuARx!FFTLkGX(IrQtUH2o_0h{#6k%pY2^#|%=o92fSSF+tLH7MAQw6{*uk{vWw& z(Ry2mnNAPL8@tGC&q?S}3Q%F*`5rYy8k(@+{xYQ?3m{~x63q*&C3IC}1sXeOpdfeY z8O{`glH$GdgOKQccS;fQk@)J%bx1*0xum{BqMB41PY{!+^A6oU^y9Puo^}M@4N+sS z-gW0M2uRjiQuHik(Tq@BWs>7nLJBK*p~5_UO88_X*ctE?DX_P^AsK4}^0bBhP1vjt`Oy=uv3xI~Tu2HRY>|`b}_wVte1P~VakJQx? zh5PyZ`1M_P8QNpar54^Be$F|W2&P@@2kNN&P)3FD{ufW=aC=08PFq9-ZPH31D;24W zlZqXJfESQBPF(kPuBKuH-O1g@bC}Ow532>{8WyLOvF-`i%|R-i$p4C3!Bv3u>VR$m zJOGFoK(S>)2`#xwu*C@6In(CuVZomQD8ffMs4?*n)689cuL%F~^WKr#Ujp`<&XdrP1TX!FpAkge5A$XEFf0 z&wGCS?Q`S?!^UZD23{`nT%tK9LZ~NJv1cTk7!5EFFsG@&2|Ef<2vjiB=xxVS% z-eQ;`PZmxv8*RU(U)~gqd#uIyR_`()eaw{cHK+!y_d7qhpSdKmfE9?%*7%0CiZy5E zJFSTP7^OolX`I7=^x4K9`a$;XbYia3zC_H#TT2y3rE>6ARWn^!T(MFye;72z;sR?W zDyB`ao$gbq;j1F24kJR-cOca)O6sPw0Z6ZL*O;`9mLMK6Bg+ql4yoD!o<9Lc=ftuq zJxsg@h(o)RTo|PQ9V_$@<<=CZTEZoE2D4o8x-6JK!oON!7}F6B`t)>)F1IPs^F&t9 zoW6Cj01;kqE8b7Jy)9H@*5+)Ef=oE~5BmGptD-j*F5_e{X094@7BLH5eipEIMgQdO zt@(MWj>aLfeIw^IVJb(-_OlMm90`|FYG;bq&AoT!wC$$cu`uHwbLr3+6vO!$Tbo{f zl~Lpo340xx=XwEc7Se>WdY_3{zdY{x-EaV-K@ouiNC5E_00$6I03Pqh$NPR0s9s=4 zL@e9dAsedlB>phJ2+9fyv8n?gVl&bg{_bLwYZ-vKJEC6Bo zVLReHvPbesN|=HNLBGRcOsjOsm3j!3gc-cyQzUjGX-|f5T~v_8^!@iF11)C*(^n2J z#n5{I7W3u1?DfdS)jp7b6RW{nG z00a;)03N@Fdq=kO=6JGHU$+1M%URJrU;qC(KmYkne%Js1QjS!Au;*Ld`}$qmJ+=N5 zS`6!1LGrvQo^*H9+AdDadh37x_AUNXq`rlZ_<0r{mSUxy*DfC0{YgO9pl%D>Hf-0z zjjC|?s$tICLVZ}x*Tqcq%5_l==xB`UK%ZOW?*NSJ3G`n-SWl}Cu~SP5omW0cnoZXG zms08RIA&EPm=;2!s9#+TxcY&uK-QpZP&KF;)D3C|wF9sVfIvBnW8{jNocgfUCfjBt zMp@eUYY7o-y;g)OPoQZ}^?@|N%_F?722`1?2@6*MQ9`U-X)l zU^?P6sc$E$BRX}9te*xdOC_Gg)k_vIsY=FNYN9a!l_?SgL)8(Ia#t#b%gq?glAY! zs>nulg!(D~!L#yqnI=Kl3!TzL!eD12Nk{F~N}xhLnNNGj#cZonmX^iQ&^k?z0wWth z>ztrh4rf*__u9wX&MP!#O6;%5x(|9M?zSw^^uoyXse4Q7h`jUOyX&P+1XlZB3=TlU zm#@Ew6ORwabiS`;neR~_F5Z9K#UU53>jW4kHdNrhGUElbhm+y*4k7QApdG=>n40~DF88SG3mtY zYf!Z8pX2OOw3p)n%)-+|p0K6Be#RK&$-xv{VI(R~+F|pA<0uPq8Z3BuO7*1-D_qtI^fh)FWUZG)P|V(QO?cqW$30^|7+0$8 zunZ0fyqww59ORSU6oPAo8wHk+PolEr`x$IZ1P=|8!g+w$myOVr*0gS3w`xe3<~3J) zndcu=*vT~4&AAF-=l#o7EKr`PEnq&ExZ+BtC&r31Uj#vrB3>902h`!u>dqDH8(jOw ztUTiL^Zq>YT`>mpQjoEP@f%6nuj(RN<(80-t&hvnTLX|J?$rJcDc>oNb65X;Z^^%K z#HXwyK|Ip2BQtp2u7wC*XYaLRPFVbJXlR=a%TS+W3wcE~{uc}w=ldhMFaQ$@R@4x6 zMhUlmfdw4RPMI1zIW z#~yX9HmMvyY*c?p}vPsYl_Ol~5VemKtl zW(6CsC4rfDeJ=;PsAR?TS$KzGMk*Da&3bBly;sd3oD3_Mv`C4HV}20<#c#{MCraGX zY4LfIcFht4vC0G?Ht}`)<=8p_=g!6!>rQWH4@mIgwb24S7)8F^wFZ7kpm_v|f=21T zY;N*^xaJHRVa`5cO)u6Kbk&HL!=LRqk)tizE-RvkrAVjV<((IjIgg+$2e1{z-Ery2 zWKX)NCwC|1b8l^RHt@eF^cWz&MfK&sY_5&+4Oc~*uX%dP^sT7#gh;k1r{E%94n@an zEHZ4tyyIS&h#)Fg{?Y1T;!R3|9R$$fgTHQG4^U)@+3Y9k(|>XD3yNm%emu??wz6Lf zs6)fcO@O#GlPd0bTZxj6=5iy9vR#j!Mxpu#siFh|W~u#oQp8PmSD&{4#s*byXY3MDUo#0<}g z(1hk#Cvl8u5e|KC<%{BZh$>Lf(*W;{0(rR{Bkyyz6U0L!YIizOMGSkjC}?SbcgBG{ z+>T9}2Ujt2ps7}6f`uzJL5SJGy0n}erpr29(J?iggf}iTufSDk(NAq zxvHUGiOL!eFw`Pd(r0-osKl60A_mYLezvR_^A;1JL64zt@R?SSQlH9q&!Pi3sQTABZRVNzQ=^N@R9><98x%4?4Ze zb~lg_JbSjLruj&2kb;JO%Fw@D`^N@(<~Pk;jcF;CtY z_=+xQUcC3sc};c@k_EWx#txx)g_n)KLKj|;8jOJdK%{5^m46$&hMne5 z^maD+2wi$WYBB@;0+FBc8<7epkckP$^ftqv>@QSo%jz8qr}*EX5MAa@J>_+p1lVrv z9l_@3h7*Ei>MDH;H*Y_tT3%l9k5a`#N&)sa#jqdz65nYko-BGS_NZp-_%c(;dGYSb z7Sy0a%F(%|UoL10Pek%mhlf9oK~fC^db8IHD6}cgxU7dK3Q=lR7E`v#_rie0qNs&)WMUbRApv)9T8v;J#5Nim(9d{6k9)jB9eOCg?m^tC-VD38Vc31;$ErA>2r%>%D zs8EISD>bu_7JguM4xh}g#q2MvCeu6<8lwQe?KZlxO+Fj5(O~E)r}t*p;KH~fYI>$qur9z ze&lvuS!kB@#S^W0I;*>!5pt7C;J%1sfwJFl1#c<9G>gbKV@(7-G&v$2SAy)6#BPPd!Z|YB4*4zWQ2sQ3ue4d2@a;j))gAV++a(Oz&fEE%qz3i0> zFbWDXk!Ek;RKA+jL`QH!{ncZ#lj2g!S1u~Zk{l2X{Zj6^&8M`qYM^Z1AQN@fboCWlt!8I}+1PGB&sYme;eEP4+|@(e_j zL`+iZNUg>_d3dUKP@Es0G`PR{WYBknstl>GgZ52MLhh72rZRw#*63sKRAaNQwQir! zq@0&dtZ;-Lx~>Ek$i;#o(o*t#I3VqYjfmC93nDwB~Ue9 zp`uAW*%1rN9Xgve2kSJiM74-B=E{~lcK9OWJ}La!P_{U!$j#TonPC4IMCj5A;KiVZ z|3PSVQMIp!vRqW_NJ88Bmfs>z?mjVT8hg8g#Sy17xNuO5Y#!BZ$TQ3cO{O;2cY3D$eKp z6VDw{yDo4D0y<*BUZS$sm6pmTqI8Xx&HLKl9@dM=i%}bI0M!MScC|Of3Q#1uA0TV! zwY1*2XYMp`F9ffE!yEQ)&O9~}GPlQ4x8v44rlxxAM4fCH+TLCmL6nxtNPsZa-76$T z1xvuWp8=uF-h`~Dl}YU$Ht&+S@IcG(ov8PM>5$UG9aly< z0jGt-ss(FGj5IZ_U%hAM_j~_&AXPG|GSha{mjw-s3So6fG8J?|8TU@!@)>w7%OKs^ zKCL8}RNR6^Xvh3D1NJwQsR`wS+!Rz;?`cn8AW5v+B?=9q;-yb!6*-e%Sp zcz7BG8?4Q&=lpuVcJyVcLWG)S+B+-Y>dlNIV;?oToeJQ;!5P^dHPN@p*}(1G9sytN z&rl$%o;^O>M9>iysDKo)jrHrTLdYtSn_rzhBxWqPuVQ32%XsTOa?RnLd2EL~dS%Hq zu7o3kB%taGt{LTXU{zz02!J`qK7HQE#z4BUvGC#Nw)M45KA9QkWYcR)P}VEo!p0DS zrQ(;)LAN3L8ljyrHqT72s8%9&#djU}qHJ{O@3H4q^0;6pZQA0~7riU&(pPX_Z@^uX zX@IJSbUpa-g+jn|Agb69aglC`|K1Kd9+SMW)p;+QE$1)c?MVFXOXW+#74*B~E8msS zuuxO^8-nTAM+)tAgac!V;5r(k{S7)7$ z261iLN#ax9Z12 zxqLijWzlhmVT=bI)ho9jUds*2ZZF+XjK@Jq5Z#bz{FNQjC~qf~NB&rOR0DH#a-4UW zJW$oj(w*xEjl&h&E7DQx@R_l+SNlaNx_~5gK#L_;UmIJ*prvi`#~T$iYvGTv&tEQ0hQ8~lP{?(>A76i5HrILF zIB+Qb<_qAVG%_sPk9G^KQeK@VYOawKCP@$loLVJd+60cte=Dd?)V1g>Amrm(k$TH) z>2io*s7pcjadBu*uiwQqSKPaLEY%HZ=KYtGlpU|8c0im~-q&-FU{h|OY+X<@wk11l z{N={}cX>!9`Eq(wvw|WX!STkB9X7AJd8EYKXOEk}d)>^i;zwfJi>n?8xNu%bipi}91 zALmU97AP;WdRpHKvZsh`Id;v=cQ3iMS%WurA6laMki=48EyZdxV)wQgidMRA4k6wr z(!*49tvoo^eKc3PX>+<--9lSu0W4Fi5%LN0RTqnlL)HdF+U_UizYCwegzIc2@)6sm za48$eIS0{NRI4dBApS2HqzH4IpZ5^mLU6Q-4*3jr&6I^$p^fW$Y8DvqCQMHEL0!+L zap%A>Scc2J=vRzVns;PPoT(fItrr1oD4AX7@GFs7bN zZ|tB>uvX%oRk#S~zB>GwY{8WTmHv90j@zh z9`Fet(^yV94S@8U>W->9V5vP|L0SyVfGBcZBu(M*1WIYhh-jSCiqegNSk@?Na`6Ye zmBBQiLwr(m@d_W%Ssv296S9loqv&icjxTid1gXHe*eo zQgKAW%$Wh6zETYw3?@`e49}%dg|2gViED4WX;bZGbF?fLiYPUvr~iewh6XYuPs?Ol z-aX=MG5B~O(|=T4E?MJ`xC9Jt^E~`Pc)b#e`~{HLNEH4uVeoj)&N;$d0a9-QdFT_c zA8uP9ueuM9-DeR{CS+H^MzSfaWA}MbCKcx^Dsj_sMe?8{EiSxq`t2;=db$2GFf*;} zuK$L6uNm#iS-Nn~98w}bmtON|oEV~dvb1uR?sx_#<7;I6qlq#cPu2|;-yyMHqzr-c zb~medm-`ddhSw&S3weL6rjc~5ufnoLyA0PkDW*VJTzPzjmhnprNsl}?Ee`>5GZZ34 z)tBVCE*f0fz={?UODKV+0KI~wAV^@~xN!i-y}Z$1c$0sVrq$Y0Td?_Fv7W^* zMU~Xbzx@8F-SYHw7Fa-{(4jFkvu;s+$r4hasK@GGjc;T@BILCpAr9|R?Tg?aAWI1AfY3qbr0Iwrb>~!5|}SM zq+L5JFg-S)X2QIuHhapIkOvqQ8>VCVK)?Z(JrbWojJQlPZ;(r9QC|bmB+&FsKMbs! z;Ohzcjy+<06|^Q*2Ucm~>Bhmll0OfmuSt{CScJ5xnx*^_*OO!1-O#?nXQ~w3<#`?3 zBGckZd&5BmA{TLVcF7>@i@1Y!@QniaK-+|3&;+(?)3rz851s+{v|(79Bq zKN0Ne;>=3Ha4mTwo(stsW&aij;veo$XfK zfUv+za=E$lWm9Ob(Ui3$bBD1rK*d=FEPSC+?#9Va$f>n%JV_ z-mE}R8__=K|1VNv*+LVfGd)}X^y&rwI~j9=WqrON+lT*?q%QPtD;@NT&d>rtkLaw1 z`^?w6c{X) zOsg=cX^0PACe(mmM?7&Mw9S;;`({zjz6c~c0^vG%@IaU+F`V~`<4dZdGJhQC(iS>* zsU*;RfbXtEvMNSRdtkLOI35E{Ay1&#Iq)BMaS_l4Vyn#ycuwEosWmx&8@X0N*1JJb|j91Jn>I1&cQ4JdpDH_K;8Y(d@H&9C_jq0wdN8g>|S9hO~<<`)?yI!0FGaFCZ-&WZ-tYEzLt`wj^V4? zrE!@|T#R%;R~AE?bd__dkG;?sCBw*qFMyc{J_fHeF$Amg_R&kRiGM}l0lFk@@W4^L zY|a792H;aX_!_(l!3-oIbyZX47$P7px=ej}!H1pVPW^ec=RkfI_uRfazD`J<# z>e9jk>*;vko9@1Gdk{2gX96}6pMsq(UJrt26*w;d`lbk#tz2K-owJ4GXzu4sRaP{X zw?LZ+?=@>7{(wk*h^j!!nmI2#N7nXA{*hT%hiFFVBWlhz2;!5CFGhP(gXXBl&}(#C zv~|;s@y&A;Y;*wE6sW{gbeQ-6NGymu3=x=^8^-{N`ao(C-I4)8W!xi#RDYk$XJ&dP zb|X9;*&q}aUBWmK=^5-ZW{60fqh~OEAT_bh`~FzGw~MO5hmr6t%%n@5i`u@!|+t?hTD{emKIP z^rM>7ZbpaEGhet(JT)D-x;SQ|CDUyeq03{7?|yCsZ|A2<_jR`p>|pRNr5`a$eRT}C zQDCs~pD-$YIhE!CD(rO4>52-iqi2CG8h6vS9BET{_3BFzE7w)5V zwwc$DY<3Vx%2faW7Jz)O;K=x^n#a zxZ^5kUICU-Mp^jql5>Fxe>$-jjaboPG}BX@aI2$-D*U_mjTloa9u5~I)VYNzziy24 z8AUCm2ii@4>dWw5I$Binh^DX{@9$duJa>3aI5@I;8Y??#QrQa*|6mJkItifEfI_^; zDIb<;$gPtiQ9{Us7mIN;+Jm5uGj?))jAJpNJllx5s?lNBZsQg;0$DLw?OPVMBqz{W zfA)MjPS*L-AecQ zD6(AZYjiE1to)oOJ^IKzG|gxD7n{C7r=5YbsLkn3^mYD1ALqGu zqHBB$$nLmc+_h|fMgs}WPQzp8F_{22IJ5DU0TwD#TI2YGXncdjR-6n@(4oMjFJM9aB|((fkpXBqP?+AiA16s$yG00 zT~VyF{|nl6*h^skQz;(vsT-q*Csizlx~@GraCBkc@8T>LMPLi#Q^*8MB-$6^my@cO zN5*H)kiL-qLGQdli0cw`a^HCmAb)3;lQWp`wM=C}<3FY-g0wZ&zrMqUg^An%ta1oY z&$4Wjyp=hON2EWJR>Fao3H@%rjX7gA06%)s)UA{i6^At?wJ1~elp@npRS*1WY0aU* zh?)+k8q1`KQj%$x?+MhWfl~`r$Vy(G5nomRYCP^0Hjy(=q5JZ3Mg!uTP7jbiA+XQ+ zWKt)e|2RQfWH=(K<4XOfoJU=n4!Q>&Pz2K3eD(Kg<=bF;Ov5jZLnst-K?6PfDn3+^ zbC6_oQrXaiaP7glInyAN@2#+}i&vWi(YK(JT9#SG?tMYX1RJ;;jnzb` zaR7)ZcXYE^3im`_q`sZUQ`o>>#78syr|+8Z(}SKvyT0}Y^&RHAlz7>;D+y);ghui$ z%|K=safNXdaIKsX|1`K&gf10_Qw21?MJYQfrhE*i1PILEk@7@=r$#S^5$q`j>2PJ) zQUNzE32bzr@vfumOJvYm+#wcZL<^{x_)~v3+;+oChQ&Rg0 z#7^y+M5rt1wM*TShFF*nL#89_tdZrZk)e3}wIh}_YcF4s)k4;*Jqr-NA~ceh+rVU! z99-Kv?P&ABXJWca5+ZEqUXLvSzc~Xd>y}vI;E|dnDh|=h+l1}qG@0oog^9X1!56!l z3)$|p_b0IEAx)Sz4XCqt?88Yb2V=-hy*(t8{33TZiBJJW*et{9xZTejBK5RWxeO|s zI*}#W-ctlJ0DFLOIw%Y3C949dQ}y$uo867qT0M5abXdC-XW%{SIWq4h%2{%M)lT4`-5TK1ZrN zOHD=-LmeuH!-u?NTl&N51`eiOtM+fzc_V?lO~w!kG8WJgwK|LEyW@Ck*`ByE-i!!k zvkwOIJOcxS%sv^Vqb`h;q_~{j62dm4FkKF1gA7zvM(vfH(^S#D2~W}3pA_L({k`jB z5#aKX(b5GigDCarm>k+s*_^%p=CVcPMGO0jO&>a<4qh z?AH)xl<@PwkBl3>cn>;`SbX59Zw_1DR|^b+P)Ouccpv3q;m=m@?_Sd4%g9zX#f#(@UIxS~esj^LG7-o5cQ z=imYGg=G|n3$bo<1Bd|)3l5mfe5BM;0j3koEe9)B6oO*k|DIma7m-F(Tr)tBe7x@* zgXR*jy|fO7ae={WZT!Q~*^hvt_+k^7NKnM1;zS5tm)T+)H>zW11bJK~r+s%y(?M%1 zGM;n`cbRLhmYCF6dC;mtjkSkeyf5@W#zr4tuxVg{dib*}*)gDy+|=Bc2s?gX*&(Ra z$vm#pa@7~-3QWaoPSHq+YbqC_mL49QbXFMZ(|A*^^>OyMC`vdvW|n=^tn{UE5cJJgHuU6ida|H0rm3;sGRq^b>+fK zm`9MSzkpAIqbUk+YYy)-@Mwd$<8hb6Lmq>UoUZXbRP7nWX6GAUKCN&Knea`Yt{=|-AR;WFb2 z4OPfq786x$`%ztVu0S1`oS>oWmic%JwYtW;9DL_jm!3L-&G#C4lG*5Y=9n0_et-^= zR24ephVXj zqHX+QCQGUld)Qi<3fi$~9q(*5Cl|3^93#2ASYtYgSJYQ$=px^Uypti4D1X?Q#`zrc z=Ybsd{844RE40VZ;OXBDu}2X87DsGeYkG|qO}NHkK5N9 zio!TMO@v-~w1$6V>-(l1+xdeNmm!%(W^Yb_-C3E_6$&vA6Dd>~QG|2BP;Fk6YUAyw zmRWOxZUcT8o&~3+M%NkQp-iF!WgG@X!j-cL%uO-9?)i-1Q-kab9qZmO`s_od69a?4 zRh#=%?^km6bG8CHB?%Is%OhNUW5-1 zd=8f~A!foGsi{;tX5`_g;{B;%VJ!iZUFC#qD@`MwjnTi4N?sdj_3D8>PU+q5U6`RF z-XO96vWJSRkazkb)Y344QHN@B8f?O{H{_ja#4^S!StwJ4xcKtUCQ%+71g%&&rV-#d z0!Edt5aH>r;!uAL<0`teT5p&n|MF`ut5lwU`=hfiCtv``nTTk;73 zn(BwuaS>;m(}_87gF-w9kyG_cMkF5;fxtCGfWBycWTIh`RkQq)GwNy1qh`6x54|Vm zjG<&6{3eXH4P|b{otD|iH_N(F5*mnwZbMbI9Rs~7P@ln1!5PqA7yX5`%H3(Wjw}P zstuG6Tsg4J>~Trz&mUmSkaxPEa{H_E8*?^z#2*!bbEROlI{iURdk}S6&AIBNQ#{pQ zSV+(~Wv!ItRYc`hJ$Hxq>bRZd5a9Fii{T;T)oG=|f?*2|rgBHS(ua1mY(_~~rf`tD zmZl6^Swx}dRn!lO*WqcYw*BxnmkS<7)%y@BXh^QUEX56CAREFA!LNzK|CLA?QV)s!g426WBbY^RrsC<<~tPM#dC!_GYg0Q1nsbr?!V{mJ@CaWi4?OX%z) z7@!gc-#<$R1{=BxoWgeSCeEWq$_+O1mIWZvZv!ZgK#e1U0{f935>fA&eQ%s_w+N<9 z3VMK(S;9_*z~Z>Nh}v$I<(*E!hHyeSe=B+XtuLmIMt3lYOBD60%F7umIKG6+n2+9n z@8t$zh<|FF9IZY!PTwmXUPrV-n`_rk+i?s%TYO-kSca#MFVL`trPDT{MkMC4n~(=3 zeeOikvp`23a5ytYsDu<~C5m)sS!rjJlAar_M9#6#Zb(oLY-99gfVwfszq3zCBe?U| zv;euC@O0X9%Wmpk_c3?@N6k2Cf4^tU0?TvorTzV*#iMon*r^CBXq;J*$Yxl@;^U1C zZg0hOY6k(?ztF6!SvRLnldoI)ClT)+D_`kmW3{9bwrPR8Bw{zSC8(TixuBpialz>k zhYoKyxL!-4nzX4?dQ!RVAQ|BTHZJI41H^+S_6q=^TyYZ!@!AQ1bT|9>JqE2d4>Hs# z4d{s)Cy31OC+z14EtRa4xzBQKer&3d=%p(G0(vns18J{&T2SKN{$h#PVd@PEVuBa1 zcG6f^!pFWbvO;L23c5pmptdH!0G%X@YU`yOiCM*enU%T(@2nH&djBbfrKTW9WQcqj zdwxQH?=IRc`~wa#hnLN%VUw%WEp@%^NH;Q|*VC69#KLbeTm!f{I}n$h+&h0&d_yCR z%iM?9ZDe$kp~vVlRUP6hO*7mvsBJ%e3}@gMhj-5ORs`e+sf=IJ(rdG*g~v@?YjR4C z%}4I_`TVOYs7|ss+>G5p()SAEUX(I}TQP%6FAgS$G9Bn!x=qx(sgs|B_Q8#*DfN7r zI;~l;B*z3yVZcYn**p|a`({j3z4=qc21CC=ZpiW&67aU#2Y%!*B_hQcCYs3Y_5v-C ztHhlQ`aa?b3JLkw0d`eyU7gHSx8li0uamf`JM&@DMlWT5^^*tB>BQ^JQer)61>OG>bx>WfoH$Y~sbAhCXB(xyOF z#kD?m7%}N1u19zh-E0Hi>h&UDK76M)lr_<~%fNue)5y|xmH8z_<3;vh(JceVj(>(5 zQNzhB{-$BbbubMUxxD!Lb*#sW%s#X(!eWb?ZRK}a4E#CnqM3;k(dVf#kjRtzQ8qYWz1NZd5&h%32@6&-_CVkOqR8t|OgnREN4g$J@mNFHKj5uV9oU)a?;}SC zXFbiTF3a&fJYSe{K9HJtfo%#0KD%q@u}E=!6jjrA1CCTUcshS=dO|<|-nvGWY=>0^ zY-ANueQV5u$#^UKZ=YHlsxwarf`L`)h>cd*4vNDt@W<=b((6Y$eaAlrqmFw;eY=^z z7fjBCj@Q6l4km7J!MRD4uh}e*cJ)#Z@@*{gB|Na`^E>yYx9Gqz_lP=(IG!~YAk#a? zT`L@ctHpE-drhQp>&~jbH+m$CkBwQ~5PrDNdWs7%9;-3Q0g>mf3YLxuIHSwG=p5!C z;lT}!p<|bqKHzt`|0QZeFG%KsXjP)+t1gwzw5BgT9mc|#1;fRE6m|k9Il|FI=Uptv zJ8HoyR@H}b*vF^)iBKfvKBiO;-v+dkAS`NKy!^aWuP6)AQXHY~@Di&Am=W!XJBtwi z>Rx;amw#S~BkidL+NfEQx*3El?s#uZPn(9NBu^j$wuB1@!SrEdwhgQ;G(?yXK<%hD zC?L_d5BGh6ei+7`ASKi)k6S@)9b?@}NuzGMHY1()@o-H54NFi&rcgb5U6o}wks{Mk%DbbdjWKD| zk~gqU+~c#^S&gYBG~LCO?Y-!A&WB7ezz#^zZ%Cw^8&3YM^!J(`-}fvy6i|Y_1#wyi zY%=aQ-6NY;D!)YMUn|pBpmMZPTbIRm>maQ7*uGFgGca%j;vXwcq*ke0 zXYct1(V2qfC`T#!aM7&>-Z71aZlE+|jpbK?oFsSJ5!3KOKq+OUh}uz@&2oQj*Thxy z>PZD+wSjvLITL7;qceixB@|@EpewTd?)zCn()-vViqyi$A%}7gU3k_OjJI2?=66tT zqRyR3E~kH?);=R|s*ej=7Xm5tiX^#nM|^Hy>s}{I_%~cPTOy5ulo0_Blu_RFL`*ND zxPlRE=i$d$p#W9H)_U$sQQ^H*s}a>|0{#is?o3kitYx!gVZTa_ zA}Jrt=Po81Z4U6GQCfgMUM*v_d6R*W_;02Cw4VGfNWV;3mH(lW7(DW$e0ft%h^7Gp z%l?tQQ~qU20=KP*zVB)!AHEnJJv;Zb5~BOgk3eor!;R^~+dxLd+^1iN%smn<{8_S; zH4@AtWd>J=i+>%-jG864nIt(OBghwi?p2xxi#j&XEX&`s4S?4SW8KeK>d7ctEOC)y zg9?Xz3}32Qw5HT%P}s_dRXVDei0o<*Rt>|s^Y!MnKk;ieKx5vEvF;|(CLs4)BN-N1 zD>Yy~uSy#2$YHJR93W!IgKn<8-jl<*a;%z*8(L!`zpDncVO}7NJZL%C56=M{rGBW6 zK;k+$^RGt3^K?)1>1`TXIWkQz=$-=5dPmgRyqi!uvvIQi>Tl{tEP~pvVU%~Wm*ecB z#3}Sz3w>>VP?P+5|4wMgP}*2qlL%3avlzl|9A^9hGrwHG(yMRmU1YieYso+84nB3% zAVR}iy1=?5ktXG}Nek{`owIhoG4hF;L@CM_eEhNKzE9OfkwazDxkb~$9((alfdcFp z^^}vCUc@QO<$~%SWinfo8Y4=W!-vin2#vvyw!Z9X%V(QA$hH%h&-Fv*kTed;G z@ss#u*P%yufcQ6T{h>I#n>>}WS3H%p2p16A z#b_?WL&Y(07=o5fi+dI2fP8dGuACy7bS5(di8ayF&Weav1wBgDDpQ>krPa8isPm3p z#Dx+0_(lG7M6aBUiq9-5lGk9Un~z0{ue_|T&5{?UcH}4rZJ7vY`Zj_Sz}@o@KBIv; zm%m3)UJrQ4>gM~(c2rt$1XI%OlH}wf0lg@)Q!d(GvvU(4Cs+A#l6iFWYz}{L?cG8a z73y*j*KFtnVD=-d#(jZdXypTx=7UT?bDwSI}V&|ODmhZ3n2Tc)$9v=E6W9OzrD_HB;gH6PnNx`_bigOA`jn5t?b z@0c1yDV@VzY5L*vjT7;de%_C{9Dc3pONF68v!PdYvjzppOOlsQsyu<2+L3tEGMcPY=mG zbIv=3zNC-T!0;>>*!iiZdfE#8DKad3 z(XGuaPy{nK1+cmfjk9WUyhaH)z8XvUn&rY$aavh^1q4T~ z&8$Qwb@M%;?3~ai;W87CyJUnuK8ZOA+TEGe^GmQ~#U#tpFZ`Y;t6DVPhsA`W4XS2srm1PLdRvbyuIYHr6XM$$iX$*f@$7kp?hkh9>W!m0_x3J#D(2f50H z5{Q1xxKzZ9O5oa2o!${Cs6k|9UYQ#EDA2m^Nbj*q&454y&0j?PM4dC4G}yIE0;xHN zOr6AJGVMI5$D-GV^#he$_MB@8RN;vD%ZC)eK86~?JWJ9Mh?_CLEFzvW;8a_wp`#U^ zbrK^oo|ZK$BGh;S@zE)UOcRWo#syj3{MX$-gJ4D$M<5zTv3O@F1XBtjmU&U-dTnlT zBkuP7dl(31!+}$FrtFURx8<}@c>C6h*6~c_F9QN#H7KY-&ceG~`N^>eTJKuHvSFX{ z+2-6B{1CHHiXBCiko)f$ZZ7Y++y}-W6O3zMVRhzydWF+3Y`}yM*Rp(8Xw`-ECwJpuoyb^6uBag zi283$IJ-LMIVv?jMoY;opKnJXOii?84Ik{=W4-({+l$>k5W2qf^KG!nX~E0wR(kfV=ZnmA=ZDd;@Q+IR{hzRIzYocIMPdb^ppG)B2u< z9*W)geTacoVO~p_Hdu$*DV}@O``~?n_1U{dkPaE`Q0VdE$(Aoc32SJLL~CLCQPX1Or-Na+?+5+pEFCph*jWb} zH8<;xF>DpSINOH})GthVwZqx#m|(_Xf#e$zIF)AA`Tb(Q79Rvc26MyyLNRt6w=;=^ ze(>GF?2O|H50Tp((7e|ER&L_#SAPD=Edr>wV9_WsoI5SsNRY0dQz|Y-?upzp zcs8Vi;Oh6o<(e)%4W};~WJxrIPPaJC1id8heD>XJ1{02uIyOG#l0O*)UZ1v#|HArBUF2!OlqCoS+)(xB{^MxF zI8*PpS#+xgft6=v*+@mONo1RsV}23j?VNaZ>%I>J&-SYW0uwW9ExL|;dEx!$xcaWj zCSlCBe(2sW(J=fA84BnpRgd5I-kr^yzd_*NZegQse$Y-~fLnW6PJ{aBJ*Cr>@&IO< z_z+}pSD{2Os;qVbAjE6QRxJ?9a7_vB)mmo)Ml4ljwVR=|qw>jkn)woF_dqDq0!a(k zoDW{vCy(PW2{??NDmkP$NG_6sFZ~;SFd`t=$S`6hu&nb(3Om zddyqx0VXBYtVPwYLkzDjylC=a2UEV6l2NidsRdut9md30GT;Ms3`!WYA1zMyGkwOs zMp9p$lpcbLO!@SW2W zkHfJz3lORC?`e99MVv0tCROQ*SBI0ZA`)=ysp3+~mgDysPh!Q-nbTM;9f#8afWke0 zkTk#@)OUBd?!RV($K_f%<27?T7@`rv0<7Bc$GKVvoK5(pun)+D(QnC#0#lshnsicq zY+@wPts|K@CW7+!ex5B10Gr$Daz-#(sVizpKwHPGUcmeJk)}Y3HQ<>S3Re1q^Z}UQ zzuzGUz!ZT}I43Y%icie|Qo4ERTOKLELIzPiYe39z#=IaH2d#!l!3cZHDwWUXN+REo z?-(8^W`vBk`DoB3q!wdJC>;s-Q|xMZJ5`kf85D1`RqDoo#QC$vCCJb}J5%bg0^KCT zk#!^aJw`E~5a8|0LXk)Jh{0w0j=#ZY8@dlq)eI%IJ1woKK;gl7+-Io3+18-ZNe9H% z=|3zJ#AJ;KC=}K32RQ{WkpLhOX#w~brf6mhhX(g+_aCDr96KMT7#GPq6t8jkaOB-S z0paIiC*#vW>N=u=VF;=ndF+enPbQk6VI!&chY+N-H?TTJa^GqS&R1W&?2yJI%0#b-_ROu=pcn$Sy?%r20%2m203E0 zbMKjtC<=liGI80|;!TaMZnu(w;N&(pYD$l$x>t+?Ehhu#=3e=#7@u&PcDvQ%=vZ*k zxe55+0svsjMa}uQGEzf`kwk6d1Onz7$CJ(_dX!DXHVNN(<}y8x3x{0O!c6}DtY+q~aK#t*&bdrY;j^Md}~TR_PO5gLS{<@Gj;08o;v zdF}gwW86pIE;61H^ipuu=Kn(fLlgc}rJzf1($&F@ZoytnyE*T)srs2h_4A+}7@YO4fNt#`VaAk3d`lBof z%XbD3mvlnVL0FG1s1S2%7Ae!g;n%2-f6SFFw&1o3Cb$U4s2aZdh((gu3K&pF|rovDJ*HW ztOzV@vD|G452K((#J-2$!D$~Hc1T@8P2%Gx#MOHu!xyx|n4G(}o&(;sJ!E7JH7wMp zmTHZVdM_vZ9c0Np^n#YAx#}+Rt=hB7)Ry54G72}>Lt>zV%`k82DNoXAd~>0euCt49 z=4;pir0#*E-DE_sro9_eg{97NB6Y4f*Bgshp=a&LkqCqyMsaw1^vPZ;ip{%~Gm%WD zlB;JYZnvP8&w3kM)6p;l_3Lf`IMIQW@9n`IWV3gDq0)&JJW(5PleEIBmw$!t)Ilwy z2Oyzcn8eG-I)!+V%Cy?*RUly6qkW1Fu2lxrQKT_G8v7#NUlV4r)WgHL{QYr*xMq=v0qF@nyA*6Mu z)T}RQaGRmFlmfChNq3U5h%#l@_lK}Uu5w=M2vWu!(o|Q!n&U;aAa$XzjH>n~yH%(~ z;wRt@B50~Qz6I7Tw;XMR=qcr$wVly8U}ry;@y|{L+OtDxZtAho*D=S(HFz{QoN>{R zBGuDk92fQ8X0T~xvcjt#mHnM3|Tl|%{LrDiZ8!HsX~8HL;w@z_jD3c0REEKev5&+ z*_ndecPj3k^`;;9bh4s46WzM`Qyyfa)EUtHpzH4xDJe9s$iBkUenLFN+AX zyh1vF(8oY#ZjntTr{v;gODx%lbX!$^%Ml$eXd7XU3e|r?n0t1}423kTNf->aV$&l7 zPV(!y4u;O7GY@_T;tHa7lt#G1K@qxH?S<^!Qx*g`PmFcLW~={@ab#}qWY3{f-&)tU zWvvIWAM4iZm(o@YDoH#RpbAo25YdlxkZx1&x8l-r)qtfH-il~ryHG#t9)rwBUFzp|NOR1%T9N6ah?RN6GsgQgQZo`dPZwWiDf!{gCk0$GMr5y% zt#{H}lVjVDvy3w@PZb+66!`n(mOdqTPf|ST8zLvwdpd;}ecllf!VCIrVdBc4t~0jL ztd@4e%zWN1(x))1Q~-2vi*y%%IQdNiuow8?HF4Oz1+@WKa)tK`V7alFeQ}5Y{V$|%Fkr_teCoDE#qI9_dgY0Z%G}lpA>usPCKQL@M15>TwR4IX(M;soj?@>xD+v3Nv z_|W{$j^V^J74(FO{`EYYpM!ipEf^@Cf}B@|gyUprw=D(Bbe?~>#(gj@^}?Se&LOY7 z_{LE1c$i5U=#fy|k%e32#)>`=z9@72ECqrgJX78%b9wL@h>1@O1gpBdeBf>@%aI8E z$r$t(#4QS0=Q7NvS&EoBX_9{ZM&T+5`G$yT(A$7 zth{r!=%5^+w9}D z;mha-@~V1-fLVng`E_|-&RK4F({nP^+kYU1gDu>F6o9A=8l|6Ppt1z=8q$c?N+kYr z4Rq^HXn$Cd$Y;s#72(I(K18@irfB7(dKlBta_Quw+Eq$Z=k@*bZV9_1XPCIO+V=2$ zllEmc%d70@t+>g;z3V{b?->Kj$@ zGdUdRs^O4)r4GcU$Q?B+60D*VrQ~Sl|hy@~|ZhLM2hTZu~&nQ*iXKEKmG4w^<)>V%EC0wdtBK(4Bfz)6!(?#8bC|f~HqfuyqM?v)7JO#JpeJT4)*y zS;D77*R>@#tiFR+mbI#XN6E;KS%@=UyWcL6;lx7e=L!sG%TIj_SxGTzk%IKURu&9) z3ci&gN3Z0mU-m81qxFGpriG8*zXq;R6Au$ z`QQz8K~iv?9c*cz^XAYf!|9zk>Rgm$vbStKf8OJSj2P_=Z!@YT!C4HWZxL|1zFyX7 z3)>ky7yB7Kum-0?>2B!+XEa&VMz!$wgo2=fiSoGjjg2CH?(1k}TXn#~{VjbIK?SE} z0W8BEzj+>?NSVnR$OcLcE~1!|D-Kp7IQ^~OnL@ucMrBgeKyuuJDnt?~VgrOQGx$86 zWlmt?(+Cd#&Gwhi{c_>?3hZdn;cxR|h8Uq}h>h%DO*%<@mRchd`0C-JY4u$t|9fff zPH9Zk`Q0hg{0<`m9{9#yrKnvTjs#zIDvwr=Eki217k1p7f}^|$z(kj*PnUGVnI=DI8C+s=Za zOx~2Xyc=S4M{L&UanY+N8k;(1Db11ZPHqk%G|H2dyO(08rm&%b;6-8}h6NW3?8Chv z$_BH8w&ecUhU3t`txo{~ zt4wXpQbM9Tg z#{3Uu7bT#u%O8{z5D)%+NxMZg1Q;3i&H{)5h(P3E@e=1@AEMxES@cqZ4U`{B1Z933 zEWzWH=wbvFBu|JqX7*WtLXl#BP(wgG;eXxnu3`5-@WS=!qYMg}Ia98vNi4zPUxX2* zrZeWg%6iX&NRQW;vv!xVn2pts^bvVgYuv02gcq-kUDTvn-IH6#-HhwA!!HU~`XHwy zuJL6nIhVXMQ6O>s?$d>~Rz&v8Z4d8YC|X*YkL0o&C;H#URP7Q<<7W zwN1CT!-0O0Virh_EX*T=615Xe1JjrzVe=>pXvm<1F&psva3GyisxQ&s|3K#FYeB7w z!n=WVD~1XqGf-*TmcJs=`*e{+KtI>)_J%StFgln7+q6SWb{gXuHUIS!S;<2ui)Pu9 zs(Qgc&gvF{SX95^U@Y$U75Mc<%JuT@iOJ2$-F&Ba3pfaj;@XKpVdFT<91RGa^Jwxm zxhserogmR-4^E{?0!nk4ov(=J8_v#L&6Mf{6O^qKkH)y?)$7|qvfF{6TV*S=nYy@) zMVuUdh+er<=l!X>h`6JK+oRP5A5qS!W;WRY`s4ZeU*9QGa?7X%$u!i~pH`;mI(2$S zK{~*vV9T~=2%^5#T^1AUca4NwbPrBUC_UJE1{1!g3BO6Yp&%fe!hytKD|i0mG<4PUtUOg#|66xGa27EGdN#i^kc1(8KoEOdI9%3YKDpTRi|Grb`_{VBoCmCA%_=g`sVS}Q2t?drR6%lj9wihhd|Nsv?p>GfrE48C zzilt|IM51U(j5$912~L3SMfPxX5rft4#)bP07jT{BYMdP{Vg zpgpZtUbU`ICu)(D6HHU${AfLRj^_u@Fb$|vck~@#i2wO9eP-4kIU7bqm(PRVY=2YB zV;aR@q%uEql*v&Qep^~SQqpSb%iyd5X~SvWzXbNZNpIp+zMIRZ?<7q|dtukA?6lrV zq9imbE^qXu&<*5f#6z%9_{|?Q41k3H@9z|om|X~euQUY;WR+Zuqx`cG4c#U(UW75X zWMXTBd!R$uu)QpcnC4uv@HkP)51UTMbD3J4%;@2q6ZmFbCmJTTm&ozH!qugD^MbK* zI6&-L*`)i=a&xuyvs9AmN8UU#FDb|9qYUuGnuo|lHT4KaHp`#n~vjSQyRI`Jr<-T@z{b`$dqTBqgQj&7lV7D;^ za4o;@Z}PsrfU$Q{gf_+}s(JnpX@8i93=mT0@5yL-p5$5CUbm2QAM1Ut2y_vBr5liT zL9YRP?A^B>D=Q5A^g{D8(lum(3ztN3JLG`(gV|6El7&)A0E*j@gQo!f%grsNm~U zhC;Rh>$Z2U1}F9kX%R>_g(jCcFO#s|Qbcv{T)H0ZRme6`B zWa?4b@Aa{@r?!^3?%630WyCow8osLO7^TcW$5If}y1BwU6WUOe^(TQvd28KoRWF*y zQKs9oM0Vo@bsjMzE|%ar1xv55<>ncG+0d;!>kj?avd^77k55PT&Gmton-Qvr#U0^y zUK73tRgH#ePXtJws7ZEThY2E8B6uzd5TAQx_+^iIt%C%8MJq)8!M-X?&bKq1xs2OO$Mc>#T@99SIik?-MCZ9auvBf8$Sg-y4UbqA z@ua^rO2tiAsSACrO4$2|^}Y2j@mL_jm?anq`gUWIf&hJjD_^*@w8om;>6M)Q4-y&^ zRKH^f*`P4tYj>c{5V)xxnVu3OZ&NC^^4iZfah=Y4@w0Z8^f2#o$PXAZo!8-ne)gT7;j#G3Za%+ zmmO$GLA;NxW?#+`0ng7&pT>DOQ~=dPHrLWh401sck66AhTkYF|Zh-kOqIMuwl~v>fDTDThACU#W^de0M&N@Wrzb!Zp_&eky6nOxC1+ z{(2XyJU?8$+?K6fc`RkqjEWz4yYbc z-;`Hq;{8EyfC5#yz^rK9o^y>$fL#s1la(+MA~57II`IIJrYa=;?$m`u7O;LXfgstP zY-7k9E-GJmGlNx`V3;p)mWaA_OBCycrof0+0*#-RDD>R&qa{&{c&YFm;BN~XZI63x zE{?{}5Vyljt0FEWatBg_{HFfSA9VE}O#T0o+}GoUGMtWzxhs!q<{2u&)H!z#gso5p z*Km@&GRQ{XyMo{mzNIdGLWLxtN-)f-WO~r8M_2D$&IPBQ%lZ;kra+$V-JaES{Ei(E zK|nfMg3DfJ+L9o07NcRQ=0AaubVKzV1M=8)LK+|>(HphuqrBw*akes*3hldIg&qp; zaFc$JHc!&5F&j-Z3nygjhe7KYA09wDE)q!E#sQet#New+73a&$MOd~~81cBvX%&>) zdL2PiafvAA$dsTY);2KLBGbs?=PfNT(a)XV_|Wjt6e;X*b~ms7Ri#oKHSe+~&Yn&I z^x#y*BskYN_GWkEZ-oak)*3QFCh}&om(0%bEjg)=|JQi zB#^Qp*lkDv5-g0TbGYV&xZ`UuK;kOr63y9*B!W_SwaJ&ztL84kL+p>MeAr!9Fi~3R zj%NXSLtX;ck}`v50V1g7;P#WkSVt~-9)%+TdsBVkg5>l83R><#0!<(9q2Irl2Lt?r zng3r>`ulE9a`x6qwYh&Y4_=-0f0$=g+hdUJM`laz64s(v#sJE^%nPjhIzL4s7-6Fu zrR~_oIG6Rnu09U?X8sN~<}M(PYan+gN`K{@%Tz5M9v9jo{3#OuojhQFt3Hj21gZap zjBTALobuwJh3cF6k~lZgDo%U1IQ0ZrBi)lZjSRH}L@}`TQC#rHKguMUaWB8>{8pJe za3p_bT!{azK~iR@i#(hfq6j#Urcr1{{Si@|SIXW6sU{rqrE`Mk)*%9{bDS1|u%M*R zo|pX?mN=I}rL9|-Iq5d_qdM^@JACDk^6k?F?`$-D?I(5s)&BOM?sM-nCqlHgiH-2_ z=g_F)$$cZXUuM#9sVrNAfd^`NAmw3~lq8qnm%KNG7GkWXu~{NS!2e?2@X!m(4eSxK zDVf_>w*Q;?H`U)^cRBF)=B<08`*#(D30!55cAj-$L6%7RPYQn|R**1XnExB|L8_(< z0P6gI>pU3cKbZOd==?uZ=N13K%>PH{|Cu`P{tssUKRW-<)cL%BF!TS>`G2O)kNtz0 z|Buf9^K`z~AM6%z_pc&!-+vXMbMuG+prDM6EzP4ED;^si2bC9HS6dECPus57ZRc+{ zJZ`!!-fr2QHQYU&(}LeY@T)&KCP3-`)VKbX;-AI*Fz^WJ5P^5sc4@)kxE z?_@#y1?Om1w!`2M?SMOFDeAzl*8$ZfH96;0v{qd&I#RQaaztbJc~mC$^HmwSm4r+t zmR8LcJ7cNZ#^dqn+E~E^=m^}E-R##26v4O~7~j#G_iJdq1(M+6vm8nR(EA8N+R_E( zB&Pske8ZtH#6-=){XW5VKG>-Ast<(Bd7i-_o#vm*-VE>GgatGPY3G^-#J^9=TBf7h zyuVT|ZXi~8WH(TV_|_$&BwBK20ki5JZ-oNyRI3>!CL&ezX7R4=xC}`)}o~ zlhgkf`<4yoGum)7c?X(!TIxm3=*a430PG=%Qeze#VRkR z-!1jJ{+g#>>MhHPbd#?82T176)kfgydXoBY7VLj`88iMw`6q~q8T2?Y4!?p+T4CUn ze>&yqZP5Ounwtl4GhNg=Q_JyM{ZV@`pe)(-x#VH$MCr8@*ynH?iCj9WddoQQ=iO{c zoqczHX8+WbyWG^&IFj!6e2*}I-S`*Wshs;TsvYRl!7`nXIcj0V=#vfr4yVZooX}w) z2#99LxrJSV@pnog;J<tO58eh)A_M?^w-I4NGN6IUl89&!G9Mk>vpMFIdV-FX zJP9lt7fjbXQktPWuIY6C)YOoa#b84T67!Zq^_sV21QDyW`!y^%RK@Qt0nqy^FyXZV^^Hijza2MJ8Brpq zzmcr0W{vlf7!Va?M?zEqdUsq8A`3E-|NX}EKdb(=rI(|3jPLJDH`kcHmt{_}g(om` zhI`<>_e8x)D{`Puj6!g221=!>34EGg4pb})W7F+Vz7}>z?Y21&A%KIHR(I4`Yps|M zb8r}joU&Rnk*^Apx3AaVxs4{(6i;6Q#|E?9WVy}#$do`A+}cC?)Q8h|>*s^~7@st5 zYFVjOfZ9*y5M#V0q}!gm3gARn2W8r4Htgj zj=Mw4){K8S?uM9oMxX^xpnZLqJPmG-;?+et?WsyAmT5h2GhiZJM+}RiBp^1ER?I_4 z3u|LmOAVOyGlVu1>h}7#g|sD^f}L{3^exmSg;3E?aP4sx_9UZ?MYhfdKny2NZHRKL zs&TH{#3XwL7!p|!b6}z=tN9m|Qm9h`ves{j)Sz)b?6O?8&tReKGK?t&x8l zU;(E(!Z=gmCK++(886rgMu{9L3y?f6oa?^sLq{qT`?lNd5$KAVZ2ot<9g2?LW~tL|<*CV@&ng z9^fYM3Obaw>dGUefMMDNowdX~yXkBxPY3>UWpE2tI7jAt zv&=j|<7KMeYqiR%siozoyugwO|d5*wF&1F}SPjVoj-e8Y(fiw#Nq3(LbT_4wA(!iSAz>Z^TBCh#Vw; z?G1vXespudA5B;<)sK5LH(H?HctzfV2yNFFaeO-&92r>|wm&hh znIjFuDb7kdU9#xyokJZYy8JM5AeeB8f!Q`)yMSW`kM*;?#_dZ#D*DasPw-!JxT1-9 z)Vv|*@c@hHr$COQRG4YRvPqoGGJ*E6Oa4ylgA>#|1`j5Fk+YqB(Dg349R+doluCp|tW*qhYfe+>qncPG*b{JY52C{1 z@Wz&W{z0#wE%^WQpYpQ`^l>srQfhdK-1=z-c6MoBJJQs?bCUY3UH5B93~&|%(M3w! zLrmOYmL?g5nx4jDjVWVAc)xTISy4lgWUCQ>KLNz6_-<6+Bg^kCf!&B6tYonHSfjR? zn6y5*h`T_3OMZvv5B?C4|GKmN`~3Qcx=;Vd;*=_>SV%t1n_UyF`DF@FiSFUTsrB5f z3Y`$!M~oNGjN)rlaa~1~0mg{{<7^N`+H$09J^a_GI{~<8Q4ZbZVB3fi%NVx{LO+Wh zO-i5_yt^WA5L4QCl|;Fz{SPC&9mntvnbdis;NS3Z*OY%i7&cv2yC70Q#{FIQTr|Zf zeA*h6<>Zt}KC(gB-UxzcPXJ(b0JmZTH>JR4g{vQd9muPZltbgqi&|>V>~S+}%h4~o z#6<>17~|<-3Sc8i>baJblV~#E9&mTWieR|U*5BMW&LNj7 zP9@>c|LhEf7^xJ5P(fN%lvssZ_1G4ik1!u6;0zH4josi|?H~KYd<}ETWI^rxnI4FG zANg3GjdZ|^cQ|J^5I%gMDn#m}bh`aw2m`rD+a zT1Z#eVAEKEh<>&yNx)n0sf`k{awP}cm&tMDXg7n!KEK~i-HtE$`nFtc<%ZCd3Z5&Y z-WiG&H~p4O!bB^u(~48S4*=IjjNtCI_nV=ByXYTgFhnR`jXE`STdB zmaHXa;aSoG&$~b03^$|m3NvlUuc{?}DS^7j2Wg!)oO3<%>3lCQWWhEv!c>VaX_hEW zn~j*REKIxvNK7-OMH=W}8JljaSb=ncNmvS&%tofV=JtQ>2q3x4>MiH&I)c~4n&(D; zwd~-$K!t?{a)nQAQ^Gz%Y#s&mB|D4nk?%7pB2a$o?Pr`94m)~-cCsVuRBG=0$Y5;v zkcvHIbL_{%<;P=N(VF`sX|&JVk>A)c!c%rgA69JUQo6Z&2EQNc0bt;TH|7)twQ9R^ z2g3`U>gIk;*i-nc7BIW_o9}_w30u^);ti>Y8>ozK7WgPenTb&}ozmVk6?_+~68NM! z;dHdoY42@%{Lc(O%kn8yXD{bKg>AiR$%FY3sEo`N3~9wf=xEZTtkuGsj&H@yB&4&O zd@LMVty+Y0eTIA&<;Vi*F2Aiy2qiYV2Z7wjEuBfe7_V8)XE`&2yeY_30zyVRe~?>J z6S9)Y41|wU@cCpZyPX#hO2%dw_ZfH}~evLk)vJ_L3isTLCq(K@MV&I_R;7 z2+7+_3Y5L@<8$~daYZbse{Yza zjT_$`#oQsX;{JW3THx3&N14O~c_)9u}Xe2x{Vf(LkOQ!qyPw*Cc(~*5%&rAyO z7wFqb?XOVUhz6oTe>o=pdXt@?SzVnL7BEs{PX5D^PEjT@JTKSbZ{{E8;SZBT;o8c?oMzGK@!{{xCD3Cjk^VRch}(V?k>UI-S#DQ&goO#S4NLM z-S_sbD*nP4`v=c_=A7^Mt+o9Lwa0Q=KV0z8ZOMM*)mX=Bm8n580BgX}u;JpLxQK0Z z|4dQwc~Gc&*X@V3yJ;5ci$wJ(^@RH<6*qG+Gk3Bu1DK`O(AY1fK?Fprz01iQauzA_}te*;pH=x@w> z|B0FZe>ai(%k%#mJl_IpwB`e|i4T>G{MzG4p?U{?F<8 zjz2N;e|i4T>G}OXG4p?U{!i(7Sd>38^M85%Pw9Es4}W6j|ML8w&+~%$09YLmId~9s z6ax%Bq9Bl#1G^lMAW#s!W&!D}MyK6{R16v&pNaQpxuE7Et9ziOZ?_QdgWxK=8aOY) z^!@f1n}K3zYUqJJFw|W2Tm%K8(6C~ef&O0{(ipc0>s>pvwn;*i)7rEq6S;uX-X@qk zz6!6ThY8yq`uQ@@&ue(Qq@csAcOC8ZTVU5d5B-ml_Vy|!ZWVdmN?;^@v+%|7PtybE zIbyQ_*dP!&QV`T%-=nf&BMP5R7|$7Hamc@R;ipN^AyBmQ1>orpyu&+5Rp4QAd06T^ zUUYuHh=5Ypjjsd027j){+?$4XK}ML0#Ui$aOL^er>^ z2HXJ8)Guv}&?XdaL2u}dv^!b~?_J$=U)oEf_wT+{kAlTIdw57Kw+OAdFj{UC9}G;C zjBHeLdg}x*{;+f0p4+K&8%vD2{uG_{f*p8|nX$1UNGfTa)u+k&JVl{1(AOn0uaAoD z(ufIM2l*QNH76pt3}?4J#2Z$6n*o!(2%qmKBGsfcPprS%{$LekvMB@CyFoA2(MQE9 zf|s^PPTI-yzPHyGpAK7SYSkJ53Vf>)bE$h8(ZH^JdLK z8uX`XC<$Oeg1Pj-+$0rOL=42o}fUh9t!!m*I zP9~pxQT7&M>D365>=-2a*9K09^xh5xg7R4qpAV&G2e$EqBJhNjM`yPtjrfe72KI22 zia4G1?{g7D{qr)hNhwR|$zm6s?cZZ^C28}_HwnUkH3JsmK2&dhFf5)-k$ti3*bYP8 zi`4;bkI1RFRd}qO6(cX_P5JYl7Kg~bTP39C!P7s_^jj3g<4eWhdr;dH7_?D0dp&)wCR z=L&AQqBAK{bx`o83I#)wZw8ef9VuL|Qy!L&HQS~mvnRB+Gs()1VG=Zb;9 z5m0OhnPKOsZeYNU=;i*2TpUAGJ2ToY!>v=lt>%8s-huAt%T-(l}WGO9YPxQEyI zwnP!BncptMH7OrkMwsC+|8PK0MM0%od!}5($RM10dqX}2dY?HnK=kc{qBy5zBRegZ zHA{KGfRY>%qGbJkv3q#a@1zX$`c??uH>#A%@{#pReh8)oPaL2%rI5OvEMy6Pt(Dx$ zbuBUbn%S1b(yfr*?u%=74iQbN{?!>N@Lk>I^GN;;CDZ`jHGNGpkKH5HVncduf3>c_ z7cLhN(E$Nb))}qH;S-kPk&qlcK7PY05f4G6M`kHqZ50!#l~zNq9ssY6oUpK~u{_Sl zjCk@UKmlC}H7>QtWE>D570nw{ubw7&WAa+YJ@NBN3XR4j8;kJ4`|ZIGK*1df7+AL& z#rmdo7n2N#U|(>@oVa%kt}fs%U&i$@_Fi(fy+KnhaecKts1j!%oxC)6;|AurMVDLM zkrPu{t;t29mmKlwfF0;uqu?2{G4MqwV{BXE@b}R>83QT<7MRaBPRt@UH)f{0Qj?PU z_CZjhaF_rX^;EC#_`1M!E z;O>C^Hb(dGN^zF_+4TxRblaDhybo&WF$N#49>e;I_6*cV2N?X=N(chQT60igzE@60 znvGe+Xl4ZZ%(fL4(+@#@|NNtIsapg4J+^zP8xLp-VSOSiN|42m8=hfAAXQe(lzh)C z5E0Cqn%Z*ulZP}xSrQmR2G);)82Nm)29C-2i~08Cg}ZTx_IH&@f!NJ_g}M;l3XOT6 zupTNA(1O_;VX_mbRPTEX>RvobjSDv@gy(_RQ51w2kPRfCF-H;bm1+TW_LECG)JX(2 zCgA`ayhvK#en8nU8gHNH`c^4LmS}rEJ(Kv zd`zJ{KDnE|tz(Du=t=Y_wCJ=tw7o8D`B;33PG4oC+3NWEZsy}7z&sc2a7MhNM{$8D z%Xf7LDD9pWU#ys14XNfDVtMLJMUR)0Ro`)qRwZU9@fX6}%kg8+dgpvp2EbN=OcH<~ z|9TM$5dgo<+#K~aJEZrDPI|w^m)t=43VOj31iVucg@R;uzWe5z5VLux8055AnTrX- zXgymfszjiGiD2W^HOV4veRwU`d3;_{}W#LOq5vN$I{xaVKc<|`Zgqkpigoz zSU!Eyc-AJ==&e@D**LIxC44QHq0hR*X6ObGlEfkMfe)upsjg+K&YvL>#p{YEC(95% zDK4(U-!9Z?GMo)f1U@F1#srMJQ-$=Jy<3KCHtO)q)paqYIvm`4Cw`Eo5bJVEe6Fn0 z2I)Rl#Y^(zwKBxyGGwq~jmOEa&hDo~%~{8Q0@|mE7!g>JPbS$LW!8)o&D-`v| zig$EownulnZDBWjo(NwqkXLYa;d#)}8`T}!J(aDx)Lh9y#=d^w2U=W#E{i_bqH7zi z!pdZDs+;q)OvErEYv@=pgu>9&_5OaSU z_=2)`0R>+Xx#hkXJ5&M!&8z*4PW!~=z7HuCq|hpEPY+YGK#SPz+)!_o$ND2-W>^xq_R?7 z1xg#L#1Z7zQZjC>fcDpKP4D*_%18>(0jl8bOxx{lEGAtVg5c!I-GuAjU;zD zCksGl3~bYDr&6GloxM(?j-PR=iZD}g1I!{!j^qb1V|`RUNv(%yJ&uD`-7r*qM~}azykXbG00;ew*GvkI8VC-AxvIncJnlLUP;4y06m( z251rv*3e%zm|r{!#^1g*4gm3~=2XS*YC;~(BnLLO--nwT#4>8I$fdhUGX{2UHkFoy zH&R+$4dX7@x$SduY9)?^1oE)!;(hxE`l1$P_}EYAKkghu*I-loOyRnNG4DOR3fSov zA+&mgS1G&Wo1;gJ#VprK7Bqt_$<3mLy@EnG$Yjl!^R8Rg(@G@8q)KPeBzDHo6{7%X z4z5a2Pz=g~i;-rKnPEv>D(oP|#V_AMLkI9-FkgKl5#T(V3;mX8%P|j^(Sz>LEelgV ztc#TjgRcxg13^7;dF}=8AJX(rV?Cqtr}&0OjeJ|XTIYTqywoN8T+8r|wr$scjt?E` zFOu9Yo0k=bw^oJ4{AurJYE^ak)SV2W%qPvwldu zL0r#|Op=NJ!^X9HCZ;{mP7&g`!3TEfiJraA|EcofF^MB^(Ms8h*dWP33%&ug|L%Q& z$D*fKjLiIsb^G{L=M;gnFF%Y^ad^KN#|UAT`vc=Bi(6(#3BAO@O;00WwHpm;t_ebb z4&Xj^%8hy=udkV_V#)5sk8{q6S0!8 zJWBnsWEl4ZB)Q)XV1fZ)7eJWtL6Cke3`eWh9Nbqq;PAO)Bk223xFnQH-1fW%tBOUI z0zYF*U9&?^(VqrlQa9FVNpr0Wq_!hG`w2KH5+81I)mO;M<;xRQy` zSURhz;2dqvJlj^VI85!v3pFI-#j_=x)U)6D+Clk~hub@*XM2rkK3 z&!bYQ_-;E%=#i7in=#0o%+8*X-1$lr5%Xe(7V=2A-F^oVSjn03L6qNBPSyLYk;B*G z$HjK=yYC#C8k4vLz)!Glwkex2$2}ouIXecdG0CbN2Q+RE*PMz#Xv-UK=H|k#<$(jI zrL6SjJY=aSW!~{Z0*OBxTb@__6YDw(Q`@)pNuoosj2+ubCqg&Z8G@&3dEEWp29B`@sq2pVe< zZdBEJhr|-QF-&WE7KuWfJHUqpDNslenPE901T;#RsjD}DSY5>Ti-k7k=zbFk83xjb zWSh27B9LX6$ybix$%9n*yOOA*@ImW>^C@)gp#n|&Hp=_b~(t6&}v6;#jyLYvXF`NYBb*{5F6G}3hN*ez( zMNGb1K)BJ1!-9N>1|+actUxZZK?oktI1Pm&lO$7b1AH)xvW|47Hbz9S{u_szfKd*Z zKB4#pH8EI~d>@ZcG(^SEV=z(69UrT477(0`Nx*(!p^vA95tH%PsXD6nU56G*5K-B9 z*s~VOS0~5!dE>XA&;UPyaEuLRqx-Z^$w-&u#rD$l+-lz&NE74ohgrS~U}_s*p6y9~ zr%3Ibc{`XO4uAszxxoF~bP>$%{)1eU=K^G(xd1deMj-4C+fn=wxzGzy$sbjot#d7k zSMl7@NkS+P4vH;jv;*(T#;uPuvv*`-3-ee)CbWgs+RGq7F=At^#w{0id*j&I=)HE!zJu%jFN_q-j0!v6s-4#){;-jg7@Ca zS*DzsKAh)2yFN3ph;S(1Q-e-}%N^7}nxLRgGo!g(f(#VLy@z^UjoYg~N+m|fn`{B| zAc<>b)|b?b-^o~z!sOugGy4__vRu%R+LH~=%_Y7+|6p?%L8yt5M%v8w zO|cvW9=t z!Yah6Sl4b&y!yxYGN34?tv>mjfbz8P5E@6s5mvQ+9iN6039GkMSWRjrNN!1X>5zHg8d_%am7 zMsAk@=Gq~&OzFgw(>ff)-}HtO7XXe5#0D1x@wYej2~gh7GS&yMZLrWdV=K2ie+;oW zwa=rCwYNjjF955sJdH}DMwdX~`M9)!3I_S(k#j1l<-33`Om9Etipc3UE@UJ8JbSuS zr%X>Np>q@FW?j!{*cOt7`V50tTIzxbI-Lr@3 z5vZ-0q6zwq%7u}Bz{dtW0SWEngKzo0&o+9Yr@t_IVy`Au)jl^mFu`0n9Fz~W3kA( ztD)zDeC9^Yo)_O7R0BWWVB}(Bo=H!caR4KhLE9N;YHXSJgTc^&sujKqPvCtj%lI-t zm3>G)I2)XIWo>;fMH_FHkcJxrJgpM_RX{>*G(#CAbvudY!yK+F)tEGkWe-ss1wPI& zn*4!vkSC7O>hjengezk(l&qH;iHFy z%hJhoGX-!aWwuL{o@QK}iw_umAcW`m1p?kVvwlHB-S7*K&9(0rUokAl%UMnVf?mnE zI?2YC^s{M=ikdyH2@)vC_kIXvoK>>t17C#EZO$CJaFj2#(-ZELIIW2m;?s6~HI+Pw zj+X0Binx<}Mh!#-hk1MkBv#YLz*8cObqJ=GAJQ+z`A_2@A@i7MSl5V9CUiPBBR&Z%cpw8Ii)q}_QO+gG`qAaeOT~ytcS)q-J(Q}2^5-jtv3!Ggu zP3;J7ZvFc$pwq)e0%)z&_`2B`JklVP+qMw)1G$;>j0X;AQ{B$|jzu;eB&MwWoS(ST zH5=QVX~Kh#DC?!24fuES8)r0{T|Wg%P?HnSX;4EO6_g_tLa%ul2t`0FTiCls%(Af} zWzWn-E(rJ)$EX`EpyAOVkG{|~#%=iO!=cuUt`8j$0uQ}#Bbc#uYdU>Bm#Nz5&(ys6 zUB!BqK?MdWpe2(DbE5Ufy&M8ZBrcHjh{KjcrR%#fq+KhsyJVC1A&(oG!JTImjnC#M z+A&@Raliinl@~=TNh33j8ZdBeq{D+F_RjuZ*_K04aBIML6>s=#?A?T(a9=E0HWoY@H&dkdCV%n~{Fg6KZ+Qa<=<3=6+L(%L8p!(Y# zzlo+HY@p>)%SL>1?hO{zk-4P1Fd&RLt^zW6_c|!ve}DzLn>91fMeb10`R}mI66_vg zs42eu9hS6|2`v)^#NZ>^G^F2P*+t9w4HhbQ0Aqp+J;!gbOfr!PMZmLq{2rDb_TRz6 zKKAYxEKm@mTgAjgP)vsX>ypFgZo9j;P^xctcFv_tK5Rs4{07UXB%6Udy$&NJ@0-x* z-(Ufda{LBMG43ij9~6CR;~Ok73fOTI5--ZT%$ou*i}!?7Rsz|Ftj8Mk9mF}ECo&$K zS!}0wpf%qR#Y%*8Q4M6g;b9=S7fBeplJN@3jq&Ur_MrxLhq<$@+UET4OXWfjy-Jva z*HO`Gj;{vsX=U>qj99$?hDzoi7L^yeKaW==o;;wxW&ay0vtTWCJmx|<<-U?Sj42mD z`}^iKe{kO9T2uq$?0=$CFdRqqZgOu>**lhdQ)ulC6<^IaR34xmP+`m8<`;xKu9Rpw zAadc~P#O5cqVmf9msS4ztg;7yI|qp-{@bV<;si;Bb;gSVBfBA(6=a@8`9l2@e8^_j zAc#Lz_^@Dha)l#mgx!~^Pk~BxJWn>Bjtp3B74#`K89bMx^TlhG_PGMIEUVV-O61_6 zxS`dCAt$jZN~DzSl;umbE_kKaIT+3-uQ?K)eZndLwv}oOY!#f9i1G`@I!aXhdq1Qa zFZVJ3x;ji*)rQ#<1x>zVFr}iN&fp)?oc+&?@b!)PeM_%u0^km?010L7*mtsW@#whw z_wvm^wrG%bsqJgb#;C3{BPH%2IVMpp9cbIVyC2W^_RUA}lgf?V|fEGOVhQ)54_P>C>Mi8~v0_lMw}5 zsAd_pCv3PUDGc*$hs2vL_a+$xruudGBCua%ATu~;R8SGRttpjc z@0=p%M{~A!FZa9UF!7&r=St^$=hKoz?1Q5+SyC}cu$<=`57aaQ@b00h3J|`FE}^5e zKnX0&-`hVn)4eyWZYAGif}e;#(0Ixm#1Ccp7Tdwr<*uO4J9d=oj~jW~>E_MBLfq8S zUsN1`UbGaroRc4;@0Kf2x*Ylu-hlJ|Cy9H7J^)Yl31tu|en+2WTrpFugq%Q_mluus zXK-VVxvv&WxD))zt?RCT>({Li9G>k7yyBZ~7oR3EM%9g0%^3YHY~B2e`pwy<8X4bO zj*%3`VtW{I^GY%=+N2eMsZZE|2=QNI)3=zui)Q5Va+uDT%v^3OuTqk8xNg1Qj&xG7i7VFg_AhQt3lo-g-bxrGS^P|zH z=A?5Q0m+n(~DlUTrJ>YcX@*PEPc3=!xX16?jWjR*n2|-XZ}V>dI-f`wN;Plvep>;X`Br+Y=c z=3M+qxDPM974gV!3^?317gdIIck3pF8NH%R54Tze=Rt2Zq0$hR_H}DE>o!-OF4iZ? z{I0QgOFIMBnUaj>#Dw81k%c`_i2&zJAa!CZt~-6~3uS(L{{fDk5~sXEQW$9jIi%HR#bKOlZ;P1zI=O+E3E3aKbI4 zXz~^ONuln!Yj7qaoAhB_EMmI5AMrv)!X-Xej+_Z}2XwL*a|8xu$?1iOG{AO~E1HKV?~BE@*#o3LKdmjNXq&tM@?;`V`K4<~etr^Auy03sY+{t~07W zmqpg;e%b;Tk><2`pXNq=2p_egPz}29;5=$Yp#q^ZmSci3?k@mOCKWOB)Imx}=F`SL zWU3?V$@EpS%-8t4^$>d1bwNwhE43$gSga3>zxX@Q)F|wjHEosb&W{nGYF5ucvRtAF z9qMT*3dFScck4P6*0dk)Efa#vsut=uG50Sk=mMTmEfe)sV6uFLzN%Ol(N(^IB@clA z22zFlTeyo2^6$b3b=7KdyDq%!1_#OJ%$Izq(+b?ExQvBNJ+!$>doPOR5 zxzlfRfzEvGD-a{hiWG2l^t^i1Sozjf-#|>0N zmESQu%Rj|CVmlsE%r6Xm&p7XOaS7dV+QVsRvQ{j#w{T?ALJ9r6ml!rzx++Ju8Z=Uf%a%?Z2@J%%w~AuKmP?MRC$Z!HVWfW&;iMi>${;yMwlII;HfH!n zVw$Fj`+Xkgn&n#&Sq60HW3F^69?7M1@IbWz_TbO6c`ajm^PEsva68Ca5CxYSQce}g z@t!hDS=Tf-%g+}0#k2>lHMt5;rH>I`E?Wu}!9?J{zXZt2VW0Y=id%h}Dnz|X;?P(q z8=;1jCZyGYm&By;2ts=yIzNenBctrppez}&wH z$zo`Gj@UD9^NoT|n*E6v$)a{JBtr4}u`GuB@v_`UuN`$4{uzx-P9u zNXgdc?)Ty84Jrx%eAKU$;@1-c>|h*7tcCaY1c0~2Ghyi#jYGZ;9Ler5MEMvL;4C1B zJhTo4@=&@(^y$`NgkjiLMI>P{i-2@cc!(iP>QdaE}BwrH1nv-8Z43nHQnYdRUAj{G%SBQ)2~a zhTp5rFOuN4_$3)eoH8iAL(ElRMVc#fyriaO{oKRW3tPwE_M}X&9NZ#bRu}jbD|PC9 zFrPc1VYTBKA`(vkeK80!M0B!2;p7GTk(j9e=uFWZhAVp7=O`H-1=m_GVWi1GFz+0- zp~cd!BWJ+Q#!9s8D0&_W~^N$IH@qDU~^shWgy&HwJQ5WE!Wb#>V6g>2ieO-|BCXJ~T1izA^uzZAFGP`h>Iv1lBVySEMss)7?MLyrNmG)ia%W#~j+5=PT3bPpV_N4&1i}QV?0;NNLoK zs8C%viUgfM6}gMew$&S_x&@h2g)I9jR=rkJPJ13BaLUdpz(;-j?w3wP7LE-(2wGCh z(GV_E+DJTxZE{q7j4ulGS5nCVWu)r-hKY)0|Iv%s8zzxUsF)JJNr8(LefZD99=aiO_LYn zL&{!BRsco&fqH5Zl2K?hw3YxS@!Ftah0zJV1Jc;zza?1#-x_UxdexuAP<9_KpUjVA zMc!{i$i!ZgoM>P>bzHM^b12YaJP*)Ui@7WjF@6?4UT?xK{@B2ZEXz%5yx|^Q5mK=? zJE*>T9pAai4s8jCM<@`f!cRUwOlgD z8K&5$85=iO2-=oF2-eg=eyH+8%e!;9UxbA9y1o%Y z{Q3ZG)!7or{H-RoD3c*N2#3V)g#Esa{9f&Mge+tIBE%Kb-NV!imgDRfArcP15%StP zUZ%tKPeLSxK|gf-gOEx~S2=a?O-||mFM7RJ5Fd)o?e`_`{9~;5jhHt@t!Vk29 z`U?)9(coTbQy~hDjEoGQxWVF4s{;bLZa&gFf^@VHWtq{S(J0}uEo=bR3PSCH%R%-wBH*Bw&gd8zwl zi2P2_{%Fd8zTvX{Bk?dY`2DuGA&TSqf2V6;=l4!1a$yA(q`dBcmiyfR`t7y1RKao~zhsWeODAkO{6TI#|hOf`h zM>f13p!a{l*#0VikR_L6h_5OsXD_du71ts}we8ma-G~Ml9B`Ci=)n+Kkp@G`c6L85 z9EQz2HDu&;tc8=Y_2orl2y$X0{(=*(l_`jC{A2g5%R6PH?xzBf7B1+LfPJEMw3*_H zNBLIZi+$@HiFDl@X_1P3Rhbp?WbPiTfs)dul}PZGFHjzQ7tE;rrdEugNV(*rNfoG& zTyXg*vo6o0IXq(SWyOSoZ$fxwX%9oKyWM46#;(SO(t8&A5b>ZIp&q{q;xIIN5|)`O zdt_Jf9UhJ$#!$C8-DCz$XYG?_gW&VW`X`60-mOm}cd^_vd_w(&4c~y#g%o z=F#bnLGaTk&77wJG|?6#-7`o*Z^18_*=EVZjjeKn2YIE_7op|$-kx^a%D7p@#vKEZ zOr)-V>%SR9F#IQFKYTu-KPnpx&Y8f1Iv|kq-{5Pl0pQMvI}!H&*?(052u#1Cyx#^j zkpF7`?JBh#z^5d7sdKa~0Z&^6qhlT|G1s2UV%!#Uq{>iijeO>Gu_pz?nBx*Eqom{!wy}MZ*jvXV_#l;%wkW5u=%2<7SEjmG}|iC_%8YecV{B6Ph)&{U=M*A zp(`yRWTpJiLZi&~~U1+(E$is|MD-it&9DHS_( zJ;L|Ofa;p6k?HoI2OU?N1|dJZ-EV~`?gVH!M&Vay-qpmVRknF(3f3KlY?(*jP;c<; z@`kKM2cz|}$kk$|Q++AAxm&6-t^KkYXHhD!lC5nI{g3CA^7xhK0uka+a?n;F|Bb#q zv14&|yS_JEmc5p$f*OFNLyYn(k&MJ&1LvURnjw6QB4k2m2bN_s-ALA{(|!qpgA4oO zWD^RzKCPo_K^f=ilZxMB;crCjkh7H$TMA@Z2P-^?HkTLun2bb5?t-8bZGswKkdo;( z^@)11Ud~-(?e|;2Y{T0Yz({y80Fyzz|8ePU3jpk!{@MblAEGo2!merswG#ao+wGYZ zLoJO6GVR62J03{2-)WDR#MCt((EB!-BCWnMkUxC&D=*F)rzLq0;)n`L_N^cIS!`nM&6Ez03ZIich=)&GU8CG@` zy#CKF0q?>7uSpsDzh43>)hedvJ~s~=xxEt78djQgI*dXFhSsJW)7#uqvOxEXQL$V{ zA6XN4*>mPr#OUs+B%1K|M*Qq8Q6B!@{;64nwU?mzJkD)uZKIu(Hs z^JG?`=dww$%%a?1f0BMzmi+SUeg4c%=gj@B7{s6jRBhEA>}&|hqz-WuIwBZ}O~AzY z;%J)(r+8FE5;{cazIsnJAlg5iU0U#U>rZ{pfV1Cii#i!g-mt59sFG8P7S(qv=rN>j&n%S_%MG0=ZWv73Ex9e%4!dX13+gRpecKli3V(;n#;*t3>c&b+5)n=ZynWcy zA!SsO71fZ>_%nM&zlq$4ULk*Eh8pUe({RBxbr2IKktxRvU-0Z)x~wxb}b-$`R{0B5`(ypt+k zEKmcBm5L|T%>`u@^~{>XRHa{2z`9L%ORh0CcDBrEnc>R_@t^aIAL8%defk`#7ehif zVM9zipbrr$9#+S@3ceP@B|Taly190fXcU@2fc0d#NaD_4*0w6G{(S}M8yx?+OE5q2 zh=@gkuF2elUI^{K)5*@tK(9P~W1=fy5>EhpJKSB#i@#iQ?ku++>7bCu>HbH;jw4!A zaRP5?kUi*vC`5D);R`dK6zeu9^lDuf#Pz6Zlv8b%JJCED?-o7;tOq!W18fm~A2Zi_ zXDkRvj?gCn!UV`9`kPFc-hz1Z%=QUTUwlk4F=zgf=c6kJGD*SjNNWU(QscK~aeHYm zo;#|xjYp5iIy5kVk1!jPEimP*<_?UMt!e_m$Q#1}D3eQb9f3>#3mh@Sr#?@;TpAl^ znld_pt%xYAQ&Du9!cs0V`855AJ43(}jz*Jze`gft^^IgY{A$Xz=s6hytnVU z4vVu}wsnDB79#k2)dC1@ifa?JF0i?c*F$ht_9|b&h;Otm^1T_&_#F^nkpc5=6II38 z`h>d_=aH08vsd$pNAra`g{+=ie_UXid>1x2TNWN^$zK8kB0tpOfHT3@m;j}fgZpGQ$$DJjNq=OX!2wQpqV;CzaqxxVVhyNzOSr}SVS6fvZkY?nhX(o0F#q9Wd#eAlkNK)RSKSG< zMcB8pCS)KZzD2o(p!+T_L}p#%nq{$1z%SV9L0!a&5_H+U6Q8PG8)*6GbeFEjcY9TN zgC&0VVdD#*ik?Z_e2-$1a_{pK*SA^&5$ z#seUV|8ep774QDR6_R?@L(|NPh55lp$u5^}3g<%cNZ~q-yC4OJT=hyN zO&)J_SpRY9_!aH`DdeFiVET7DPJResW4zH30iYo~BJse~>gaYrH2g(};4#TR=tyR- zrJ1RpD_++K_awxSp8#RzgDs_Ff`drCWB2&?bR_<9>G;bX|FSze{Pols_&Vf4SpdbqCUiKQ0}Ax#M4Uht40Dj=$XTuek#kfD{CR`rDJNxA57? zyL|#|&?HSsiGpiA_X=BInx;03rfmT0+)g3AfX}n~vGDN;>88rUtJ;{rYGZ^K$OfOxnC}oGHkG$lWzixM19Q;xWgnT)>Rd}o@gF5v3_B#Cv{%m|-@j}Su z>Hq3=Q*n~y!7c!>m*JmG+%z*;XQpz+)FkuSJ!m*36+ zYoB!P=8iHbUJ0i-1+7WfuU^-wGb$LB*XS||++o`XJ#>JVu0_E5Ti(ml+p3qEm%K*Q z8|@OpFo7oil6!-*yYh^s_8VXp5ctTsO}Q=O{wZudKD~&))VAHQ5*Yq#ZJ z<$k$;c>xCkN3@$?D_;dYfiDEF5-)C#XUE8QRzQKHcF32UldNYQpvtw-bIFtTZN-xI z`uJmew7{@e@=G)@_*DQ1d_FVsx@unq4g)8E#6aL{`#$BR%slW!dmFe840@;qj=#FC zSDYZf0PWUifcM%NoF8avbR`W~1nhd@@|Yk7OTyU_9av0T1Pz=z~ono!M1 zT-d27C92tsg{a8_0;~5 z*8~%CV!ly$G=J>=ZK2vXbY2k<^D^m8N-~!zG5-xZvsZA?M-U@Y0Sr60gX29nq+^|6 zNCX+;vJnW+@~S{g1inMQW{vZdgeziZzyd%QkY_&v%ON2<&K7F zz!*HFd;}zj9C^)!iNcR>l1#gfAiN-NKjwXtt<4-9F|KTgQ6bDy31iu<3pcUMHU}JB zbZKt;qx?GbzAPU|4CbGpzUL^Rmt3%5h_jp!fm67gKYi)ZrImKQh~#LPW25S^Ns_a& zv5G1|SVIjmnC`SkqgEy*B;aQ(pnws_P7*n}WG$PDFM7}^w=366KZ8{-*skD z19jE2$CT0$CSAu`Hz$U6A@+`lPp&^-(jAaX+I$P-o0T4M9_p7tJmJdzI;5#r`(3gn zOQ{v|xD*+XeOiIl=p+5R#kKB^RbzS6Lj6)D*)(g%t?kbc!|U(zzClosT5e$$140eQ z7t5GCy1RX$By=psma%yBU7!=l2sEQyY=w3?c{@0GePiMsJ%e*qybr3V;SXEj5gf2JP)|b=k%E-H<-4io*4<^LWh+Z@HcM1>e9IG0;jjR_Gn4hW zKv@hi0grL=vRC9xOj*0}T19%cXJN6|fGK|v$Q)_}ujEp{B0P`=k8i8y#lD85Txh%p zREHk;f~H@!XP>@?Ha~u7yhrcA^I4;^)_MOxg3;Av;-bzHCxjrd(FOe`z8$i^ROC6Up!79Cu}J#ETUgr{6zga zu_T@TmDtV4mPY!{%RTX!K_MEEgR!dR5`-IkD}Dw<@(!s*8~=rx4J<`VII3YB5!B7w z)#~k4s$;tnZ|bT+%d$#~TB+d-u4_ZzPvBmk?SX0J=`F^^++&nvsH&U!eBMW6ff|#Cepu$44??W^MVd~`jCc}kE#U-E>j@xyw;g^xwz=}}SaCWgZd`U$i3yC9wU!JEEHNl5~JdllvFgEe{cv*ALotOr*j%`=>~ zdW8!Q)$(j+_rSJBvD8x z!rJfV^>82oZk@53t;AB5L2WtM^s)VD?*d=Tb1BZGx|Ns~$}buVnjf4#@VogzOl6_a zKpH?=IKZy@eQ?CET2@rel`@_gntk!3vTb#n^LjMF^)WC4pE;>aHLk_v6AY=EQr8m@ zA8SlH_GK99-sk@Y;KT%HKf?_UIGjRlG8=o(mdWUJT*)db0MPXo?fy%MZcRxd|wThy)M%9k$iWU5Sp2p$|Mu8uCnjGF;29;S?Ko09g<6-h7`$r&?#k z_b(Qc(2HPP*(*M5LkOtUsK&5CdgEHGomR#!>Y?|N*5CL0qu`iduBRTca>u8?rxj`X zc8y0wDC6PtaqY-JoMNgBGoo@oJoMRKJ1aJRp$JqEWjP~%y8;I5#&r(IuDsU<{H*z{ zGr`2tRvIFKfbhhoXztfzh`~91tW5v*j;ADhL1whvUIX^trvAhFhuhsTiawqia`IFP zYI6HZXm=xc9baBbxFvExx?e6tPoP79g>N3Fy#|9M$kc3K@@MWOc6&{w_ipGT(`!9f zT0J!&BJb)C7KK}ARPIkQGON!O*`vnR&+9ip_FL2AR)(&=ayUXS zU6WU%>N-**ChVj7bxu}yhe4Ho^hRI&#w#csfqBP1svt+s*b_M7&`L6tCv7ZnD>~GB zl*n6|rol+N%_?fK%I)^W+GC~$F)#;v6JTcDUzYiW>AU*W#A#ogDlFOx(nh*G>;!Se za?d@!PILuXBItx>tU6P=y@c5%iE?~Fz$YhC3jK5x?3_UMoxIg9H*&@_Z4?;&M=T;x z_H}6}4l_-B!#tu;tTME?6$}oTAhVioIVXynMy3kGrQz1&{Pr?sy7OVrsFa$YCp^qT z^%3-?k~c4-b5igE@hoen0v>Wfs-yNeaZ@UQxko2DX4-6`Qo1muq0$kT)WQh#b<^hK zgp~*NDa@J4Lg}-_0rlk+=3az~cdQ6!_UMD{6Ssp`y_YbNr9&iaN6Vn?qe|dwwvtUu zzz@Xy{a||26tE+k0T(fgYR&~(P}i4jay1g5y0FT@zVE2H*BHs@q<7F^(TGF7xD4{t zVV|2|%%I&BMdm@A#{}#!38G$~zZ_DS?CbQbJ9MylUVp*T`!?KTd;+(`drZ!Q!_w!m z5|g{7-X)N0Vx^Wu&3&G{Sz$m+RM#TkMz-@DOa$LoSY-R?#US-moVX~+f-JNQlgt@= z0q%rhwMee5XDh|JI(O-ohN?k1ZrJ&AkbmVt#DmP1wx)U=dEK;#@LMhN<`)BAE}C7v zYY4``*>7~az3)xc7;MTe_Vwjn;lu)r1jP{0SA{i|cG_1NyT`|T1(%qdwXpi(y@fv( z!5CsXkJ*}M*iiw-$J;6YD)${&r* zscFX#<0F_;4pT{_Rv-WMq+++u-gnRdiB&dH{Ki#}k&6ifT4y3CQ56^N16&af&wh1>3`q4qsS2ATSQRo3N)#r(>R(iIdQ{q7Tn>$C!)Inj5O3tA^CHA#Dgc@NiAJM3 zs3Y^uoQzoy*!oC~r957&Cy67F1~x_nLI%Xj2R5Kpr*wTsg;~rHEzQ4T&Mg3ShZLf4 zawBxP#@&P1AV&-1dk8pPfMkns$rj}*IF~MAbA~PXU3_C3gxuKal2BK>H0VP*OtA-3 zeL^sopO{spc+~@XlIz|r#KwDVL1lL$_rwYd*Rgxov9H6(25I`_Wa+-w(E!?EyMYWL znd1FRv*bs;eR%oM{mc}^`O8B{a|@z;^3s_edsclla)~#+DFTd` zFfRF04vZ?&z_H?tdp$^jOZEU6PeZ%}+^o`xbA`A+w&da1NT-X4>=g-ae&adRKOs6q z#Lx9H2c5TPglQ=({ml;FjLQkjwAqaVSfUUD6h29+9-A$hIMr(5zL)=-#gYV|1eBq1 z?O+p@R?(IZ@nr=N@`6HA^7*_5-4t024#Fv8IP^VRWAXNl))tqO)!B-$Q9!}dnPZGE zJbHqMUg?{SX#mu;#k0jPe??siFVu0uf1;G#Otgxbp2v7_)`8&)v7^s`+w6|i<=0G0 z{e-EIa&jnt9$>5^ibQ51ceydMW-lk~u-RtICqPhE_+!DR0-rLid3x)9xP#xVOu{hg zsfwN!+XdDa=5r@j>gsTD|0bUfy~R}toVpU@^Tcl))Y7k;7S8S01 z--Ug!p8lX7Du^4UDr#IJ40Th{N|=!t$1W46UX)@kGAiY&e+bsWJEP8*zJR*Ux^rzq z3{%XNbgUbkn`?o@=F?mVp;!m8i|pj5O1 z5){o70|Ai?;w7Ycwf?fZ#N&V+sRP0T(o}%mrEpUi1P%Lsr^j{8W}kOuQhurKTXVlQ z#tv~41wBc&1=W2*C!_To+BEx1;W%!ZhVHy#I(zFra)%6(@2aE5gqX*SGy|b? zAy~heU7IBIfFo$i9QQ_uL_D2*esE?8-r@BjJT_|s3cmQ8V!W-#FRp<4iT*9> z6Q{!yQjWrvBq`f(Ju*MiROhdPnAFl6ehZ&&qkW3CIC8RF91=c-=ROKzrA&a2)O`J> z^9%3*;63)I;GZVEx+JZ8dSd~E)CFz9erxJ6`Wsf=px4HzILufKh&D@|bv1?>L83*t zK57MGE=~Yi=d^)6g@sk0^ukZ`QSVQM!5vtFwkbw(;&jhA*#(zg6`XeuO^C&_)@7$c zF*V@ibOCEcdtV@b;mQ%JKW!O9()cGe6HQyyVrFT zBvJt7ohej4xUvgl!R=8t812ixIV7FP`~4Bh2m=u8r>-B!O?h#NRJwtN+^bqTaD zv@oEnjT-_XebbO??$ZSJn8d*F(YUKA%RunQy!10JU!9}}c}>1SI)HfWcKe2Vso{VA zGH;8Zg1YMrzYxI;L)868dZ~BQDgQqz+%8FIBFYN@$HZ+JnH3v}%@+wp@|4dWGfg!5 zCoB3-;KW>{2@AQGD~#WC!!einDB@7g8T{ez-GIk8l&X{MN77W!vvmgjlO?Q4xDY$G zG!ulZqj>ba`nE^m_)cLh?Ii=9yGe4nJ=~8yqk*8zjAOh#!Eg>c+!{a$J=Pia`5N~m z?=dL1k$C3rr*GcB>^fCi0h{yP1s&-D@tZu$-=^PX{3*FoH&}VxA9MO#}|6xD|_- z-%Zm57*V?r$dnN<=QL(B@evbtdSb$;MGU!`z~)(DGGU*60?vEgxpBoN<=yBEYO!~- z;SZyL6@?q!={q0M zdfNKqg*nyOcBy^*5BacZ?EHHhP$j6Q^c5fQ##DY1?gt!-t~jALtkXjt$41dY#>E}I zY-NTGYYwfppUFKlB6QBLeBSKrZ>UcCM;=D;q1jZ4$gN5Z`)d~^wX@DtJy@SY8b215 zoRMV;FVln)OF}HMT-3yfNr0~fbF1f_qpmuE{yrZK$jZT3%Lj_z#!Zru7sv>=irh{U$;p$3 zoQ;EH@JsGxS&FuXN#Ry%Z1?tXVSZ#ABl`)R>kt`}?M(BzQKZ){?-bk{Prxb3HKBJg zkP%?%aXS~)QTI*EBny3TZIU*k*{BxGMlnDp8Mc!K4O0@|oVFm3nA&@aUXs%&yvv~7 z9Qhb6PUv2sFw-qO&x@zLT{%n@5q5?afgjkRv9VlI-ji&o|H$Fr)6jQ-45Zx^_>I`q zwBz8icA1DLptbebtgH>SxgzfYfKH){n9|a%F3Zj~YFz>tFjw=O&hh@xr)e=scQ2%p z04S8OrF#qNL-6GTKa=T#T8xu`IogQ-2KI*cDqJuek70}Kf9ZcXF7nchf3P1t=)~cD z@1yyr(NEnKVv?)HwG5gO0OsCpUYireGXkdNC3^BFFcQXK48bM|33ntoyDHbHsG-}+ z)DrF2 zvS8W(?%8OSgO_m-ktbKj%;dQ8c+aAnoKFiCeRKw9-shBjOaoZBNx1g0h84l9dR-ay z$<^?yKCNlSZNcpKzfqc}m_u(hk=ngfr`Sb9%qX{4lVpohgmZ)H>x#`@e(ZUZ!+A(i zxxXwd>1K8DfpxauzQaX?05WohHD74eb_uAu_o!3tZ`!FLMcwTJ34UVk+sT z6qWl&)n69?K^&XX_=3t;>v;Ii&Kc(k!H>8?1IU*{yWeLZ&;E=m)T|b&|M2}i=d^8a zC@g^iFXz#t=b8ER78TQUcu1rABSeiUUxuLL($T{-jX)l~6CTvsGFLF|$uMpa$QT>1 z=D^2EZ$&OC5xHOip9UDEa)(KO&_Ws(zpmPzP`?I+9xPU%#}TucH-{f6O42wzxc-YK zc36_}AU1D3Uz8Mt84qM|K2-M|cR2_!;_bCewLVBhq|ZEw&d8 zpYt4yKJnTAjn-FIPkrq{mvWR0{s{*`4t5@s7(8$+=(E9G0I!sPNK{&?~#lOu+pmVNSh zfHaL*76LMk-WI5B3AD=nqz}KI>4_rF`` zzh)(S0h3$ePiRcUNfG5k@!~eZC+CQfL~oXOf-_aZQ0n=3;nTyVCMssvG8*U$SlKT) z>>=lPwWd>C@KS(>WA}kZ!hRYOE~s3 zktoQJ^T$c{IEEh2vv*jp@rNt~Xb{#kk7aYy~0iAM0`8lCUz3G|lRA zB)Z)@JBAfUr=}d&Jzkm#D4;Qayc+XwWGoKGjmR5I9LIx@(^jMp_+m4C3)Wtf=>o3} z?4?>hXC&V5DRQ`+Rl~>I_gF|g?r6N+*A$0EGkE<_qWL03pHM$F5ZSHN6)#9L)fMyp zG>9R|wU@#E(^K&&!0hnv!Not0^?4J{oO0=Y!4uG&xWNBl4(50N$%0LP=NV-!T*8|^ zN$NB2_Ah;%@#*Hw`?O5lE*?f$PeD)KA2$u=K^IZLl z+}1V;3FOyLcE!2ryo{)%%C7Fn6pWO#F;v3|HmW#8lt~x*A9r}ol26bWj>YTVInxuu zv!2bS4=l+igm#Itu{oMaQ{Nfe!2JHa>*rL+IluKBY1~j($17d!_v0iK`TT&a5`w_h zg~_s&M1JQ!FapR$4?jc%mz~j37QGJ}O1WiKlZdWYEI)~sdgLMv*$$JU4uaKY5ulQ3 zTKSg)7-hq!M9uiVJLYH&k3g^BE^h7ZYL<4M$J=+S`WZN0Eys=DwmkeS$b|8fN^M@v~*dbL>3Rzt`eAHM-wB5rZTsYjtG1PLKC4$E-1*?$=d0Cd;hU{>se$k&BM?8;+K;H(~Qs^eR%#hd>&V*U=#G(#H4~g*YW3kd*c|Eg1L3IW2{( zXn>GwU|jHo#$*^iWMH1MM5E}WEK!V6_cf>oQwlZ-G6on%hB-?-2LN84P>#p@7m)qsH9P0jWave4&Xa+CwMI*dfEw^gWcKrGca_?35Oi<^BYgTCscS*h+G?L1ds)w4itxQtb_mbGZFK^%C=o{8<0_<@@fp_7#Ke@ zb=$hVjpjXz@G;O?E5N0fkb;!QMrsf<6=WLMgZ3z^>1p{8xccBOh0NgUYMcK9hOMUP z%N5>?183&mSI$FO9E0=g49y)UYkb*Vax-FKW0pzE;nHL|hNh6vKzd?kGuCW6k1Q}- z&koF7l~rTnBU)vS&wy~lv0zyN z;kQLwOa9ulXeA)qx2GM&P@dxFg9IqsGpfjn7yonU5ZH|G$ z;s))xWDeIJW7^pz!&9;6+z-D+v)UYz#2$~ZL}k@&P5iZK>>Pw=&FBHb3A08l_|7AI9mW3N7otE6@_U-hx=iYUq%t- zHG@ME7`Ds1vc5;8nM%qXn6?<4YSUjwwiw2c$AXr(q=3BK9?-PdCiW zDVI{zeBI<$fO6E%hY;%BQpBdo1ZpGN<7yDI-f$z}2#F#Z-1?dLLpoH5dZ(4!-7Noq zFjl&=+FENTeI>3Fud9`NUK7MXJ0)GUXBJ@rvtblgH8Z%Km{kY%20WYROgpeWEx)3) z6in4qhwtfydykO}t3|eQ_KR(5#<{J0_pYX?u)+lt$}pkLwo27E8zKy=GLsj!EyIhJ z`a0L~ZI(j9yRuknZ~~dJ4eg=2d$B*8A;?=$mD}uvuDRWO`v@^j_Dwhu^|ac^h7WRA zm{UH;C33E#`8yU|41@!accP1Q1Ep1mDj$Z-rJt_PI7U2ZayIf5#qF!KBOM^Fpm78jTKun}JTpQA|IKA5%nU__zwW|b54hF;yVrt1t&;O}{}|zfa##=KG`m484RF`gaoU z&zFglW~#U}WL1p`*{zZ?X$2H~>DLkpUS_Xb|2PKlWEzY!76LfUNocg{of1$26m)9t zO@4CL^_zaI#6nU`Upcr+jSBe>-!j^q_E(N1F8MWVo)@FLrX1#U;SR zTlTqO1{SsPlr6}de!py0a6FB(-3)a&Dxo<7K;?<;7u!(k5yn8-x>EDba8OI$3Hv67 z)78dZn>eS#{8ilqsfK5}GGqob6}L$0uTQ)5$~u=wF8dlo!Qnl-c4T=95Y^D9Em}@u zH!#jJ5!o)%B3bQ)MRI<(Y zy$zDdp?tBRZx(&Vd4;&a*3OTP_0FXtf8g^-(Fw$KPfN0H#skxMMHJq*7qDIY6sWBk zsE2l@tJ?DhdW}?cFO}-wGp|WVysm$5-E;Qk{p2Sw%}6N(lE9-sb|ip@8nKJ6YX7GK_F`pbI=ge z*toX@7FKyDJ9QNiRLM=TmN;DQdyo?Jd31|U??gs$Vwm)&7>pn-C-Nu%``P0Ga)Q)% zAnn9l8K312y5>YDhC+^k{O=(sQ5W1#L1D=zZ5vq2XwCeR`p-a+nssW=Mfy-$dRB^7r(LJLA z)i3m$L0emTmluzbROH0!=AL2za8F-8~|7+g7}i9G+kZ?k}WvTf58=* zb7F5#LdQ^Tt%_;n0BW!`ao<^WToAYFu5?L1MqPqK;IvF{@1ogaLo#?6e+P}$#sBkf z|4Pl;;pP*;$Kx}_<#D9oi~~3t9{09lWQUZAF5w__Ly(k7)lyHZNHBkkN!Wp?&};AT z>Hu^qEWVcESECFQMtUBnG`H@5v@K_cCq7G95h$bC*+h{yzINAz!bW9@>R;r({FdoN zJ_x3MR^q%4?s@)u6<6?`%GI$OP8SwT$vmt=41MNnguY69vA<#a1%pJ9Mwh6RuS<5J zx<#hc)t6>GfS1;N{xjPY_!L0-IbJ~1z=ZU!)QSmrT2`%+@&T;)>l!mB4{JMd*uOUGvnLeaI3c*=RrZOxB~u zTc%l&TLF);=SM#~#)6W&Uqbc`T~DP-e~W~@nDV9Sb*s4^2=oWiqWG0$+(+i53aP$@ z_e}rZW!sTSBK3GKhGOMKinZhp{V1lwX$oZXB4pgXmBaoq&El)zR@rOF-&8MfsozQt zsSu$_lLx4^xSLlGu<;>)yH(T-5u{jCAZFaGj*X}gTRPxNd`8FZ{1#0BL3XgW$zv)8 zJ!<*P_Ej1L?-g#Dxj3m(>?80NDl(3)6^fB`Fk=ClAavr~x(S%bzq55ksql$M30(%Fp)0&};wwpx4roCzbGX7g77;^S=<#ee9B?eo1!iFn7 zo3Vz>-HfepOAuDCb@q)7RQ#m+@oW_qtk-O_2$@V{W&#w6gp{_&@k6yjt^xc#W*_*< zLS2{zk{|0hLzwz!98`CIS3u5A%;#kRuQ_4Y8QBn%EGLAfZ@8lLouQLx|;h zdAPcg_c-`R2wk{7b!J(g@GZd3;I%OmevUE4q+6NYxe92?nb+QB9FTL72-z{eu6++a z0)c{3Ax~wlj80O)4+b1ZMuBIx>+RK3d@%~NRqi_9mo=L8o>9CW-`e=drFx^oYtu5N zjQr>qDwsmu%9ovG*aA}QKOn4V^E%$-Qz}WyhSU9p zMc6PBnXvF9xH0_}L~ZHE^{|mJ+dy)Gqt?LO>L8SV&&W*enjg?*vyIUyE)yR{bd`LU+4S2Ff-bp8Dpcc0aPo+oIVB&AJdif>pT zGw<}i*YMHI86(}2XQUhohZ~UoXOs$pt{vyN$XTBjGP)`H;(rH5-}$KU<%9{TlG;+Q z2$Pncixgpq&-zG-b&XQs+33%iGoBxH|K1_}F)P|;eZJiQ67UzG3uLFV&h$qrefd$I zl&rDauk>Zc!KPSo&vfuqJcx6C(T28$9nDsqL~kJ%q{LfTc3!#!l}!whxNoySDcKf}&i`~J6*>efDY%EeK?uN2YuGso{st^ab;oonz|9tzxJuk-93$W`#G$S`Pkn+hAodpW4OT=<* zaxdc2=ZTxH8>`H!#Mw{{z?ZW3C&%>nMC5Q@rBH70Gmor&qMjst0Iy^)nE=D2&EBO9-Exq#8K(j-0ODz*%|}3fwi!aN{*7o>s)*YP)MRt`ddy zU9I5Yh81pjjbg-I&H?J|nDwP1l=m!v0!zOapO-Y)Psq?NOj!D|vfSP_iRBbG#~gg>R%EAB9xf`xjogf%tM?WFW?o`7m$EF5TyvVj zqcW^Yyaw>83`8JF*m(Il3Q9khmx^JV|AZGMq73R4NIGaVMe4bFTI)FE!A`J5i4xzuR2gIjt2KNgmQKKXZo5 zV|k4yBy0yM#wmp;?alHxiig}YrEfq#zRO~_sD8T?+FphgnK?yn_^L754M`$8xel!W zR93o~S$=@4idWiTb|-7BUjzcgI+Mp`Z~!2f%1tRyUSd=QnG{Db3iiNA00>*@4Up2M zK!M@W^^gj$2osDn&77rbbW_37B&|Y0+i{b!yYfQx$2QOYiHD>>C7hD)^bpQ@9$#|{ zL7eiR*|L8u?qqm+F|zuhP>A5@-ur#bhl1j57e=gg4?D>A<0}immlg-rL?`JBToz*W zD8Xl5D5O@yZro-(KqdNi5KC&1Xt|tbRkKoH&WD9oM=;nV*Q;9&Z2fT$keX1WbJEB` zf0XB6SXXdEL%4oKuBFrZZ=GMxv?R>Nr!0`)9tBa3$i;eF#x~(7NtckxmLNZmtalSW!DKWIn0}z}bDRjRvItb0R6qfK1-Q=sNxH}M>)C(#=*W?Zuo>={i zGRBTct7`RWua}V*-f#C0$Fk{9e*Ut{yOtImuuRM50~+%*ife(^QHSMt9G#*B=ulxQ z8kY5JOTW?Dwq9T>`c%+z(wtK#pawv3WI0uO)3GS4$2s%LR;f8RlQeKXjU+C7tCUUR zBiVq;zQ7$mv&bTs^(H}&A3pellboNWOx5PiFEkNP=mIZmBu?{|d{H64uYh+0T{AS_9?#)|#F_>_5(7FFv2EZ54F9 ze4iI~IOg}Xs>3*Dk{7zMNe9&P+pX)C-J(Ihuk*{qy^ngN_G7 zV@?c|*XHfi68$B6SQi@g` z?m1wSm8gadnXIZF;NHyiTZks7p;T<6Y~x1)6e!anUNhHJ5fKMLaK~6l`o8^ZCz2tI!pbhs83{uOF}JZTU0HMt}&v`?D*OsPGTCu`ASR=@b^Fr4xn5Q{>m9@~3D zYYI|sd&Y0p)ncvctOYOEpe~oAJl0QZr(paC{pJe#1yH4+0d{bxXXt~A0I+mRLkaTLs*}E&bvCojT3)SnC%`ABd z&sF3dt5Z_?TMr%#??(z7vI0~%-&Iktnt4pP_xaMSIQ0Zd@%~D?$)L^3`}dETwMyOT zd~*ydRTz<9ppgD`y%eP}E0vL1L-LKmP(0i20p{hSU%<=I zk8h1dm$l6oO@GOsk++E^{Z3~PyC_(tjSDSLdc?&4ZhFm^2KLW_who~-h4zLN6>+0r zGtFdI5!wLQ2La}BDTPz2$I?$N)z`CU1#AWl5@P6Qy~W4Ux?1qy>W@^F&iL&xvbx0r zX~4e$rs)?EgXm#2?XY$^-j%U-e}uS4kkO#i)>!V)V7A_qyTxBpbFISc?U=L^K+*aW z>Pj3`!iqFFl)J4maKUyGo!GN-4&|J;CawJEEA+Pv)zh;Y+!qc!!lbI_08fIgIKka> zbal^$V!Y|&K}wKY8A%q zs0VGf%_%f`4TsG?=Regsdywbd)IU-_nhd$lFecI8nqhcUBb;*f;sJO|!{&zPZ~t0) zAOG^oV7Hh@}pc{?*ErQS@%9!_dZ$oK2>e;KgZP_ zoa{_cQ|h6a8BX|HGB&OiSCy-%fM30^%IR;Qh{M*%LqF6>L&`kNZHp%p$jXbvk3p1<@CmNr<(54c`qnbaHaM*UttXOpX5wvz5|3#_qhD!58>#ip| zUog~Pj~~J;IuTXA;TLS`_DA7J;0Un{i`)UYkVCjq7gOK#D`wJ9$H~-;O1|C^nmE(5 znmWoFD#;bYlHP;o;5=OI9^*e2WuK=fLR6h836`)E!Xm7lbjtSogBc9l_??$JfHx6E0g zaeIe~!F(fScqpP*N5TC!FV#sot97ph9z~Zt|C!!%4-IyjXhzd_3^DU#n+LwJmvf2H zza>o#suy6L>|}X9qi|c9o38e;xRn*NmKu2+LeyV6mdq(gW#ZZ{nw=Uhp`U--&(?CW z#@lXC7@l3TxAXy=gRU*qA#B4Tz4(Uvv7M?E5F=&H;T^XD8(WlS8rhih7a8F2z3SPU z7@)A$(NoNddfNBlp_~AY2hPfvIy+!Ak5>B6ih<7$)`?P@=%d%ezRIspkyjdfCQU<| zNHp|>RSVrYh>P|K1oxdEqaVB29`?}&028gRYK+Wn2J~=U3e)e2sLR(nQ$|T|8B_35 z;aLiH%h3>^xI6|S1*+&eTnBa;I9dA2B$xBsKdwZ%UD-+ov0#!eeQKAQWrS=SRDTp@c{x9T6-$ogU#znOE?I$^g zSRkT0P}{A~9%nK2TTC^))!;~ zCLay~RTCveor`r~*pn|i1Nw|ejNnPiq)vgghZ*pJHQEBYp$8~iPyITzL19R*lG|LU z{D6WE#`v@VPLnTCbj1v3zDj3_gzgc;f;9Cq^Y%i!s(q3j+K-+RN{nwMAx+9CZdEsY&wLl4)m47mZ z;f77sq=3&dzyvpOPrybvV8MCfBk|xosbuR3w>+RX$ZQIn{09VI-|t^0>##8QrYq;% zLxdtM*3(r(X&5#@Y52M|KyFB3k;?FFUP^Y{)DAvvp z>(wPISdH!{FvGbk!kt@+n%VyKWy;C}yR*;IogYoP9dpbtTnnU(f>t6mbvsl`4j4b= zL(4jdF>`*n|7<{)rEQL^zl!=c(%v>+&W9TeM6u8_NRC7@{FD6$56witL5$T9ITY~ z`FOOuJ^m4b6+@|G>U9`GIszh11su^`(^xZDihOh)I2E~M%nbyI$3DrWJIrZZ`IJa~ zUkyieGQd8ExewP_AR!(b?Df}R+xQiDoT{)ce0ZuGJz2Ln5YYE=MB$L>pcom>*p47i zWM|Ovb-RZjLj$NJ=9?1M_N*qr(Ch-B??MTjMdW>P(S&Cbk&F^9$jorUZbMKDBsabr zxU`!%psW)ECAF?c2fn$GwnO(Mb{s$i+ag9qyYyl~+}tChYz;MrFh#njfvnVFwRGEl zA4q$SE|Y{W{n2<$UyU znlso4$5X~MF}-v>5dt?w|2E11NWAd@35rJ_a<-Nd4K;aMktwElid^FC(&?*@_j_3Y8Zhx&?%qD$YBsBTSh9}ft;37k}D>p_^z;D2ZUL1b#qyu3nmsB z?v6epunyAxy7h>Hz`~tCT*Ckyg}mSzfw?Z%hJv zL}oE#8FJHof34kpdr7 z9aUs3Mei`r(Yq`%3kP*+Z1+OiFdKUf36miv5fDx^fj#{}EqrgMvf8bb7NpU8J1D+w za)`;jZrgtHx#be5Xx_6{0H+v!qTTN13pYWQij^h{U^DOr-IKde#H_*Ip#8=Um#hr& zG^uYS0)ektMTYs}w6huxXjW>=%>j3G<<@7<6K-b0@uY}p#G>#)kFy)rxV`XAO<6Xl zN9Lct*KfRsQGGhqvQzMVBVk#YfqV^BT?fh5>H(?F9pt+d_tmZ;Zfz{0+D053Z$ zbp3hYtvxPeyfsC`2KiQgU%q8G zyerFkP?=cj8wcgUfgWTvD{mG47lU2bi%{5?&3GLo;eLR=SN~T?gkneJ!Sa=(KtSVt zTYmK;SP~@>h@6*rKW4ZNLMC9c;JM(s)ca{X`lA$3g_jC#!vvqxZ5?WsKA^Y~8Uqj_ z{y=vLD9`0~0^UV)gKkd3r^p)O z>{vP$Apb>+GJm$DsN@_j<~nF~3`aAR&n6viCLD9PCmTjNRk!WobHc?w{T#uvUw{gH z9>XczVEloZg6r1DBNI+ygF@p;!eo1Ouw@?hL=$(|t~ZATRqZ$v?ip*=l7ag@vBg!= z(rzbFh7#Lv7^c?&!Qti7H^E=l+-+aN^wa`hU@ za7tZKspn^lEuTsbo!GHp+Rb)<+`hO0{9k%=%n~SJF2n_OZw|Z=88LwjlM^3x4aaHU zs|1t4OoKD_pt4#xwixUZ>#6K5FE%yA3@eJ%nQsqZ(~Ughvzof@g1Yi)Z6sZ`@7dz} zQu_{zj{a}&-}WgmlZW4IDYmKrx?1$SNsgh}Hw5odzYF!&|Gv6&jc7I34ae%gcz~lz zaa2SwMtJlB3TSwSoIev{K>Fw7ygH9IY)9IZMiwaEHxY{IX$PV@FlvWJ+T^!3=((c= zS=`a&5>ME$5xw`Rq?MFL|L9B>9oO!jx-o55jYT^&IUkV=i%T3B z0Ba87`I7NaA7pW>l)@CQ1)y5rxe*HgsjR)+q-&T~YMgX6j$X`5Le3>9y37d~-{9RM z&LYVAY1OARuk-OtEiNNL46J`l#A4XFS|>%f%NA`ii;e&n*aQ)bix~E}f&tnekP^sl zFp`LQtnh30ztd^qPp@dfv%ySYaehsSuL0HSFY!Bgw=C`$%>l^)ASC57x?9uT#&0u6 z(gUTwvO$hSq_3w!zw)ZES`J6H->;8?dWugKyX)EAMpvU_hrjzWGB;Hj#d|Q9i!adI zuHMNjKo$Mb(yVS?5^u+<9p|)9@`IJj35jCpR^JI*N!XE}DBwIZLDb)oXP218KoC^4 zLShjvKCh=UZ|v-_O{P)gYz zx{vnizjdWE7NV67_oPP#NF*gQz7C!_HCmdDht`i}`h&XLi!q@#ejhPk!&5))TefXM zUF1TYhqC)*Pc%-l4YJs_k)%g4$xv%izPK$s(9F>tspKYG zsuSDeMGd#WfV5fZ=5nlDpwZs53|tlLSW8`ys6AD{#dcPX&#AQw0==?i6bq!P-#Ud< z;HCqx>Z47wA1F#AjTD7ZpK77L@jPik;J**3llj;kGjwKxCJ2#k1;{he36lw~C)R~# z)B`Pbav_NvCLV5qk@Fq}k*zAzf$+whX zN!}QJb-qqWYb|2W0yf@rljk6Xsk-*-(CH>Qp|4CKRM5~`<_-OkJWhQk!dNW;t6vK&t9p-XZ59Dd{kmZ5UiFidPZ_NZ^XGU5Im>d;sU?%UrqboXu zNVFAHrhSes0{0j$EzYCd@x63XJnY97qK>#gbWYd3q~GtyMx|Bdl#%8fAmvG4BY47Qp! zEY0q4b#+7%vO1Wc{1TY6Vv=0(JNtYz8Yk#J$ohI$6*-CyZ}p&C#^R zF*_V#gV$`li@U}*F^5;QZrH~ky;QjK)_zrC0I~!J!UrplFvc)- z-imHmr4H`eS^R3T1UVIu#$Xu7xI?(G+^Ri#`iD>md`N<zO`$nsWGUiqJ<#X|Px8F8CX3RLqaX;ce%&hkk+oN3gxnssBrqe-BC{p0bc32~wvX zW*b-l5#$$cHS_(ZsHmrBFUg{5^civMr{BgjBR3GPgs@-Lw_%K@{3lkOigqqPuSyn{ z`Cpws_LXS4(YXYrHVBdb!wZUVYh6cLle~jSl3S#`oHlKiwD6K1Xh}6tKvOqJs zcn_(Bt~vksPE~@2-JyrE*3$i1%Sq}{2tJNgF|SVRY(E1bTJX7IEo06V zlV=cDYIsN`8dZ#TiA5c%sW#Z$Xd=4Ay_KS*(mVe zX!T0_&C|Xi2g88k2(G5YOj&dcMB?@5Epcqu-;*feGXTH;|k9?hcn`Ly~VB+(hhz9cwcc|c~>ee(5iG`tK-J9u@a8erZx?^n`ytUp%2aSNXXL?Mz7 z6`s{H8DrWXqq_L2j;g#vVL3)bj1Jd(m50GMW+tbG&9hMRST~sO+Pu z!Or4+Vo_cvDiva6nNF_C>XkhmKEKm9)~FuQ&EUQ`=4V>NtmH$9_o@rbTBt;t++dgy z#Un;1j*v*Y(mkf7Tk8ERzakW>I%Q^CFLmJvhrG*l&;sYF&=QDVucr>}0!}d?7L0(& zIP9?ILBObioC~1NTA7OI(s|#B!Ch1Os4A**lx%2D2!GZL+ zmo@NDhx*zv0dID!vwo8=Mh?`>WmcWg18QWVC8>_`~p+K>=Esa(z|G^KBV zf5;B``!uF^As168->W5khwRhiG9H3qPS$PVb}zk7IC|!=r8b~h_r=oo8xV;ZUFjXZ zkdfDd+*xR8y?fl&|L@Q9i^6Ej@kO#kLEC^G75Ee>r0$TQzmURwl^YT z8@!YEoPn?u0?4~V%&wjAUHzsMtRNXw-x~oV+3ow9MxiW)84@e?#c)>F@;V~Rb)i{O zQYN%A`?LUaX+*+UO29At!fDwPKv4x?)gt=w7(lrRB>n0gBYhJ#eUR`%GlH9r`0n?7 z!$+Z0erF}k!JdLT(&y(QGS+-x6b8v)Zr%PP%eyK6ksBKY@11rASquzeQ+LGHB!7?R zdS7xnP+RQy>zZ~f+onXHrpQs9;G>a3>xh4wN@##L{lo)plPVKL+NGRSFvI_Yjm&a> zd$Wzksww;umrAO0!(1JQ!K#tK2-Kz9TY_^&@RXeV>EMDs`M0;uLqkQ}FM(5IU2tKC#1h#X_Y zzhB$<+CR1OH?Jz^xwI$Gl&&~0T^@iP(@*3@?44h*)+g!EKd#k0-;{{?Z2duf#3LP4Rvq%zr8i79BvV_v!rrq`}Qs} z2()`vgYqz5`;mSU31wu}Q36}!0RGDzWbea|TXv=l{|T{lm1|VjgJp%oorF0N+8srS z&I2A_`lpL@OMFZ@G1jiN8_@lrU`ZltT+ey};GZ%L7`~o#s^A^i$|KrmR{oFqy}wh% z#c}jo5_od(dA?OMEoZPS=>DEvr@_I_0?{{aG|OK5XXgYXyc~-VJrSQ^T!Eo`9xe}g zL^OMeHtviqKDlzA?<6NW)oH2mY&vA-Ywhjm0|H@wN6HEe(uvyZiA+};H9+`wdymb} z4j=e=u#|0-g_bEAQ;Uw$F7t4uI!m-*7_q;w#};Sz4KyqI)ed@6vLqE=17jz;p3;;> z0o1G!B5b9MBGEgwl4|7q+#`-<7=wG>>qppFX_jcUz>A^|GCCioR0Vb-Bq+8*2&2pD z4o8s=@Lf%NA=7ZZqivuDPjeI!nY?mKF|YW1YrgoEZV!3Z%3h-0^^%v!vhQNh2JK&> zau2do_9Z?A_DWJ=d zqZYuQ3NJ`G7J(GE$pF%>5Hc$>r4(qqvk=^!)qNVV@jMV*`|?Kb&3aU1D%%Lq{zePK zWh&&Hz~Gfuc?BS6bln7ayMK|8C7jP96fxP`;D-3;SY~3rrR&oIt7S)O@Fn5RVD*S( zQf^WW&cGxHr}D9W>-J&>nQNw~Sk;T;%DT7E0|~v479${DU{5SDma5HS=tzmkuESCm z@=0LjsS2SVr&HadL09BYr3AplQ(9$vp@sXvF_JK!wX-RE0lTEzXy6*GCP6|_5huT7 z%&~-Qvl9I=g(H*_L%XewDop$#oY5t-o|t|Qrsrk80T8=+;ny4f3?HE5UC}rY`!9xT z7G^%O6*mR2{jp@f#lh-Mc?&5jnq0+Jb&zF64jP4bA9ADRU#o@j&lu*Kds2se?kKKQ zge-3uV+Z+Q zys$3%rT(r^pw-6wK1tzZryYH}9f_(R762^ccnBv5KMNQGjXb&_nRtG@f38axl)nmB zPWO?<*yV=Zop&!Eqj@8+kJ9gAeFS)i7$|q~6}}y-MVT?E;n!eX4A7e555bBa^nu%8 zJWKjK@ftOuEv}=D0OR-!i?`Ac=!0p@5dw72Tg1ZzjAPcHT|@2S>PvK2V1JA1n}d+d49xYNrng@QOXkC)xsO;R#qk3ueJS zvj9X!TPB#dMVYMNC5Y#=eiX!<4#@WNK}ZTnNJ2eHA{+VbAgY1iuUKN%LkLQBJsX2) zQC!#cPO}Dz(M8G;?f%acRJRS;)8QJylf=vwI(Yhck;Ah3=VM#N6YY1?b4G!;r*K&7P4)lc5m5w|4zlRPgg)9b}$@A!C8(?rWMIhrG-;$-5x zbyUhl!v%M}p6ss>#h)xtL67`4!B;AK^fH;`uAdGkZnX-=o`zx~oi4srK_w_86s#LL zU?LYBV)>`jwj#=Vki#k?k7Ec*&ftWRp`1##njV5Qn-9rj!b5D#MQAK&(D?XX1qpVDWOr7Mkx z;Brj&LLmPMdL`w?#eC*zMf}xobR!FvDOTQKXK%N8|6s<|0I*%yawXn(h(cGh`jq94 zvf`1UT*y*9%yjr@tvtTkv}oNy7Skvzg8N<>R<)$PhruEp~t zDljbnu^wH*cb=DW4a!A=ov6}*Xjbs%Dg=&4YSbW!73$B6qYu=ZY3m8nZYrsdhy%%d z82tDGCh`sGI7w96j)T2#YN5Y8)*6?yI!|E15+&<>PKMTe+Dycmx@?=mdXM zU$*ps74Kp?%7mNUc#@PY2>9E^%Uu3JbZM9@PX;=_$2xHv3#)_jWk7J?YZt@vd(0cm zbLnz3x!G_!7P7SLn9#0e6yr*j2?%JZEuGa?S1JYrIFslKc`HeaFmmWfTkABr!NqHD zyOk8EDw&sduy2bsRrAr4J#3e<iaoV$iWp@XJ zMNz2*b3AdKGyL?NL=(M9vDx^}c$$#WOJz#9@sSYjhcSNOx6^iN%5v$Z>Lf=Vv`AP8 zWLk2lW2+OR@pgEavsTu^i!+lKIzoksjeG;b!iyIsx~&5E1~b=we&Q5okM8BL~sL zqX!o8g2OxwiXM26E(auzDsxyeCF;S-4$9d+76$INc%Yz#>^-k3&-88P6S zYywD-Q9lmbO8&!hhJUsMot5ww$ClTw@mPf}@OAtYis_Id6TS+dU~jSl$GLMuZgv^~Fy9xeo=-UnUM7b*u*W_eP ztdoLYwQ_ch8Y5o%=!`P@`C=JaZLi$cF0-VR4)^^U>*eXlBXUJ`TkT6-H!;&^H~Qy? zlgE`k`}1ll#dK6ye`C$`iQ-*Sg=L2^%NwcR=0aAiUvD^Z^IAS!L9=IjO9jJOn@HNi;!uWU>rW%;Nmf&7@9lf&Xxm8 zhq`;?pOX_}=!V!f57@$+)?BYDrnD%y&90+EmfvAj@e4XDR+3!)wC>;IA?dPYR$w1N zxmKyg>Whr7{Hz_!DG0R|q-+63g2YNYJL2zl&xEvYs>CLxe3}bC?F4|mM8dO91sQgd?NE$nJ^}qHVp(4~a3C>=)iR`B zO%pw=Mj5U;nmuVnzws&gb(8XplIJ~nczML&~9}t9lL85I$ zmo-~9Zx;vr;`JCuQ~#qz6alznGL-yv!b>=*ElP(t0jIecJ@3Bz9|wL?q#(2JpM zjD-=+a%s&vP=)1)_wHQj4TQCn zk21%kjH4yVt)oI>{CqOs%FOB1;@Px@1xF#B)0Oey_tE$T*=K~J~U<6bxncST>){WHt=;D7l`WITpT$?tc1QW%mk{4nSNjlCt7(1aUqj&?QDw&iWb<_#~-wx@XSbf$X93xoV&b$#|O3{00xu`M^9>xSQ zUf>f?YfMmFPa=#+_syE&^$;a{osb+L){^8(Y2;LMOB#OnBLA~=GO!I^#n+xH0jN(@ zCtG;J5m0VDbBBU;N0`@>q-GnScHcc`l7A~4xDiuUs3(#rhR95E)V>jZ<3mj~SXl4p zv3IpH_1rHsVSj*EuY83bqcT0~>2^+M$8j}F9FW*G^#AxkndZV=lJMqz#NQNw$K$jg z>6B&PAFl(pJg6}BTt6`%_yiAu0<;|D;%ljb@B<6}xC*DC(E{~xBTsoF$YFZjYg58E zVZ_g!_R6(s-F@E4-5{=HY4m1bBz1 zCc`7qMb$2!g(`M>-*z9&3@<(ZBf<0yD4Y}gF?mrkvm;StIYWpdNuEk$j_x>A_dpARfz z(s-&))UM@5`Le8A7rd+?|7Kroo1FOlptMOI$yR{iwIU~((5M1YyevsTLqIwQsyDAb z(NCTQ(sBK)&n$%0LRM1I_*gx25}{;FrK}c701sjI9`*$4e!jy%I?UdoW!C>hdbA`g zOL4eHZj7UFvO475`%4Y=29s33b#e#p^Sv~-++%cF;lhqJvMiz}i&A9|0*ClDfPl)j zHUkwL;lul`7&?UWk;vl6SD_9ON1uA(XgsJ1=wpR}MmNUqoji7J0@|)$D+wQS|gQ~GTp}UjdP&~%kSJU4Z!ibC@t_gh0NVC ziG>*w2{awSzpDl2fdBwDN9A0oKV8S9WTtM2*h%*`k0_=8phZHsbGBL*b4bDV@#0+^APDg&d6Xy%{Q%I>K0+{m{9Z-I zgWwxNThEoIjFe9+1~w1uhMs79J(?{caQ6tpa^^Pa1%*b`kW-;}5<5+u!0fcybOAKX z;zcB*HGb+s>1v(n(eCqNqcC_^QCifL8e0r>lb*>+((wwa+8hv==RPC$m)>FA)stft zNx2c!NUk~pmljf!ns8a*4OgH_beGN+$-HKMef1D)i!Zn^rDQ1dMw`l^dO3AqO?BP( z;njdq+>uG%r`?;uk9xQrE#|@Qoy_Aa4LB!baB^P1)_LNh_;YD6nxvFTzv}9;3EIk|LGil$-;}a z?l9wi1`(FJ?eS8l!Yo^LI znJ^VXz9p20M+_?@qJI}2+H_x>8q?#U$JldV!7J`k9;!2%?@h2f@`&}4eP6HT)sU3# zfy`GgdH}%d8Cd#wnazHLw6z`Z`FA2S{-d0XAJ*&Y<69FP;Y)2RGcu-#$B+u2`9|z9 zE(ctY*V}fE(d+O#YUpv^xs=<|GoJq(`0QbDY{5XGR&`6HSO?eX{B(HOv$A&BFu(46 zmboo#(z=!pXGViR(so`&+vmqEk>rd+{QMXwX>z)P%u2ZxQ~_CV)3!Kb(pQD;$(ZA$*+S4ZQ)(OWCWHxp_ylb0U)t%zKzu7#2DbZr zx)69Lz_t%Tv~Z3hii&Uv$b4#hx@PL{LmBzO<$hs*i(6!&Yg}S8Ac${S&rtpF$bqEs z=f}vAmAj&MOJ?X$=k9~f-nZL6spPmvd@R7wVB*EV0i z2-DS-s=+nR;w=xpRAE(Al9jj?3Pqv&dWEcA*8;=|d$Ge_Z?!toAMXYyWhJ+V_aMZUnr&K5M-Z_XeCb)H_Oo85+<#UFkW;QR=Ej!JUGl5Q@ik0 zZoR>&*t~WgN zd({McMWw>44nEwfb>1G#5bQ~DHsXyiIRMJ%9u7yy0{~ofdmGdN#uFf4zJPLu3(AYl zu$DHYhI}y4E{BnqEe|Pc24PT3erSZ{)mnwcHBb6?llv)H&8KL(#KA4eUrVJ%v^gTr zHvMDB{bXx6W?icnoZ4gszS~Ym^5(#;kC($-37={ILSV4&vH_DW0c0N(9qR1X{gki~Q4&UQZ=@rw0$nE4=5Fp!CD}k~JB1x!cf2TvB09!e1EfKJ zI>`;g=r(J1NZr!P&&vHIqWAuC(_XuT8NT z4YCR|K<6ceS}r=wGA)FZ3|HJsTz+?aP%E4}nC+8qV3}H5r0wP3Lft#fS-RS;8^vVa zw+X9zDi(%^9rqT2#5)2c2J}CdmHsjzG?e0%t-4*^n5vp84B|EoM@eWDpyhPgwB!Qf zf#+U5^4YQ?s5R8KALZWXD&$(%Mz>Tx4~$-_D-r`nhH3I0)e*$EeXi@OCA1g6ccF+d zQ2)izoGP~c0N1hMAR?<9`!s@zGX$5jpwELy)tokpzvC+aM{3c!scaVlqIX-HgHWO` z7~!g9FDxDWv^&}8dS#^_Xq7k+G&hoBmsx)uY&%64(vi?;7Dr8FjU5TkqQy!+eWe#G zZD44`P&)aEyW~MN;;|OJ1oey;eoh>F<4TGP16U=boqvLWc#%|8zf+=8w_|YQv|Jn z`P((`ln9ZIO=^RdbZXuCm1kSL<>nE@wGtsHaGgc3NX7ZTyXF(7u(C!%y?;?~bop^p z5shIzUqY5ZB_A1yyU;Ynu=^Y&jrhd%F=iBXuwyv6RT`I$cS!&ZE}ZwXwVCbb(cfa+ zNb5T~s_+G+5<{^@pia{Y&-k61YyQlmmbilOAQ*__7B+u~!BbBLv(Q)J?g0cayi@7S zNBM5JODP~bND`S%tO+vpzyPC5kbw`N0P#)$0T3hr9>e&Dpa1_;7ybYKSO3eL-2Ijw ztu$x!bS)qM{Y?L(&HwHDrB6+Mfbr*Mze9}hX(#ub*4lqMc0d3Av%lWQ=SXMqu3E@X zT4UqD8itcs=>Fd&uzzoo*gv)~s36X)rmxZb;I_&&6dN|;gbU~cWx-aps)^uxy{u3k z80|wztQ?e2t@!~Sp3*YXA_(AQp#iAmdO`tI2VWsz)HIsENA~$GVP0rAZEE{2y|oBb zpdajZbyW(2>QPw6_`gT?`7ML{e3rro1Ks`PPabRdM`M1Cj2n%dZaMhim$O1pBpo~~ z*auqp^TTXGc|uzAIGRa3QKeB@PCDP-LrV0X*jO%s!u5gsBj2ap)%OJ*mftpy~8hLi6d$1l-w zRZi7`_Rj9@%*T)qs%0TPx)SziN(6)EvoW!_kQ(9HG3QR%H^NS2L}|1k(df4YCwn~_ zN=K`%8UR;%fB*p%7{+-<>OZ&1Y#;3AB>F$M$!s6n5za>V?`7MM0Bl~=o!Tr8VVE*4FuzCRg{G?9S{Ba*~%9wabV5~5o zSO;hOC0i!e=|<96?mMhl{r$nP!X6ovrjT0=fG^R3j2qxV9%!y=8V!WCi>N%|*jB}F zI+B$2YUnag734{0*ukXZ?gBUdUYJQMSW}T@mLP@0n9Ab_MpVpw_^=Bv!sNxZuvwU& zEwM<}1v8rO05gztr~sOITA46Q1a|TWtTJ*N`;O6(0KWEtD;HN3{(;&2IP8g{%F9El z2HE?h&t-XHN10f3+hCO_5E{`bm9W~#ClC5@EAM;FC^%81w{f8GlzP2k;hEl@(5W_S~Q&wh=J-@5xj2f2KglBTL*)nD8)KQQa>$wNM zcl))wy`$sx#=Y_CkqK?s-G7b3%$_tIQe~gSxRiDum;N?m)#L8rs9U2}u|&C)&fC4y z1X!IOw78uaQ_5l0g-qV%i#`-p(ix54p_PdrJz*q2g}2ho@TJLm3H`R8G|5zoN?JMI z#iz(tB@;IuBy9^|d~@C=ByWS0GK4QY>+}8MsV!Le$eg(HC#Zj*MiNq@sSRnVjg%vB z0;{im6*J|!mbhQErwhqx(f(hIHvE)r?ByQV#v%mqgl|sJQnkt4DmEleSzPQOci(*P zHZWCoMLG;J#cv?tEb-YMZ>^c2YEHxIBn$~-Zq(Z0>&7v}s^iBNd zVj1V=!z3u(%QrrC9L2fyEI|TI3qE-?$}FStC4sTxO4Z$1x_X~t#Zl0ZBqpN^^SbT= zYmLmhsG@8xGQi25BJprB&zz%zW)Ew60f=$Hu9+{a1^DUR*AAXd#c(jWV8RWno>1t6 zj2=5ROHFc@i^p0KmXBo!+P4U%0RQSeZ%)#~RzBFP;~X)yWuRjDa5etf%W}Wf@N!kj zf_L7BV+Tn{^P68d1E0-sggq?0_UQ`j?w0N!1NzD$@Wj7k#g-pWV!hi=IAjEk6yknQ zhq0VtlqWvY6gAp2Ci>lpzf4p^GH*1K{7QI;LisT_28_}cSFYB3cb^&it&WJ4+=53%%;Ar8zV(sDQou$pNX)}C z@h`&eRm>RZDajkZ7p+DSK@Rk&6B_C6If&F-6;#-668(OpVT=ZPeikaxBB0^?&K4V- zT0TWA^1WEElC~+wA6eBIh)TZ+)6IxJ?_~ojIU5fDN&Od;Jot1h(th=nzgba3l?a5;eA}cFi$xPrf;DTZ0Us2Jm8sWw2pXEK6$MuxBDIse=N^-HbvveRO+j2C#}8SKx9B^Qy^nEemuk5haM(-jgT=>JO%Q!( z**4%KPif}u%#Mu=M_!$C;2{yM{WX~4?a20X^_tgn&<2SUl=yi(xjeK@+cOe z&3hWd;)!J>sgnDlP-o!;S)N?n4_7BJ8XK(R7ryB0-LSYJ|v`VT${ z;Bmw~jP_HoSst@TCC1eRw;lWBOobA5*fIwP!$T(K+Ao4U5v%8|LI<)NhoNRTLSL9H zoJmhq`KGdhuw~kxu4urQ-xF}#Edcnu7E}0vNzT9P;2e_e>X;T{#85y*onnPGR*gkD zl2GJAziMcZP5?~W!(7%SGZLk443%dv?Jx!CsT{6?ICLearutllD~O2tgY}o2`Q+ka7>wJWtx2a6$;}gkoe|V%BCj z(ea<6VcUfBQkq}CgiaTmOOaUPp^;=40w~cxt@^&0XvibBqB8}+U+6-VK1`H_x%C-5 zyT~`i$7q}$lup4lN$}s+<4yYw{N+IX4_euA4^~~(>B1gqs-=fx;FkVR#i50HN#^J zO&4%AM~AdlS2GMNbbxaVf|{`2+Y6Kq=5y6{Xgh_k)3!}54i#G%DPRk~w2egJULG#4v@gk2TbCi&bK=`U=^+eY>GBB2 zS3c)jZ{Z-OqAAM38A3h;xp2Ul?n(P(mUnASjDH;N+fOQl^+aLK6=uLJ2LiaSTs{d)nap*(%k)uU>-HB8p?>8(n9?|ukun+Yaa$;YnLGhNGQc20TLJlJ$sG@ zNL&tU1zBZld|XiPOrGIciW*OD^XQ+j_9xX4u4)Cp8XnH>4tSo8bJ$bhgh7A_tN_^; zIl#+6k&cK%&R08_2SoG;WUy|5KsP|38=zz4e*W!UIN4$8)WMb>eG8QUUR^xn?~}q{ zx8Hx?C-I?9ds(BNLEtO(4qvXrrvW5nSSnVB&OH0JJe!L2&TO2tK;bENiN|3 zq^3wZWY9F796(OOEf#QluVYfPJ!k+qg)ndC_~q zgT{E3dm36V5U~q(Xfc@a(76+UWf4iuzfQ4Rz?s7L z(Z0QAc)CqYEoZ%IXc+N9=%Fs9GY^t?iBw?+31^Apv&8Y);-rkDy%~5_+lW5Tgmc27 zW7ceY{EP1OyW>T`M8-IWX#QC7P%bnbsA}X&6REFalkV;~*pWmfw{dFewOli!Sso$x z5OGh9Q&s{7OnEfwr~$iNgK{AE)=?$+%N8_r-m9SQ9Z;4b|nSaRv;A0fo}245z%9=>{D(WyGPe z5HSV$JGjGpD&=25@I!eTJz!&j8APXRA6nLTb+c9)mevDhiS^wUJLxA2wejK^yYIj6 zlfq!PW!4vTZFCsV319*=@jP~To;yPMJPF@dY+7}6TA%@*O`#3rg)}Ut1LhHza{3eZ zcnosuEO;8_pTlZve)5T?m-ex{Mh((Ah2PB`aj1b<7Z;(|sMFovPI*;7#_^bh^ib-S zhLnp?+~+l+i{;9kqS*#7qSzTX8+rdF7_1mxf6>!Y9BT1+K9<=Q@)Ak-_3G<5hJxet z!ES+tz6t7zWKT-la7vQEA$rBD-ViO>X>$~UeDp|-prf?%!-r8|X%~f>Gh@p0XP<$R zaMAyE#(17~i^tV;b?& zO*k&$>aSbZ1{*Z4;VCTGd0PYHyb~}G@%|#D{pZy|1~1w^1IdA;(w?_@H|>k*A6!I2 zhSP5njz?itEw;Z9?l3Q!scorYw4 zCx5~4L(teqzoJX#3)3;~PXE z)^4^YHJn?*|GJ7;(ux4~*4%fK?-%K+?48tOH0TCurW=}oS2dA<0C(u|K0Rhg|ZLT> zH+awAI08J=akWN`euhN(wFBb$DVp)UYY-f2(`qwMeYJhf3h@mJk?E>^LHL%MOa^j_ zS^&H$DWCe0z(o55o?65aX#(Accn80b-JXV?*!6h}YS`+&Eq*VzWucB)^Omi_B82LZ zG{cryP5Rq3)G>G?N9>!IpDjC4G_BYv00na@+3DTuxCJ>@Y1Ef=te!E{b+INtdyllb$NxZ`Nnd8aDzvib zZiW;wDfr_jrKohiIK;6$-1z+33#h|b{~3YkU)Smm@C-5sdwEmIK9h$e7^@vK)9uF2 zn*sxLnj3qP_+&1}!T2-Sj41!S^&G(nX`kd|Z)rj(rigzv0AG6o*Xb}7*jt65?4f1) zp}Uii42st~^b~~SaP1x=RBreg=rE%Oh$H}8qSPJO$ME07i~%9==YD;@j3+BDCkd5f zj_GvFwwO}<95pIjspw68X(bd{$mJwlA$7)_{RbjO_B5n3?OhJ2sH6(MVSOU2Zgyw$VGQzaUYlo)aN=}r zHT`n#yAOdjQE3!^yxE35JeH+=N(8%gL9Cj%7b?fUxY$u9Y$7>a5}e0bPTB%q-ldg2 zBaodg5UTF9hplUuF9ZR1RW=O?Qd%wq2)kEEprjkFnP+beJ1x(W*EU=xDtk%-ExzaAXgwZc?IUyFJtxN(@hWLFHLNJrB zeyz0}3%jhSe$}kHSV55;n?-H%s`uO!q80ij`C?aOCOuiF0UW%uZQ$$lXbGs^N#Rz? zqjrw&4F-zX!iE5bwU^BF6457<)B8z~gFEDXFnae~+Ug$LWaonzl%jb(;7=ikja4JRRUA>DFE?mu7hku+1 zXXtK|*-{QwVo{%h?xo`ELFfpqebb)}d_gf8*L+1!`k>lw z+~&}g35j$$31kUOW*FYMEsPB_jIWV(ZH<39eldR$dNxDp9TR}lf6o&bwxkr;t*;CU z810gwE8foF?lnGj@w}2~Uv;pC9cEMk;8k<%*4-Y$N?7myE?+&;LLqdxN1g0^5VjVj zRI(islJwv}s^@>vs^$~qOpkYGgV|tyf?Z*cZYm?fwCv9UATo zD}Nh!FBj13kZSA%UzmKzcEdcsDvmiTJV)(xOROgFTCN_#xi3FvYn{MP4nq{TMZ9{R z(9tnsN1C$0mb_hS%6JDL15U7?&{XsAWako`xM1c^d<6z&M0;K4Jm}Hh_K5N1xk+wi z9U$HYrOyEjQ7Dv*2ivLEy-N)RXA$t=AWw_zhIfR-W3tlicr2K3I^&$G-+R}4;^MG9 zh+!MthZ>@lB)mnwXF1kUvuf&SUwOsWd&P#3R)?|_W~!8e!8%}57?Ryuue+8qZ6_oi z=D-N5CKvkKqM5^Zx)n|*7KtPA=5artebdq zF%UD$pV|94l0{V@KjU8G4JG{wIL>bc(ab2_D4#E+rgxJeK~-g{uXxhYzoS?K&ytB4 ziitL$cN8@(er!AnX232)ke(H#8YuY!G-$OpG6-EOkaA2djw2czF_yosRwpy(D!m6? zBLqB?=i}-|Uk6*%8vIMR-LFP`<0;m4!Mtv zc=!{Duolylu4ZC+aNQiw!hvj0n2P5Q+hQI5i@10EB%yqKg>!!C;v9R$>!XL)-EV~f zYl(Dz{s?GuS&NAtH^H?ADbNwS7t+ozn8;{)%Gx$`u3Ud!otf_0aV9z^BAQXV-hclM zUjtBqS`oZX`|1ig4T4rr4b)Q-d1Y1}hV_r37o!A3w~i}>yZn`NPr63YI9;M#Q&=>; zBPji!tB!_?k;2&_0&hKDZRrCsuruo5gBp#l8;JAF& znxrxjR)mr`%7VTLb zZ8&A>9Hp!EK}NmXWj;((4xC+@9c zTfx;CsXPhn#j!W)F%fdDtnglg5LWw2brUdl znPYP-kTkZuE*?4PEcRm*&IxnDb*+vDMz}q2KW@z2$5{iQ#JW4KS=8s`VK9dL2PldE zg9;<7rM&#ZQ(TU%B`hka1Iw{b=uqj4>Sx6XBwZYv(LA-y?4lLZ(9E$wy2a*)4!%0< zN2KZ4{UP76Z~V4RAnR?l1CicgTd6z;ZVst>l;R5on9fbLd1&|6@W>@K+ELC7g3H7M zJ97J|ovB^1b(pbpUcvH?FmnA)Bmd-^^sxT9A1; zAHW-Sg|m1kfjIQEG0=v#eKtsB2+>^Unu5fKt;K?QjV%;Pv!^4 zc6ay!_08W2KqO{?&GP)HAq4NoM@t?z1N}^|9<1u;HI?gB^is<`x?mRHvk+t*R8Pv3 zTbN!DTcLCCA^iKf&gd9T!26YWSKP?aN0I**9{qm-2`fHVnGv%OJ1|iaKDDk;Um;)PbyXP`yhhYc?570 zd4IUUu_iC32eQ;$M1oHkN=>n(9lZI7W7p9M*)M6u^FV>?z|2N zI;rgR9=5IMZc z7Ts6@o6m8V$N_h8DYERuZizio1E=hEJSq?Yo+%_%SGPmDN_C47bBh%51MnE%ApC>I zp+;9qtS8eQL*sB4E+EM>pTP@xR+h?tG$Ojwj7;;~RY*w)!xjL3K!Lw9bRGQ+zlHy; zY(85^@xnhjwb3kQKjz>+feSzJIAR)wDoGO4iEPA(pm{GI@FNA@Lbk*xv{=JEj+$LZ zbnI*~TH#-eiF6jEY+C`b`m$f?s{f@)w51P!mTdd`%-|XwBs?>4E~>c>0lfX`AXgaC z(_EE>a&Z!DE3R4z8o5U<)0zd8%1kd*9O>}iniSeOjpF0_)!G23 zle(5Y*zfscZGP&N9Fv`N<`5u;K&2B4Er_Xqa$y!>YS=Px(FoM zqi(mLt6!}7?StK4FIEpQ3yLj9!)$lHDq-88lLe#tL0=L2NHt@ClyQ(zXb0J^Y{2F* zU0zPdGGnlXAa?rH^L~r4`>UUn*!8Ddbx4OXmmNaPjn8~ck3342m=eVFvA>z1NLTb0 zFGPdxTD6CQklDjLVmFgd*QyN6^11&$Elz|40&rwl3%3_-Vo-d^E#ZB!Idd2B;)Rd)brYtX%@pgKsP8WwA}(L@%}&fxKpJzd6qo;^zr%G-wKhb?9>f?Bo;ba(7tQzliV+M`6p- zP9i6RO^MHB%{`6HremX8x@`Ai@vkzlCwJMjD&y9!Ln$8>ZtW=>IanZ>uWI~^y~nXP zT_d1&g$H?=0(z4a&w6_z>&0<-QUFN?r#tw&D7WisA%m)Y#utCb64h3CoD>0kx#}JM$YJr}|YM1kP{JI|cmUM`-mh@2yzGH07 z>|a*IK?Uc9m1(}~`e;zkDDb+(WiSY1+&hu&eaKF!#i8-IP#}5`<@7#5?hzh`4acAy zqJ!_!cNXWbe$F0FlHq#Ws~oD-$z`+3vd0bQ{#e7~wAwqKr157rCQlDcxYbh>-@3@^ zXBL3H5IWj+dZveS)G`j6&41eoeoEPiOaKhtFUN}_f9!nHP=xjN`#<%FU&24Aj|)(B zos$(cI;VWNh{nGRK|_W~UL}znN7lIXd}r-3oE+ECSpB(`d$kDN^2cw;TMZ*- zcL*=K{CEL6GS{)G78g8rgn#&bCdbr?%<7T%>YTF%Q_!*yoIBR zz8Pp)l&4z>mFa~*KvfHbA$3vR*qYo4zq%pXq9(ujai(?Xf#6Ol^lcjpS8|Z1&VT)y z*=fzPsnZVu&4C<9By^rWbW(S#2CrRur%J(3mbLk2_>KfEZTB4$^P-SMvEnGnn)d`Gb!OPh2tloLI(C7X%wRTYfG3!lI>W^` z2&l-5mIa8Rsw(F2q^x0-uNsWnuY2#R7WKENJJA1q#FIKC^eZv97i19}^7odcn-vg= z4$wJT)$0qENA=w%Eo=&)ScH}o@9XwWDbZKLk!xA+EEg@a%N5ed12(m=S^iS`=fNw= zTvBF$`$6Yvovk`ytiRarep#ySo08*Jv)Ctv>#-VGQ}=u>+vdQfo)Wh?%eECe6y7Bdn%Tas;2;C|v!Ucq`Uby@&tl(!BQWiZ;2uw)9o|JFQ#bvF!J@VtIHHQxwq>d3H1I96 zeHj|v+Vy5Q~)YDv0%6rio`Bs;U?={9FwN$ri?l6a{2plG{Ne=S~3w6;(ZD)`zL5YtO zgUGhpOjFa?CjOx=Pwq*7te>Zzbf#)vC3r>S}XjrvJO?*)P?%wLYt4r zxr+8a3{wU1=C%zX=P8featC(jT3EjD{E$11{rKxrE#jAF60RL9@FjZGElo9W2^9;>c$-@VMD4OM`#_Ed_>dD@}L&lT~F<&0Ozg0Yxo{l!l> z$f2Va>&*gB*{%4e=zOu(U|RcZ#Z~`O*9lt0t%zP4W8ykaX$M`)qxD}E+kOfbk)w?I z{fA*PfDCpbpGAMsJ3I(6-)}%eL9TZ*GY-{#67A&i+}0`|0IbuV=kda4uxY9~E~N)S zAv(5Br<6^U9s~tHS=THon}Dlf6r`V>jvUM}jKW=EZl=iID!6HL`U=ft$MNyBR-pg}DH%wHTyxH~-P@HRZ!r1Il zoUVP{@H-{UTHzJs16+gEiw6o~SRDtz)Js>~+0ScUj#ldfLFfNX`Ffn|vr09`X*TtJ z8j5QW+c%7C4-#?XhRQPK&A)vC2JZlKJl-n33T18uV@i&CQ9Y64l*mL}iy|VkkfF(=epDi6x z4eqL9RI=$IeMht*L0>58U@xd>L+M~E?BZSaevWAWx;1qYqEaDmh{m5o;-kP2bv#=e z6y)7M*RS$RBK!$xnD>7i?|H3l6sD3p`TLLsNz(^T9+;j@y5Zs5%J49miH@bsLLWx5 z7NhKg-RD9$^#(O5GQptZjS7L{YmtDsVO14z+kV(0MD5l*P-#@bJ~0C+SotJm-3i=e6`8J7|qMnZc3 zGS)jt@;BWCc)CN}AA@^;R&l{D`S~IX>rzhG3t@YvIqC0<9rHyxBOq8L=CInLrz~aCY*;-1SVV3* zWM+*!b)DUU4)~0x4rDI+(w%ogR`DBXZ@KZUPB(J(`wSd_9fWy|yn?w<5jsZQ(4lxI z2+Pip*LktSeX-ATzxq2A4!zS_k0KQ{y3*|Y7>x;>4T=&Ngp>(sEAA&e><6b^G3qU%_O+eTG%_xkyX!f~lbdW;d| zs=r@!4)UdRON!p~h*D9AX~`YbTs-P?6E!ye@nY1fNCB%{AgUO1={czdS+f8-5NYCf znnX+&1FrVboaIw~Toh8?9bb0i!=ypS+@ZE+1h(;QaIHTOQt$`1T|hJ?mH`6}jcAUh z{2Mv}R78OmFmlCRVsTiFDD~qir24zinpPo;y(_ObileRVJE+f#FsvPM)JQ;~#iJ)9 z8lLF9eRgQh6yazxsOh_`4LWI4f5V<)636= z9DyV$zmP$YXto9Mp(>5S#Aops;y>)-Oz&&%S`+{FQ76ckRqq85VW>;)L-|bkTiMx{ zl0WPwnm_2Vj{9lB_ILvC8vgbL12E!)inf1a90KiSGu5+jAf@aX1oG=UiJ+UFKyBu~ z@zXhwlS8!LL^K3OOOFa-=feNmHX=IARGXMFHukQU;=UadTE=t=7@TLR4tpXfc~I0J zFnZ^Y$B-(}H+{(`_>f+MfAxljP4A=aVzJ(37%g_nt0fcD)b>I7&;{5aqCXfj%0s?L z;ju9NaHQYLG<8s6s#U{-jN}yVL;XA>4wf*POTt;mk)5cKf!g7 z5ey#UU^T~b#vu$HiF%1tVov=-+O@?BKZ@)M&MYe$t_X_lf&dj8$;*pid>)s_l4Z6k zET^XfMRl52R|;1)j+TN~7KYGL&!ss6Y!`;iz7l%<>e9vkn00ZIK6C4}B9qw+6|NIl z){%h>h#RYh^iA1P=qe%MzwK2eL)Fu^dbp^mgu+!``7?@7P29xcT;5uUW+*N~6t8t; zLi)sIFbNhb!@s)Ga_>y*3LTVOxWZ2hi(aeX6X#YeGD!%+LkX)#LR?`d{;d11kXF3m zgm2i-0F)ovsxR60yN~padWVZAuakDCC+Y_)*I>R$*Cgv%iLJKFF~gc{{J)P;IVaN2 z0l19&Eu}{Jt6oRGqmv`2Uv?jt0*~$0q!{5ma7*A@!Af5cg&5hGK4GF4vRvrjQ(M2J zHvIyhw|`Fu2DU1&bWapG#0)Vn`DIu-D-46|=9%p1#CS<%g^DRu0AjMSXjP?b(s4*2 zbM*DHRMF1t`$IKSEwA!CQtm!OUTC><{d1VHyA8t==hrA-Cb{^+*jspY$|9!>y8I~K zLfOUUgN#&}pEO~k9lO;FLGw86NJt;%*ml>WQC6JJuFDWBoRcm6ViZ{w&4aJ`y#L7OB8I-fGf8vD`DC3z{{N2*?}CJ_<|fJ{K$|%i~9B z#0Gzx5wqMJKL%N9OWkx#s94LfQ~M;@R7D^1!~}AXcWl3AK-_kfh3gvK@%JSIW!v-r z^yz7;ictSg8YOq`uQjnr5|O;`VlVzxU{>tH(y@%dY*p>8=P5JN1-F=LUWEHk=~^S! z^@K~t74~`OzM%7v$jDpWVVUlP9Wn(Y6TV)8D|c)+`vh*`sp-ao*9?}NBr7ge^d%OH z0S4gG)Oyge(pryN7Fr+9aXp?6%I(++EG}Xcw}m^_c!f(mpcql}%#nYw&$zT-bPCdH zo*F`2NuVq)jyB7-g6fT8;NN9d-sl6_&^Y}0dgBXen7nbV+y4Ve&an;H347Pd1sLzk zUkwEi(sds=xCy0V{VQN27J%3={%%OQ#g-A~D9?x6W>C_$qv)rIwbUBdP9_g#!s7Ze zE?q@&6}~iJh6}F!Hf@*YU>y#mO1jabH&u9^u!CYawy_A_pFP7SDj>9kHdk#BPb%+m z7d3C6nq1^As_%kOg;1is{F*Ed+2Sy)1sdDa$So%x^PbKD(MXUQz&Flr48S6y^k2e9 zMl|;JmYmyFb0pJeNEnh@-_5#y-ws{ja4`+i$#p5rWfkJ-zuUBWbrjNQH8qIM_5Gp? z25p0h<-3_6#k+duqug35n-6NfDq8#O^bLFxR=aW{Iew4{&?7bq<*BKTIZ zL)5oN>kXB@rSNqLma@(|$r9tuS9j#o$=Zf6urE``4wqF<<~?h*Tzhqqd4IH!Ha$LI z^jI9y@7fe|%y09|v?_T`$OQ0bx|jW?>qLJ8qPb{<(Q7QZ-L&EX^cQ$z6n4TJEqKE{ z3^Ozdkqj`WS8bd2^cgwp!pM2iUgYcWhNSP&DI|2J+Ii1_=PwF* zK)DZivHtPtiNyr2PY7r)HafhMLtlUH36p#hFXOElbst8RgMIKQ0eu5ebWqh{2Wn5w zk3ApA)?MnKOIIw_rPdmLM-&EicuU&TF`&K8-eRH-t@strLWcA$6LI@Xn0=|&Od$9U za3~TtwtEuXIKu@?oSIE??0R@UpGYzi<33q#;X(*9JJob>DU;fE8T&oHS)1p*Uz@iE zOtXD8YGB4tox776zDz^p)!y;Q%9LdapSWF8Fx2fR6%{YEsVIT9FFu3j(O|n3SavK&3I1C z%V#lQ=w&;ENp#FpUcZ|`uli<+zdDA7(!lM4Tbr4@ds`J>N~D2A;ED9g1eykcBrIxG zi9`$rz^q~MC4UCM<2!D&`mwyH@u?R^VgbN{A(I~z(}q#5bXqx#^@5zQ+cB)UK%$*& zkog>M+Jp>Evwm&ovH|C?Y<_N{V1DUFfQ7~sU|rJwspCchi)G)G!0kbA`YgZq!;7Zv z+PCC%NrHG&&XsD-AF_FA=0D2UqQnloipvNeL(bm}QNrxQKb7FeMdvXbqI!@@Y1_VX zCjU~l3y8;z@~eZW2PzP=Y7QuF_=b`jKF>Kf+oNd!TuYXF`Fa)?4~Pclei%F0>g5%E zl0y0my3|OW7pav`%+2ks#Y?BA+ZMJXm4`m%w@KGE5ptCqdJ8^gvgOp2?rl%?l}8$Q z`6<^xy51kpARAGqHM%Ul;qcfEiN3-iq9n7Q-Z8xC8dzH($TnBG(JofU_&`eWP)EDs zgVG*e1nu-eV|HY&>cdrzL9 zYbyZ8Ae!74vq(EMB)Uh_O4|K%%~XfM9n8EVHj>h?eTPrf_32C+rP*tS2ca$AMc%TFeotvE^Pfr zGOUa)fn>177st-~3*{LmxI<%1IsM_pa<p(`JImPf!PxB`8zt z(3Rf@WTZ9nHD6-sE;$KzsO|6Y*I^y!G~m(d9yN*3#?9i-C`qY++GKTRS_|4cd>>VJ z>0u76c*AyO7f3CX;Fl#YiHETl1DV}JMD>Kutun@*P9QB0WzSf$N;Lq~WXCgH0st!R z<5a#F&DPL$BMVvdrRPu@u-~oAqRf5^f=+vR>7jH;W)K`jF}KW-`K8GOwod1+$GNQ? z=&zO7jI3k~Z2luIYh~PkbvtW;`jWchU}ic<=<7vQ%}*F(P4b;Q3d2KuoNLf<+W-2f zcfTl+G{DR8R6ieSt}VtVWdAi+ShRK-l;}OKC(`aH`7RV#vOyhQiQKL z;)r3GX%?`F4v zJQi6)ki|>XX|ZR?n5LD9-&s~gX>D>>JR^E~roC&{50*B1t|D~xaF;eIF=H%Kn~6== z2)q1-!%p$pY}QmLwyDxVyszcED>uQ9v^Jp>qnbF>p{Y1Cq&>|_M2a_$GqCGkrCa{w zCC0u|o)aFL>L(lAtOU_2k2M<#8w}BTW2`dRO!?&%ez|>{R?PwL{rWk`=7E*cLW?ny z1z_KBn&myfSSySNIU2tBp^FAJ%oAypn+0gqYrrmE913*-z-T-dk#Fb5ZXayyx)2N(>0UE1a(KaThytL_l*vptW zZC9MDQ#f<*?n-EZp0$#BAhQQ;H4Y+DgU3(&v#^d(=iJD0ug<^f z;@Ls=FqR|C%ffcQdnIUDOD%k4i)^5EX??e(O+Tn-gBSn+`1?PD{bT;=Mkx<*9S7@d zxrpf{?_z?bLF`B*`gSw;NdE6?ZH4^01kMo&J71Jr7$U{+HPYLS{)7SQZ66=*7l^+U z$pBe+Xsa;^(15B;+S`)@9B64B#XXlO3-~#%en)^s6bHCIGGm0Bj@lKyRs(E;O-S>; zMz52k!=?kSicsh#Sh&jkkuBPtk4JBtE}`czpK$^}tcLY0$q6vBjNZC9HCDDzuNKU* z`AD{fwXg$U((1dy$phY!o;Ri7Ry;Ql`XXA{|3P8?%?XzKU)gYiIAeBHS*0ChW5LtJ z&@SJ)ydP_o>*rsGO*bJ*ujoCemCZw#`|9^{A73Pk5wQan%?tHU zLeTYfpTwQmbilgi@5X z!}{IdwMSaTMG#Hf;Z5R0HaSS|F06V6U>HSVy%=Gj^( zxSyI*0XAZ-45eeAgLqL7kkjH%imC*$11*Hx`*Zm#sqEI=G^U9GSBL3bd7!d>mL6in z5o(V3vRMhTM=!hn_?^Mb494LpV{q9lwJbPz(8SZ&_jG?eY4JSs8S0EZ`vn-^u~AZT zbS5ga4b2WLtD~Z9P-A@a7+Ln@C41?k2+UIjsUMUUn;5esCw%l_l-z9|;(OIXS9Y0G4VVjh>yU5N`fedPXJDb zgyk0!4@C{d?SRqBwYjzQ-mGWVvr^HtD`J{+abGm~C1_k$@%*0!z|D6HEb!2I|DkpP zo1+?6vFD-%WG9Y=SVyR#%pm1{7j*RRvsikeNQ=a>Yae5T1loezf<&i4lKMV&C9~4ZHFIFhY6HKT}hQ z>~3o6j`QF-`OH#;R6Y5-?3$qbnfQTUAltxeP+PsLnoi9Yn?eZB+;+M^8o112<_im8 zd{Awo;djR{@79wqqmVqkjxY*Wf=L0cTQ#qkbC_-_b;#d*GvY!sc{Q%p`%BeY*I@?U zRjf3Z3lME3(`8q0Ugp$vRl~=A0+8e1KdpRy!A;eYW_E3eiWfz85=|`t1N9y3^=c1& zY{Dqc_?`z=dY7`tnYeIrqYGM3)K-sim962&qwx6uT#ws9OGvW=(k?GOg`P};KJE02 z;b@zv?S-nw@{E_&4lzDEQ`SqLd_+A2Ld3}BM=Z9^zYS~{m*xlBodb7zRDU^|-oSJz zRW`mqigpc39t&;>iB*_H2tm+NwfizmdPWzv~qklvumrSvMiRaZSMD49G`O+4iKyg0pv~}8-x~dL`4x< zpiQl(Lm!$qf##T+r`Kd9NyMW6Ua$qsu`6D9kDs-%97^#Nh_P)}PtE?2omj>j69sK~ zwOKeXPad)BkWLYoz48yOK!RPfifQrHFEI$*xbW5!*9Pm=VY2+`-e0LU) zxdXXxi@$L16&Iclc1~i_12%9u{7Z9!C{rwYfahd2lQgkv{pR7Z9c=DQNy7$U)1ed1 zPPfM$%LG^1cwuHArLSA>ncb2p+FEK@a2J(p7z=%j&|j)Eq|YgqR7%oKz9CyQfcq zt!pOtMTs>6dIv|ecu3KLmqTK@9ijd&5C6%^b9RT@5JAKKuH5aVL2?hRrvn-ljbBw% zpTM0z93G;#^qc@^EJgl%25dmjtAb=U$g(DnfkHQL5QeluO(Zn~(1+W&u*~o!hFf&F z=D;*DiNnroP0@wdHu>`83n-DXru4UA?DdhQWwW7?&>J<&p_K>Jg`lU2)22?lF8PH^>QG$kaf;>xTVvea+h2A@Z+dm$4ym1uwf+Kc6OjcI-n&W!b5u`wZCww`C)mT0;3%{=L}^UP))$r~bExb6 zw`$|s=-g8ZdjTohvWIo5k0ztks8?iP3cB4Of^HC=ugIchw~7oMAg3a^vRZM`jp~Rz zv7|IGr(<7Wg{0}=u@j+?pf>=H3Azi^7oRe!i_t?K3XNG5X%YWvz?5ya>ZjEeU=#mZ zHF_d!W6R3e5soKJgiLu=iK}iRHP~;p;X5B9oxU7e(d|k8ypgHOWWp?YmEY%21} zu|EV`pExaU2S`-Q#3{T=I&{&Vc4QH;-kk&+PLP@%4nu=nAj;n6324uRZhNH^2han4 z7L_3&n-m5c#>@DODKX&MqCr62qG?$>nXa%=HT3T<7=RX^`cH2Y>;%BRITCP7dipuQ z5JmZD5Oyp#=-;k&(b5oXNc8Jt#A%n@wmf-3=Ql$+x*5&T$B5?#H6(Ihd#lab;(aeJ zOMPllnFQE;mipAAG6}E;DWMP@9kgSxAPgk}{`&wMztnCFeK(;heQaNMq-%No`KCM%Mh-x5>AmP2TXNJBl2f{q?{0z}H+qX&x$qb0>^v00W(E>a{VwOu`=rL;;+PPN#4g#} zNlZt#79k9qW7Crg7pMC$ruZ<<5(u4mP&w?&64w#`Xr(Be*2{Q{BJw-A@|gLzp0EFHJ2Kz4r; zi79Rr_IUqOc0vIKZbG-hmLb#(g8i8xyagll`#mbqmY%~tuXr{ zczLN~Tei{5Gf6jAu~)pY|CTObYYT&qsftYgByzMZ5Grv~ii>Q;TdB2P4@VwteAkEl zJYP7@kz)HtDjpP>pR4yrsZgsWrjUW1mq9C+H~fTz_oJKnRwkVM_^I?S;Zw@%hgFhJ zhZbzfapu-yEA6kD=z(*1I;#vyt)8BXPoyf)Xerj#Db`(XLUqt2aFR$bTK;r*`>m=kE2D*7x{;wgf|0elDg0PG$cJm4mA!MVfaIc} zkU4E~S5f0*QB0be_y)Ej!>08jzg4)HMe-#s2V zbj9{H;z$Gpqg&BzOTF(=DZencP9IJ)azHCD$U#3z-I?Y;?LymiIQZOCOJO5)j6ZS9 z@?%FR`~0ODlf#5uZhJ2Y4>2wd!ju;dYv(uI1!)+Uw#Tl_ajtBO6+MhCDAP^A1He*# z2}UE2cMVL0J#>f9%?g$ymhL#8vu6>{3a&P~xxI-GS-{KQb!+^o@UkCngV2H7lID=HpY@%2WYL)@7qC8kry?}?L#KM4Ccnw zF;r-;8e+$43hvY?MpZ0_->Y2P2GZD>2~YqEV1#%DjpJj6-3F(I`zU|Q6-n}6KeiYh z_vEG)*1UUIy*SE~FG0t_u+#Q+8HrOinPJ`Q$JwWE@69M#9cJSxFR1DZ9?;D%~h*YQ4Dr#?o307%5eYhA^bHbz0P;~PVu-<2tACw;4R`R8# zrZ$iH!(=su{j)#GLSN5?S)@FDt9CF+WsIcAuC1c!>*(#iS{(|fd_TbS&0c4__WgOg zTmBFdz90yR0Arf$c@AEq&}Q}v+6Nm{88uZa8iXb|>9~uCq2Er+>RRj>4!*{|y})Z{ zgVOz zRkunT+89*jLKoWe0No)1OA}nDUjAF=E^dCraOy^*im>Gqm%>8GWwT}J^B?@pZ--3>UZ;Efc}Ky@hmx-&j#^yb4ywvm4g!S8&hgc#Cw0q^NrsE zj@Ei*@s}oo{kPLNxr+UZUamQ|1eZ$_QyEzFz8mLC%ii0Nd**-t zL?&d$F6>1Ti_k=O1cOeS+G=0|Oy+k%wr2FqlD|@uMQ|0bV*#F#L{R~~X) zI4~G5uht%c65^#>k~c%I$NnFlKL~B-Hc%=j?V2e;9m?1W6@IZKnBa$Hq0!@bCYuzt@iA!|m&wS| zLqZ=*0bgel@3Z)}wDbRg>Kesj4u*#{42Y|{y}#cPGvi=a;?ev0N?z*8MBL(ly8b#H z#;a*48o*Ly_j=EB2}8}3coW;2FwL7PF_E&cZ`KhZp&xdV12a|enX_K)-=%^pTZ+Mx zEswSJSd+@;QGZ`K5sVVR|0fQ>(sZqIml#&iPMnrmw?OFdgolP(JVCf)jmaY#i0@g- z&1E`!-amv@5``iwu>NGB=N%*2`{AGjs{Hvinn{ zNvS7mGf2YtVR*QM;Z6nO@Y`3fTST zZjzV}Ms#xR#r$^*8VmGt3-ERSNAgszwK(eO+M7VlSpOyXfse;xL`*?Hd`Dp2Ox8F1uvE|U_XS&0Vms>pz-R(q95=;wvu*uFHI^k#nx)TDQp@Xv z9^%o(XP^E-3-~|!$_mDt@^+A2C`g`?kcDlQ_Gx(MCpOe3)GzxN=gTO5;EAW_f{eT} zygE*gqlO5A;gI=;JVF6c+G(UNZVMU1f?L&huTaIWi$FMvIR`yf0B-UJ`T&!DyYz|W z$?wbVK+xP{jC~Y;N-o_)dWJO{>7;vl;f9;@o0(PxM>O45241 zGGgY#Lwl*RuVmQP z(p=za1&Ggg&th9Axak~O(&Qs)v$?qTSSXcP2hY7sYk=UqMzP+=+bQ?}WNo?v;5s<) z)C=L0%W?>DTvtk-UDCeb_4Tl7*MT3LF9}yKg;l=kV*kPK3(pfCWw{pN(!H1|OPNMa zpXoGMU<1fc+9+^ro3D6O;o#JgU4}w-JzoM0pVlBeP{*IUTly<*Nq8I zjes6L`w~pdvtoQ(SIQ4-i8T5)C0n-Pc8|RXxwpBku+Udun7oX;RA8B#Q6m z+$~U)1B)>Ql4IjjM%dCM zuyuU->4l#Vds^HTwJ>dJdI)87&dPNS&^B+eql~tTl+o3ra=IU@UmK1C`&J>SuPw@dIr3e68*=1@=zI+VIvp4r1TO(JleCG+4_*@Zz1Ew3wRsg~z9_3%{8wh7d zOTdz>#?|boj0Z-*ql$&q)rQogGt;rO>>SI73p&i@@b@nAG0vFwS1BMN5v&}EfT;$V zyapW*nTIz{0HZ>hfe-Kiu{Hn!5HtWBJqUg`{Ib9Q_5FkYhk5;f?0QvCC+>s2TYx<_ zqP!JyOTkwpycKdw!B-@_6?3_KW`#UKcp*@Awx*k_ccIOu!aX4Lzz*I59UTaTsI!UTr1^t zzxXQToTR)J;ODPG+mO#zfZg<8EIt=24u>>If3mJgcq-(sIDmCy^)%xPhRUOaLWoE` zO~4N%lxXgj%$3=H_w3zMqxX?Repqdk3bWb|p*VG;-?!>Jr2-1MCE%-)UJAJ-;Lr22 z#z(#HD-}cf(-2*89*ZxRW&(Z5@(2RXVJ>Uo+xtYin51E*UGazzS#Dj7E);tjmVmMg z`Q5E&oHE8~QuNF-xv6XLa#|S&r&138m9}y*{iTM~zhd<WdMC!vMn; z4g3fDnpKYMBVXg@w77B>oS80$^t<90vXVb$Xy4l z;llldGi=kSm*qMz{rq4z;uy%Cbe1F`?#*NLUyy+RG!v?ZV`;IK@bJkoYI2%w2@xq_ zV+($%if-|Na zkLWgeKfac=ja}}0fm}oJj>1a`S(hjWK2y!FPM8R{t{nIw4!P_SM#*IX&3DuxZf8o& zcmShDcYzQX0P!dQ0T3hr9@p3Zx_=k{{-d9|%rpP;KmMHQfB($WBme)_{Tjdj`sr{_ z{QhtMZ+e9;gyw%&|MuR!H~;@#pXqOZ)UCI-oCinvhq4FE00FVFv9YnSv9YnSv9YnS zv9YnSv9YnSv9YnSv9Jib0y%W?kx0kd=f*PMvq5+(JsQ)fP}Vr{Y^Y3mTw;MqFku96 zR-{N9bjKTJjqb9IfxRjf0nEw)TLJus2$GHYml*BQaZKkXJSHI-I7qAfw<~Q2tiTMD z)e}L|W^^pC5_UmKS?-4Bk96GTUy^MBk?oPTisy ze@T>1FF0UERVC}`H}!L=w$703n#Ts>lu}T|JYvDCIVZDRG0WCP*DGdAgm}V-=D9$K z{DK6K2$9LU2(C%n+8abo;~128Nvw2{xZZp#<(>s>wIl3qJ>3SotMNMT-g7!>|F^^p zJ=+^j?*~4k8+Pda~Cr1 zP|MiFHbv)G9pSb+HHU55rghGbdc|c=EeL3Le6}Q#N5xAiL_>iG%J)uuHmVBMLmp~( zh(4ze^Wy3C-SIVnhrf|R=mgW?J=TW@#)*T{L3?>6wO=X$83ud}FqTMUJ;xDoMK$B` z3o@*dvU=MT>%UoMo^+1E(+0ApH&o-%#c$P&g@jr%uaVzZ<|T&tSUu z64&<{-t2HftE7H^CG8N4)u8IWRpeYeiK1D598*!~k4E)!AUQP|LqxmnhrBoa?=~P{ zo7Wpd2C&6lU>5$M)8Uqq?1FV1sFJN|G@}@@UiQJoRWv9~B&By8ep%Po^Ai_&gKbhM zWFW{w_Y?31YywW{?VCx;1GMu9YczD4XtFNyJa@NAsj}lwrhom=FFD6>nZaKwCySq0!{D96f0Mrt44ejY|& zRMEZ>N@f*PIO_D-|D4caFC&B_%lIVqPQtS z)b`1l7pnldh9acj{?YohnKC|3N@o18jxBn6bN&0R(g8z0g7-lATp-F-3kSjK;v!&m zo%U?(nrrQmEZ(AXaXki(4t<>zBFvXeos)oitHp}YGw_KxgcS6jGaaF={KPhXV0Ea2 zP=Cy?ZFc1P5^G`=6b)!~%Rx~%sVygf95}EY6>stfq7GKpp{Ud~liVuX(DO+pIuus+ zm96Gy=`<2|Bn&7(ygnz|-CK<`7(p_>%2KAD{Zv{D_9L1v_ij6wnT(x z5~UrF$`_H#e8`1o*)j}kumGT4G?~ZUV`!sCg)oG$q|K(XG)f|O$ngnfJ>wh>+|nmc zEOy*+HNw%S8+lw!ImFXJpW`RWW%`bSNns=Fi_Rxa6MR`@8b6i~uuS~_(O}2`qifiK z5I_LYCjbEu7!-Rc-~M_0xBvQ{WPjcN|IVTQKRf^bK76KsrT_n`Lp8tXfB)!*;J^GI z|NGnjzx;m7TzgOc9(c*`{>T0+zYV{C^nd$la{re9|B~hAfBsQ?OaJ+I|84F+|NWhA zVa3P)|Goe1f-M*`^8!L1u8w!so|y&!_$_QR#wMFVzT&uW+Ogck&Vj|js!^V}ac(Hv zf)(3iZL_x8UAA`IM_k4x;0ocu@8*o!1|F-Z@o-lTE&YwIF7<3UxA8XHW5!)W1#rx4 z=51}Z)>}=tYD$C`6S6ig2q`SKn{Lx>+HJc{w`y8!R}B~(TouEMdTi#?i@ox3&e=&E zBdwMzhL6UcD-v8drc7X5IJguz#{k!G8@FuC8=1A22EN$VTpJa)5pOmdoWpZKZe|V4 z+RKAtw&UjF;MkH-=`%b-npZ!+ec01dvPl5Fa;i=~;l+zZ%LN<(qt|yc(ajA9J@(M& zcnl>((oC5hXpHv=9Sp@rG`D!w4~HTCD%`OdBV$gPN%RoZ^`y(NA&6a5vBF$Da21l_ zF~}!dWaOLCSX#uiO*ky}RH`+d5G8GF@?tk^c!z&bP8}g_j|c>O-LIN-Xd)h-38wCE zl#MYxvVP|QJ#$;#52AK%embJDk^1u79@f6h@rt3anp(rMX1$FQ{#v+VLXhPD7#(|; z_Edfure#3P-3I>mHE3Mj5jZ8A+BYG_X3^dqbCjQP`l65!M2}ecqtJ5ziqPytu^8}S zTMHJCa_&h{fIfcYVd@r1#qo>~**_@v$ zyiK3030l_TxbdY;$VRsdZ$lXbO_D!z2)K;tMXb{7I2?oz-cihJ73)hJK9PG(97-W` zwB#9zZKf=+IO}{Yw}hu8H{AVGjA(86?F*K%%C>FWwr#GmZQHhO+qP}n#wwlKd!O`4 zI-T@YC;k6!=e(E~zl>zQ&-;uqCiEJScZNX+*(g~yRXONce>b^;4Ca{KX7GZfI0u(J zu`Gl^R}&maFqaZ>=bltK-Q%oIhLIl|CQ-B_MM@qZ?OB^)@^id5qAvpGBdKTC1S68l zwmtcBQu4J^Jz5C@!{Je$Sx#M7*SBOIATL0{xJU%Gdrz3ccm56)4aJ#mxcv;IpCX&K zSZ=M&WO@AHu@pl^ZJ$4f-aIl~rB)qFOre+0E0dQwHd>NX3?_>gW};O#%;)rUCT|~Q zC8O&dkOvpsfvXe)W}@wm6-7DCG;R1ABlCj38_I{NuMxKc4k2jwqaM`LedvuAO_Tts z;Jp16KYvT|E%C1ze6e*vWGmb+Dd}!x$|TJV61{o?aK3%=ft+7k7V-FG^L>}kR6fVnCeN;a6U zAUdoD|CjWG6OLp$adHZy2o&OE*Bn~kt< zZ6JKuKAI|0SxxF#8GodVxhJy}thuL3S=yTt`QUJB*0ea*5NVlv)$ z)*;!*gdlTQBk>{IsWl%K*SvHfNv_Id@sJfhG!xH!X(YwOu%$VDh0%PB0aTDKK<-Tx zoKA;1p}(c=C2#DlU_i2t9UMy;*8~Q*V{c#l#?l7%JXx_smAe+%JcnmlM(;q=i~&r( z71eCB03=df4a}+T#^$sSV+%UR@kuOWS(3hpBL=XEL>$K7(*&sR8VKPr0-MS_b5OY1 zU|oV_OxzI=oF;2CZu*eAioivpi&%-LL}Y>;0ROBuQCp)%T{UP7Ch`)QcJs^#m`LsB zklEp^TccJ2{;Be~ridooQb&{QXkbc>xJ?{txw&R{tiD03si&!1IZCiJ;8HJ`&CSd6 z>$p=YL-L2KZRmNWW+0Bn$793?yKCDa-)YFVw_fD#Xi%mHRlA%Hb*Ql~jdQh+?k9ao zocw!L&BY9H?6I%uf6Mqcu0k;z1>P(6TF6!Y+r@Dj5fus1uN?J)d)X5Pzgp zFUwx!j(nvQLxC&8g?tzx7B4%@{=Z(-nR$v85-7Vw-$R+~?j*w1r+axH(x#0+Ep@%E z#@!(~7bKN|>;bYfXCstE$mE%-_vUa0IGU^0deOKO-6}_Ti{uuvx1B5VAth2VEl}zh zS}Z#6M3YMEo~DaWkYnwj!X-u8)LbB884#aKeHHf_d3OQ4dm9Qy$sUxz`mP|BP=997 z1!+Rvlx>I)izD^CWSGNLDysW~?HING(qJ;sr8lv61*}HX*pM z>%Au|S^~Qfqu<`dE;Q+^)I~0oA;!I7^@c<$&T2Mbxp<#vXONy5luqtD(>#-GY`zt9 zOzLBXa<|LYk3p7OwN?j7!VF|U&Jn321jg(yR{8=Pi(*sVy)BiFvM?DEP3e^;>V;F8 z;MjCTfOEUA%)i3Ein3$u*y88)_^zLT1TlJyUM|&lf(;~8^?kXuQ{IAp+5>=>NV_56 zw?qE<-G+dfmZyQH@qzRvK63hy?aXAnCb~cI)kd8wjb_dBn&un7eBhC^>s{QmQajMj zu5HN(43W{)hq)wlQTb5;N+CniKaeWm4jTehjXhZ8iSR~ddIBX+ z{9c5myCU}K<4}n-tGyP(J+2`27p!uR$p{fe~$41*On=0`eXSc6IlyWg} z-*s*{X|S*`7>_+o`TA_^Zbv(cxxA6~IDvUZ5tvXR^HypzX@# z15P}0S|KWDf&3M~A2U;+>t#%(g1{(abuOu+>fRq_Gj1F%PY7LlAhs(3xW(e2S@#&t z-+&IQzx3gF@72lCq5C3T-M3XmfY;ZEMz}V(>u5U294hX|O*4uYU2pQ+>!?gJ-;ZLK;Ds0><>jU~z7A)@ClDPbi& zhd;8#idKeDO;4?q2nN!Q96@ne>IvR4Ob!#N-fWd)y17?Nc1=9&gO~slygMbPrm7Yp zywjkbksl??tW>uW?j;t30{W$%JleJzS-ZpinKB|3=C5;W{E~CsL7oW7FmreA{Eig`_qd z&}VOx(cLaQ3cArVCFqF@Q)fX^1IXZt&545mG8chg?20h_OLz1slhT}`TTMb^U-ZVt zsLzt^L(c|qj2N;3By(r|6>#0`7S&A&GhuZjv@55kyqIv*6E=dKfe5HH%Ixn0#;BrJ zXf-YxGdD}E58d4>M*iuwz3C&Mq+yf4@4=!-Bk)t88*F@iWJm`oBk*w%Gj4+0+f&wH z=)(K&r^(^^#Om>|6w(*EO>EQ{vo&p}?n@`EI;nRDZ#k1pE#v|>r-XsGtQ-X`-smfk zT|Xu*)GW37KME1eZCz`kZ4pwU0&C2dp33v{VS62 zP8YzCBz6iER}qm;U+)@XnB^FBA$ncH^sM`Umpb4qKKCi`)m;2{$B)IX0m%{-yPNg3 zy#)Hq^t*@$woJfOXiXA4VBUspvzqutF5Agia*<$&s7Nq4N=mxy{(Pd#Cw~DO>|6vw zmk`S$5)eO$WFE`n4?22HwQ_u}=%lknQPe+Htx6SQ)A@#N&9d;RB61&)7b7679D|i< zMi0F)MG`SqFA8wRwezLH^DX1PAMZTU@04z}S?%w}XrR#Z?YO09(HKcIPJ%zKB z^ZoS<=)o*66^Pqipk~at+8f7b(Y--vffO`8@AVo^+Uo`;_Pe2lX7F07CD%B;9G*0| ztQ7|#NX}DI4Dq2uPOwDi!JDKLM_qLXXl|ydqZ~4*zFl=Hxbrm2n2?s(B}yW?Mqpeq z3I$~I!9%v(Qn;ucpDbP`_kVV8)2HYEUmh)3G(WdykF}^Gr*C=5!N99PI6oi%m79G( z9l}zi`>SCUaZM6|Rq{sS{gw`H=s_D^z1}V@*-7{X&YzS7Tt5lOr6(~WUshK-O`@Zz z%ecW*?5@f>g~O}k^&?j5l$0Uecu~D4m6;N9TK_?L<)Ut|zL8D}a!sCT{|XMk@+efN zhDYTIZERn@4}Qe%s7?!s!%M ztkEzB*YqVC{DL$fOzs@qQ?C{)zn1=;KIWg#FYcZVI|& zTxR>umU~Jyj~`4M8su#oHR|)a zQiU-XS(OSV&!X23_pZGU+iHG-x6T~yM5degNwE2vDdLumPx3b!;v%oKf_a#9FihH5 z18Xc|NUC{pjkr%_&mHXRwsD7L^j)a))g*FgS$Sb{I7V%R$j2x}NWLRRMb(_07$=Z<@{L>=r0)K&-4_%?&SO3igofV?>q zL62OaRhAzHbLvYIAkk^@+K4=X6^yuge)=fU!x~@^jtm{oR{a%uWPO5`2Tju5!&{@0 zO+m63BUPPe8ZYP7lr!w9=tK*`xbB9GjzA77m~iLK1kx;i_+>vCz9p9<6}7+x`UIqWmr4o}=lhf5&Ku z8rU$Ry#Z4H2D9%+6dZyj^em(m#Ahl1474VVMQ^ls5UQX^EnC6{rZRbq7o>WP#Cm}Q zQaO-rPNcda=kV(tebGmtjl<~#F63LNe1_y*Nw7c+D==QO0s(bb?gtc%3$2&>M(YFt~1E7&%j0jwAVdcnrptVyqxWFm0@2# z!g3XOz~faz_i6K#Gkfbqy6t?2S^Kn9@1fu znXzsg>B*tCo0__9m9ueZHL#fjdqel5ZzuVvBNxSv&^q~3nfmnlILJu1PdkHSh#MwD z8f&s1jtA8DN;m-TfG`3vHGnp&OIW7`v1TiV20IXR|qxaLkirzioxX zR3D%c=KTO$UxEw%RO~9&G0}2sa{e_wC6wEPz|QPOa^_$(ABJe3E^S{$_T4pWz!MjE2=Xr7w-Sdm0lukTMWQpXa|iB^ zI3y0p1I4uTlqQ#Apsbx25AU$LjT>Joog@MUInQ z_ruj601g~%ID zBM)F%*w4Q3(<3u}Lw4u;9dTI97W)zDRdCKEeK!3FY_+-El@<*IQlf7~k@I;)R1p~wpSB}G$u}!#vPLM`%ujcc; z5#H4CdYf|9EU@+K?FaLY(jAo}jirEN{S!?~=NA*s`0m|a4OvVL?(XXuIG>J9M?Z!< zNR=+7(5IZv$(~YS($z?oLe~@&ZuoCKUxQV^z7NLNEg^P#%Apg`&*cqP3#FCM^dogb zEOjdxuTij!X04dR;>HTHCelp==yMrU9SaA-Z{~2EhrNHVRnCBP z05IGAnFB#@9i3+o^ z9z48xAxU}PO6Di_j~C63ros|bj1nC|D_jtZ$7EBkzh(Y-FII6)V$(s8;NFdgy0}aC zrWG*e-bZ?N_LI~tXP$o}!X6G>5AA>@KA=8Ro8!SMjX+5cSAP%oVtWe>sE*ObJk{_~ zIUp{=&_|R4clxNzTAnqMIIBeF1x`V8mc5liPYct#G9Uc@r6Q%_iT2al%~ z`k}`tkX3N~iF@+-Xp_NKTq7GcTsI~@?S;s75*zI*8Cvnp#edymE#beMfWb<$PPo}r z34s*N5fLEjohhGq|29jS#AiQeUNA2rWT4e&2?6{N7qMZC0X(89=%orbkfZzXAe?(a z{tB9CbF*GXuYVrI5(1$O{wt3_G|DD=RKfikEISq?uWXe)kVG)iy#0tcAsLlF_vX@6 zZz_DEosAO}wW$$yw8>HEdVDv4%l!t@k=^0v>>T-HzJF!|`dAi3$gh65sn=n`_`*&# zTFUP;v2~`+@@$LbvN}&N(j1Jy@Vj5mGD$fPEPh*Ieuey3nd++gB#?^lXm-puMk28W zAxZ04OgfUxE07~NW~P5mi6Axlw)=_2V#4m$|TK!8M)rlZWK@9=yL&wy^2>nbR#n!K4H!e^V!IAq68K?<2} z0!fj&cX;c4r-fmUKeLmj<)0I1I+!e3m=FQ{^(TnfVo4$506Pcn|A3<}qqEmBPRBwG1+m~47 zlk1sxi~dy-ygAWFb#?;2<7fFIhO-uJR?x-|+JIbOh~}=5g}wgCvgP4I19Yl~j%RAz z7F+&ZV)qrxSn(t;K;=rlpCWQA5|;ogVxH_a=*?z`V55|5jXWhbf(us@WI8F2s0`0M z#Rdf@v0e)k0)rf6HG1`}=icE4qdO6yIY_sdrDB;)7F(35Ci#(mo@Y`@pCMZF+@Kt$ zF1TkYUolT!K^Az^sypb{zR%q6R+h`?eZd){<|KtfPOT@hwQ!cGyzvTdVpZ$OPy>L* z#47CxD;NPe%!L~Qxt9)_wNu!-nbyFp&o=Op_-)M{L zQ6c8&d%*q&XpL)C7S#L|X4)(^lI0)~ ze8n4GpTMKf080||(-+vgZxkRwib8K%#5<7bFIM`%@vB4M#5GWD;~UFE#(=>zH7P3? z)ia@B@+OrP+JCJ>3P6gMf(G4Y75W%@{M%{Bk?T5$U|T zqPHrApjC_~OOb`JVoeL*nBzP8M~13_7JS(}NL*-we29S3@W6Uypv7;0*{jK_`83lP z-R9|)L5(uZR`Zth(M)hOYCE-1t#B{ABMy zK@ulciDzReYb(p7Qu=$_ZI=d@5#+)s_)8geOm#9V?~l-&K44!Ip*M<5vTD@!`k}WY zCsp94PS-BC*ET7JTBBrF0Is9h3;G>==Ij%-gm)mv+0CL?zhka-J+2~VTXKlNv_I=K z)HhP!Vbu0c)QE#<+@fjCY)>(=vcqj5;AqUF`+Hg(Bis#P2?u9n)xC z9iByDiY7R&&clgM)~MA_oT~VsTQ^2H1bgqZ!ELm)F=g~yvwwj;21tdFz)4KizyNVL z*n+1&lMKqE^1yH*V~iwxp{K1-U^$iZ=4gQqV;9`0TQnApth{;%(YOAyp;V1VF0#f< z9xm#39P?`<8ki**gM6awvC{1w;grR|mD(+ZfL??@*X&Dl>J?Vzy z(v_3}lRtJDa!iVveuYC+e0~0aoM5mQZ5ZaZf6$3Z0Ka=6W(ZpOK+cUdgIk1Sb4wyO zrsCw?suMjC=SAR>6drRRb9cObp8)qh6gaTlnJ|g-f`{@4gg(#Qj>D@P<^L5PrW

W0`6|49H}n|&9!bXho)9DlafNg~9AN#zghG%g#c&L)g(dP4jRD9rwdG~uCpxi+0v z1G4mTu%1icf7z^MsfM`VYG}?@rNA94m69>+p{VXyM$vpPwOe)Urqv?B-h|H0^?=MC z`u*cT&72|;qaZ{1gx}WFzRUS%^=scegzsBDH}?SIioZEAV9%N1Wl7}Nb$Z)%g_n(Z z8p=RhAG7tXr%Re}#FHLFO`T}?RxBDr%?XOq&nV{h2pfJYy`yY0CeIx17j#HnD_7Vl z+pV@zJYW#nfXb;8mev>Db=)S;3^m<%Ra~x2jVe@-S*Go7p&i{Lnv7!d8P`T+N$t|F zGlVt!hlO>%)^@EBaHJGfE>QkPONXh|CIO_fO_{xGz2ciZOun4)NhM-9W$ag$rY${K z>M3Ifu>jc?(0(mBZ9ANcx=vnYBVCuNF-c)8^D|AqY|3q&qPY$Cb@JzLmpI1Y)Lwc| ztX@75?^02gS00e08SNZI5AOzsU}BYYE}(gh>q2*3L5wMk@6J_ z^m*9!^{H5m-iR2Z$dA^^fXXz#P?`8&)alDh|J1x6K$@MXK7YYKV_n|ZpQcXG`d~PQ znk{Vej@6@XDf$MfLki$t4oLkBHiN)ulPeXX*x9%pk6*22etvNllw34?b1HrAbQsfo z|5+x@et~%zti8Cxx?R`612~_s=iRVO)m=fK8u|uCptm{LL#Q7-WL~~qTn;UJ)L~r$ zAj>>oLHC##@q-ny0TycngvNua05p`5Z3Pe1Upq;Srk+3h5w|x}re^H@YthTP)TcS( zZK*+(??z{?p}cpO4U$`@)KE@-X9@;Kt+i2$<$f z_H_e7o8-`&-z$|cH00vs$e$h~ycBs`76$+hJz)38m`VGruLzb`^!bR^`P+r&@NMZS z-5aOvuJgKqtF*%Z0 zfA1td8XDskQxqvl0MY4jG*R-VDrxX_M_!q0xE6gh1QP^>7lZ|>t5{L z)ZQlx*qtMkz2zN}l$Q81P8mv43B(p6RE%55;kN|Hd!@=8r6Y>QGp`cS6^ax*Kox)8)fNUDi;D=8?7^tJ zEq<|kMN7AAFz6mrUZ{AtC0(@}mNg2+BA|GjwZp^J$X@3bGYBKJ7K>bHK-9m&#|9W*Q+NgN_Rr7 zo|rjHu0(|rv@hx8*)OFIccBt`_;lft01Eu}rYvm`sU{<~g#?xZXjGj^wg}(rGb(n= zlc(UVXK$`kP@U;C)7C55wy0=2`h6ms4-b1oA1L~8n6F#BE(mA7e}S^Lkfj-t z7g#l$%={dd{?N8I1e0uop_3`!t&e|(U`0J0u`^uuoNPFffNTP$AEyheVi);ZzHed9 z?q&4L3_foLX;Cq*K?7vSVm4$D0cK<2KY7F|#Y*7et@3Vyec|uob@ty& zz1B$&Fq}T@>3LW6b!_fP+p1%sQit1$9EtAz(QYWA;{&e?-m*;4$^Gd;v1p^`biy(PCF~gbF@HyrBF297JJUC@SEvvqg3}O4( zdhZ@fd~lO2&B51{YA%rAHy99@kS%1>WkPy0T&C}^i?lLlZ zaug`4HR)+6eDxge;yCDse*)YK*0DY|tQpS8jDPCj#-0UgAEST!A&EF>3AJB*jmW=c z`}%5VUFn%$<%C57x#JypnLfspbQ(NhF|6U^W5 zMp)_PL$`axWg$-jLj4-9q!cqq%&K`52P_95c}C?@@=tD_gL+~ge5E+DO-yHI4UmvXd827GYOSg{#-trX=cXs5CQ-(Br}P4pq8%iiOG-{@i107RzlFG!8hLmH6ry{ z=))1kGl6Ch;5$g!1PEKpW`KqDlVx#b%_)VCFiAjyT}>9>r>X^6%eEL^bk%%gPEBpt zv_qD*K*^ObLtc#&fNHyCIE*$ zGtU1N4c<^V9<;I97rs0Sup};+kFM@Y#~`7igXi zF|+YbY)P7kSKJR7kB~7ExiHuvgtRXCe#gaF;Txgcg53)@7@z|&wz`WmmVq%jHG80N z8gRYIj_pzBkU$3DCc*e&z|b2l%g*YR7ACqg=ugEPk5tC57xcT$HP8B78X@*DYiu0| zP&stBINZ(+j#*Abzr6Xqj8K|*;6?JtKgEdZsB?Llj1i+o{EHnD&nqJ0fGMz&20Ig= z08W;81H~E!gP$;X49F~|9C!=}Rg(#uPfFWn4X>Ugdw(EdG98CF3m(#aeWVLQzX86! zn70Jgp_azvo1m5s>#L|qzqJ$`KcqZc`0wMr_3pR*q4ycK~nE}KoRCFCnW|hwuamG8;Uh| z8PJ-+8Nx!VGP7usi&0E?$Ki?*U8spn=lP#&AMdsXmM)(f34?N`DFI^uo&*AtZdPC2 z*EY@Z){9yY)cfC)S0As+WXiLzcQP;o{0Kmyz>z<^8NfW||1p)^Kevp-_jz+_$w3|L|dCjMNO!$m$Z7 zV^ueH#INdo)C&D9gTgy-O%64Ky}8RvwUa#2eCZep08d2lv2i5&c06ZmAJLV4_N?fv zwUpSELK^Dm6OtE+dKJWF0VPKi0j^e!cl`=AOLr}=wFBdrl zI4NQ161B^tlc!!>r8l#j7L}`pfT^qaddCr*$pY0BMg?R7Gx;D8ZHC5f-llgF^D`$@H>(qSa->WY_Gk?7U^HGkNm!ll?)@bDHk%Bz z5;@npH?k|5MJIH0T)D-IUXO)6p;Vc*G(IyF5Yc;fdO2Q)`ZR-Z9}Q(+p+J>Cd>lYL z`v1m^t`==Ffost4$e*$E+I_RIrJN#5)ubzGNvT+b6JHV@4S;FZZNhX=5x|zPpXyjw zkJ3tiW@Y2aAj$1V^*8=Q(OMt7;xC1yVoKO*MyV)|(JCOQ$*wD8#364^7`w0-gbAySo&`fj?p|sNY+r^tjQQoG5@EMF$?xZ~@tO&IL+JoY79MH6i8DJXU!F** z^_2NBbj1)6eG=LMK&#Q=PCGV)gGza7deWan?++K>1IUM*Lj_|IevKN>RRDG!W^oi2 zAUQl@aqNK|C9JmSi(S4l0ZgoRHAwE|SF7;OwWHjfjfd#wc%}s>b0F}1xjdeNC!pi) zxWWUEk?&{lZF+?jro}mYL7!YB9X37oW^9G8F3~y@>~erNV?24Rb&a37>yZ>5qLTk~ zyRL^s&5`Th`}VL8IwsV{szy}oFW73)gRY`yd3#kw5)=i~+HL(FCln0HIcnu2L}&)U zeC*PGRax#2-v+?&f8zqcUZZ;gi1($)I;k1IMZUheopLlQkTkL_&o)r>V9{i)SC>!D zjk2(yqsIFGPgZNaFsypZT4R>Yk(#AQJKz5|t1}Fr|HBGQ?f+rLUl$#E7$2>kmMVh? z_J#Adj&v*m=&weP8Px8ebZdw{$}*9WP$LXiUkr4*{#1~c1+b?9Lpy=3aef)MMM@>L zC_xbA6!6lhOCeOy*V5_|VM@Q5>aK6RnJ^D99&(|0a;}#X@a|o!UXjZG?YiTMv&_h+ zE7BzW;Zpw+z2T0hs%S=BG}`9U9(=l~5b;{MWa^6XLpdCuaK4GMwg7#+^@TrNJ3Bw7 z`Sc=VEye%8S?&FYSpCO!g#hs%V)Y-_6$1T#h}C~xSL**ER{wQf`Td7j{g-tmHtUa2 z1`tp5``i9&yi^5vr12Z~FYofaglcM7c(5*8X4;!_^g^RI1)qz1rHDWB&jq3b`r${` z$QY}ycB!g`#M*0cIAiq`Iby8Ogd{4v`PoLm`;8g8xjjNH|J-dg`W)hBV#?sa>Kt)a z^B-D;S8q&=sr?a09rAUxpX3Z^baj2_STk%_19YQ(I>0)x2kymt*ew0|PIE))@dI{4 z6U-CYp*Z8~m1$u5`Re)kSsa%8Ngw|CjCTxGvs3fpfGa;>&1F~l**w4S{n^;H{T^_M zR4uh7=7H_+>>|LzL(im(-nqH0YL@#NoBnwLX`r#xTk5>{2kJ;;GGX?JQAhvGp^P4c zgpY4AY^jz9I1Y;|-!4q0&Vxqnap%;YR5`1~ zzxcQ$astusstX$Ipu=I)s$W80spR{Z9Ptzc^4DBIVs$RRG(Hi%2I>yN8Z6iJW4;^1 zD@i90u-)#N3{iYp&Nl0jF^-hSL>6JamSA)($w&$KzSIk^P-RFKF4baL(HT@<(6PBK zD(FoJxvex$`}=m~+ef+11LO6FoFXg5yH&QMCAOn+vM_4kzDpO5mSy$SdU+!%} zUXL;Efk?9J$!Jf6vBSdajgjN{_#&1y%Uh6c$Fkq!g@T>6_iaqOqaPT^sSl-{l^G&X z|C|MThgD~RJFuIWG=ZkLJJIR=5;6Vhsrgp#zARq&&b)n%5q!$F(zC<*?KbvXa<4c5 z6)fr>LdgVr5*8B|QLUhX+GsLPgKxtbd}4Vfi#ndeoR~r#ySIA0${grhVLWFzaQ6)a{w>FCQLSR4+3)yPZwF|b``m1tM;c&+%5K>Ecb%W%pC|dg@MZ9e;^IoqA6BL_9;Y_7 z!*kDq)IyJB#>@uG27_kwhGE50*k?tBZ6g%|Dp>*`nCJxX#Qj+bS)-(phnyY=vxCF}z$R3i`A#mM8cVKz`k0ry7E}(> zY=!F>(9XRVXmkkoxd;08>8JA$&mgS<87tU`E|``>p@;1&v0EB7%u~uk(?|(doy2y2 zLU>=(r)XsV9rYn{@d&`Y;h2@=*QrW<5|$c2*hF24w|FI+Uj7Siw@mfm+p!GU2{IHg zuOW$7R=O)pFYE}^re%io)mlsd(bvb#!=@*>00+?Pd7P~R;EeXCG=?mhtn(Af^NRQP zskXZmAq`KLAS%zFBAE00X9#ew-cl(%X0>L`_>-I5kN61FEZ_zIc z4wT6*!Av%XL8mMkj7xSik%CpzLWutxRCakchuq?xb-PX5p98p(dUeELo90Dth?Hri z`?i>#rsvRGM=Y+Uc>;enEE|wD1*bSLCZ2vReR5+TO6AxP$qf1c>Ml{sx4r}EhgvI2 zVTIv+Z#}-ow=H5*_zwO`e|ug4(k%{%PLu5i6y68wBqn4WBpn@z?uk9QJZnKx!N8C( zsI|k365TJ;%d6%qp8${st2}x5VO!!^pcj?DV_QL;`TRZ1vjsWWS$04XNaM>*p}NIq*g5YtOy8MpfyM&__oZZ$n+5QqjKwwGe(TEdpM_eF z_g4ujDD1%|@tc}0G&A5^$ zX2V>F3|qO_mtI$#S^q_Fl^*F-@I9Ozc9AGjtwZ$OwuHw(ZRtdnI4_i-#2*Zgtg51% z#*=SMRC*Y=G#cn~w6{nISZbspjX+0nVdpa%b%4&xI{eS~VRUZt_EY$4QGzOVDnt@f z9bQ+GDaf+He~}>6+HwfGXt8Kw`l05evf|F|dvzSN@nc&s1awFPV-Voab#V;L7Ew%Y z)YXK?>w-S}?U<^S5WYSQlFdx79aN8Xlv*4OByr!Wn9cU(tF{t49bKS9ZCMgnfj&Ec zrM~-&u<^yH@jJHfv0vnc46tBPmCwENPZ9C^-?*X_$M(hY&UD|%suM^CEBbing~0-2 zfEf}$Sc;|-&pU;n*l9%EFT|UOUF#A^6f6;lGvetIy$&fZKPb<_F0k<_R{Tx62 zp*h~gxv`V}CKXdC=Fp8-Ghc9Y>7`e=)D{}6Sa$z_u#mDzomdw{huyftfE{nV+xr;6 zVsDUPMmSWd&i6ezdmVt;So7&O{T1zEYzc4ZLLnq^OA-RxI{|Qjm^X*{YhVVbUcb@G%AZ2Hvn-2XoH05VlabJAWI?x{+bH)x6f-Lt@>ZNGG65R) z-mec4`wxchj{1yocblhVm5jF`BpB~uTEqL!Jcm`!1UIRb_+$fwUW8-=M6UQ`X0pjF zs*S%8gZvTR0OryD&oot#2n1NPM;=)|Up=2UEieGD18WGjzNmO(b=-T-j9V5{hnphR zUd^{XUS9p2HF?(gw-2_pl-eBgC5#1gvO!W(QPDX|HMtWETRhpaQ-)u`K#3!h8EBp& zAlHcaE_0b7#hh*^zasnB04AH*7F-luP>{yEyzjU@NW#(U*=ed1%}|mZTsx=Z?Bch@ z|3H>csrWe+E(64y9)j54?_?&bej0+Pcs7y>`a&+E@}P0 zjyNTrT5Po0Ls`?*4a0DnY$fcXC#0|0j1@02v0TE0pi*qDm7 zujJ3W@m4WK1Msyes4s`Sl=e3psma0MS(E{3llLpulai&gN9*K(UU@argwp?lpcJrH zJYL4SCnWGO*Jy8aAL#Q?l^7{1u!2b$MB#H$!2fRu;{P28|8tdK_uql=KUWE9{~ZYbbCuBd-+}NyR|#kT z9SHwpm4F2Q-+}NyRtZS#{~ZYbGnIg^X6cWl55Pe2dtQS__PB~k#$C_N(XUqanM?#O z^;V<*H2l$9h)?3XY+rme|MqqHuzvf~wMF*v^D_xHwqN5r>rb@XT(!+6~^G9~n$M^N83j7W3=ROE$H3Cu2x+Z>A{IbY?Q??a}kh^*6hO7MJ zEnfJbQI8~$M~P~m4uPWm7{*;lY)Fq}wO^N5e;icS&gHGYB@&Okl}MN7+Tb6A3%wkI z8qVstb6P%v>1pyqNGCR-EP;*}Cx!K27}7$!sp)u{t1zz>rU;x8&Y zBf#yB{&LL5`U6Cc_+}e6`CBk%PCURRuJUe(3QB-Nur}oh7UNv_2POm1a-!*4KOr!c zhf2@|M(Kx%b&{J$gCPZujqx)aKRnpcFZl&)`XW+aYsXh>^<)-=DC53`(d+;=4Kgbc z+J(r^Y2Pzd={G`s2H88h?|Gp7S{pKzd6HOL6kV8irCKa`VYqAJPwCaq!tq{QYk4V! ztoR0+ubXuSj1O1cvRpGSml57frTDb8V;^HY?2cGGOhbn)B|`Wiry~|)c=)xnf+ zNDCp5H>}7fmTyV;KsU`BvMGo?`jdnghe$GMh}pyC0~B_iuhXL>vCc_5i|XiJLo>;p z_VQ(~9SDDW+LQ^SY6H4g{(?E}Q;A*j3jl*LXL)N0S6aVp{Hoe_s-nv0117T2i>aQ7 zyZWOb_FV%Hd6qz}N8XA5gi(=<*@Rw3$qaL6!uNju@_77?iq-PEOHTrh@ZwbXanjZl zymR@1d>HeQX9-LbMla5vBqkD|J=ztN2Vuk_P|X>4-lZ?rE^!Fyl9bZ;nw=N$_Ht<7 zkq+*E233RGye-Pum7=S+3j^yfU$>L`uU)JB>q*J;Z2@dePNmZSiu z#+bMwDGOu+K5BLm?swp9amHU41Ih8$K9_sc!{7Idqxy!}SaZJLtZzJR_ znP3a`Rhpc$=%kwfM`D*0{-{M!%l8IP3`mQRblIZDVp+OTrYT)zLna50&xoId9A~?8 z3Z}C-rd;2=wmjx2LGl43<98ddq5P=S*Euk#+L2iI8yi+xoIJslYy^qE5)3zgla*V= z`QRcOuj{o;mh^W*_+HO|4Wa0HK+WvI=(Z;vO*IU)4Z%+@M%GR=G!DiwKG!#tW5vcR zLgM@|1dnW3yONL4r!+C-98u7;^Tl5IB1jbP7I-MhNj{R;_eLx^7cd~E@x`~TOE@m# zL|10F2|LEz>H?g#gQX2AvK&D-0B8h_c#RerwUFk=n_3LGN#eKUX}ovpX914N#yLT_ z;9co##5l+v;`j6H{kKB2{u>w#x&ap{q`58`` z;xpOtBjCPE@D?3t)=qe41ueG|#40hH-LY_PA%=JI_a|$=O09Bdt6>Bp+XBk&_zb&D zJ0PS>xeUR~*K!4H@>~#i*l3UmSK3z!BhbGgif%mTE0i8g2p3n%*tFUK-j6)D{8G{tkKd~$!lmeJ;t zV-i8(Hs<$div7GW@*a_9*8ioP_KlYC6ntRr>){$BJwL-%SPzxOS8Xn(_nN z@ne`9!7Bq-IA8FOM_w9f(Xi$=Nb*IBBz zG*cPjpyJ=$^8-ofN?oTAWR=+YQd^@6s7@DfSc$diqiX5jNR7qcYAD>l8zm3dtM(rp zBrOccc_qC}c^j|6!c(VG4@5;`A{FA~0su`0_zqvJ8MT{EHFB0i3Z;87VgWSO;B8=~ zD@OZfVsWj|^KsI;M+Mw8{es>Hr(F|;a1PsUnM-eV5&q=yJJ4_E6vcm6Xdw-FZUl8PeTSts5)eB7YOh^PZi?)f`5q8l#A}-n| zWJTLV?1;N)l^Dx=><=j`A;9S~F(3djmiO2mQdUEO(lruo65vCCK*{uhW5$4~urz)Z z>xv?REzB_XBOedONDTS6wC2(}g&KVc4#!U2Zq-I(OJKYI<^e7!JQE`~XNbL{zPd;i zxPR284WJ!ocuK4cOA|s}jblMB-Y%;GRSRj@U>c=Ebv&+9T@Yg0Ut*p`!7I`m5B$N& zoC@D8UWlU57Wz8VfKUFw(S6%2r>muKei+0IllM(l|CsKGWoqHC{6Ybd=HM?f=$gS= zsD&=h@jA)R-tmmT8$1(bojY@3zN5D-x_dB*xuue@`YMA*=#?|Qq+B!$IpAsn1^&6F zZm2PPO1=O}`#AKCp{oMKb(lG1ahPQ@X>b|p$A6Sc_0l=5EnkY6b`>W33`Jr?^ZGW1 zcJ_$SB~odkPPL0&Wp|7C8GMo%JgTD2CLf2$uqX&oL^if{MJ17woiX--=M=*n;Z;0e zr7?kYgp>Ql%@C$+BOCzaQ#!Ppb^3Xrb9*DB-uEO z^=#&rpR;FN991TqDx$@qNn2BDr!9O&4k$Pk++0$BT)Cu*Y~QA5NeV(J`Y%0R&9T1> zxWGb7r!MPt9o7(a!+}s17P)>YxZy6eE4*Nm4B+?n+dz2lOE++KGSIb=jPy*KXm8E3 z>c|iWX`TZyib~`y+%8q5SqN9{RQOQDG)UU!L*OZuF>Sp_W>Nylm&ofjLEz#5qb|s; z(olDl_}~pAXA;D*E2=%%su?c^Xid!HilXCy)?sjz+`t}8dh ziymi_3Pf3$NFr&rBB2hD%;3qg-aJFY))jDEkmG>@GQEG`rnXPaQ*E&9c;hZI<15$x zUl8#R5b(2S8pwn?L!==J5?7DRQ*keoLfXKxB9<_gFLpCXbcaYm{+a`%Izyx&|4jkX z9U;;Xf2M%x4v^^xKhr>The&jUAL*bvL!>%F5A@I-A<`Wo2l{9Zkm(MPgZ(rINOXp0 z3=V%_5bOD)4}rXk}2p?H?2H(w=qRo#@R%pJ`M-&F*@SnY|KPjXlVq-6qZ-1 zN;zksN{}QMS>T_5 z9zw&j^xwM5QbAq(QT!Fy@Oxg6jWc^$)`bWGBE^9ZFQR!o^uuDzBK&tIlUXZ0dT5bW zz{QL=$#bPc(;D7-qxgS8I|;a4Q>ME$i!oiweO@x=PKAS2QY+&h8F6{V+ciQdQ(DLZ zkO+~s9I2W&toFuMyZRD!0U6o?Z>qtoT7+V4&bd=8RUP?9H>xXqoY|LkbAjRD zx`m(USe`HI2;cMaBa*}1oFom1-hd8y$d6oT{zq*(#QtrS0BGI{KN{_*T*sUT!%i*T zMC!|kF-Ku(3^6V`p1XeF=rL?Beb!m?`JBE%r~r`2=kLctDHDoezQ{f8S`V{CFM7_h zfKyX`(viZd5skB5`nEsSFCgy-ap#Am+M0QCs^sn({_MY*Q1@SmZyDl|6p+2?)|f^h zAMgPyDHJ=ARq7nE9PfQ z(7hZ@!@(fB;G++lpjby_$7}IUjy#5Kh1z4SsMf4cd5lWV zIhv_mUq0W#$gtSvQGEG@xAOIesa}6#e7~}RT##s`0`>JNS|pnIvViP8Ut1~Me4L72U4MM78jh^B*E4njC@yHTW2%Rh6W;K@$@PV4!w3Lxq!nXT-u_sEHq!$|@5!57U zdSS|P4T+~BSUBKk3IBuBCDVr5Xe8U{0%6Z7VfV8tztXb(}s*vC#Kv>y1t|9zuaLW?gjhlL;F?)z`A}mf6U^j z25anAGT45UDpACQeB9i_1G(m`)^k2Oh2#qbO?t|MEu_0E_epE7eXFI%hk=CWL&T5* z0qb_w@G!72;RaQE(Tgxn=2dhCsAAwUUszLkp<2XjvJ+DC0eSj3E$i3FLraXOX`L5jX1U(x~2!9XK0U@0(hxE!;$dm$GA*t1Rn@}FD(`6_)1P=R&+O5{L zaP`!)gYJhZv=jrIjuEA%_j3FgUXt?vV47jt(;&Gy1HPVvE`%)@9r&;QP`mE5$9_qp zU-G~#4J%c(75dROtmGQ@YLN~OS0N>t%#|G@CM<-h+5zML%=<72-Y)2T2bBqm9m=Px!HG;6Hit2Aw^*@dmk1M_k#)_LMv8L;DsmzXKBR&9O(dq=AZ-O12<~bxSi1&U)$m6{ z@C6@gvR`7b8&NVPZl()30Ct0Jr?D-GxujU$_KIBmi#5_+T z7~COIBq=0d5Z}>*oM$e$HFe7h!cqwRp(Thgs<`xZF3`S5cSSmah+4GW3;IL*O63N_ z|AZSLd?M^E;OkxX=YhXfzu}0SbtFOtxUh&X+e`NRT}WeJw#G=r!jSHi9TQ>@pWJab zBvhxJWW^IxT+N;C=wU);#CWqhCv~a*Pvu$uJf-&#{oJ|M0m-svdEE8ln4{k_rl%?7-VRliDd~R=bQODC5cHF z?gBBe5`g{~%3i<2lteWGOsQ9uZn;rup=?SYWD0NYvcMW+Ajk4Y{_4CQE*@_SOc{|( z|Dbd^llC6q-Nk0E{KBTL+bbO=u|Qy-c0JTnc!S1?MP%;L{-)X|dAmKb`t`3$LRa0| zrbpEo(fwQ7=^$l!tBSsdV&b}YGIc~Z&`ZhQU$fycBx9y5q4-pERXrZ1iLO?K(qA3C z-9CIBh@WG`zf5~t^OdQ`kJJb-ydmP56wYa$t!&VLHd?yme0bVZVhR$ffQ?vm*e9auPww_g{RqB;#?8h8iMs=$ zbg31j_2a3H=o%(aFBge!ikBfo-z(yS+c;VlWi<;qEi`U2avnO*#EM_grxFKcTiwnm zgS|r?8>VvzewGxGjln$C4WtG*jQfMs(J69$R3%v=0`v*nfC?Xt!42lug?9tY8g&Ja zdaJY2r*5&B{|$b#dzI}`1LUAUYefPt8Ki%vv_fwSAVBFKmyo(ni zVOXPtxn(br3RWOhhc;R^jXr*t&e0zERc1JljdRqOe>p=A#_z84w$l`SYmV^N!WOSb=syn=gzm#dmf za9Q;BF06TWNpg)hNCwq1SEhv)5)GXUx`Db!Rrt0xdlI?&?Id%ZJdCv-pD{kpIelSrT9qpBDxOknXoTu-HeKWK?yPgf4VA^Dk=k2DDQ42Vlh zJ@+CnrmO=9IQ|o9{^O!r)X4@i?vxL)doV`7YS3;tFutt|-_ateA7dk{LH33QVg+|m zt&&P}R$(PZu*UJbQav*!70T!)bRxhQ=f1*=m(9Zc{I3v@km6l_mEsZ-980g#yh1`l ziFNu{h)76rF273g2?-7**Xdp%AtA)N{U&IDHx)0J5j&{{;5-Kk9fSwynhLYRHMJU_ z{QVOFYqx-5rCh2!CCgO=mko$NN4dcex!G*h@W_WB-E+_m9V`{V*KP-jp6B~gHLb_F zV=;OXQQ~3BwidM#sN0}d%n#?V^Gix}NnVV;ht)Msczyvd%=ZwI^stIfR?noTGfutD z2qPb9^YJ&mxGETXEcRV`@;V3Q5PR&uP(OgPy3V9(@E6tpIQgTn9gP4CNPOwru0L_= zF85zOaP{0?NQSDy+mw{B;6Y5F@8bu=Vwn9j+N1C~hR3~eY~WJN)laJrB$b8UIOO_W zkHes?CqN|bWq^iLqEBFlx|rdZ=c*3C3hm-D1;w)^?SNzERM`3@W|upU3DwD24SEA+ zN2d^|9`j^mQ9@uqJ>X#oA4apaH7xwpN<7TnMp-2%z6OpHD}7;)kG%m7RkcdN103hRrg=uyF6d!EJc%`3>IQsgMC|>`T2* zkfydS2(M@Jv_YS(qRNabi>gSr43*S=bM&{*&Rvg`o&gy2e#?ez zUzmR(GsxnXQ+n)Wx4lZruO`(KN!5cM(tjIOpevx=efh0z2{))Y{NynIb@?lay16d- zPwY;^))&vxTUeW+E4Xq8gPoJVj0*rPb)1C1KfzUi(HF`Pwq&EHIAksX9k1}BX+YE- z21MDZ!)`!aCTefr>i>bx`1!~ZPE~|br&{oy_l#f>SdS!I!Ofy0K7#hCv0?g7r3K#S zBAj)%z#1mrl;RBw>^Cyr>L|p!c{GCBzIwTRCXRE=w#&*W$V>NnLR^ca#p6+#vS1B1 zWS#HTF#Vupd&#^JyxdS;##jJ10PV*IHaNJv*K8n@XQ$W(mv^fo}#rNT;pGDuS#i#QMdN8hGKRBS0HZ)?6{5Kn8!8KB!rkODA}&vcfm_l4K-zWT~oEWDwTV< zZ3%0(q35Boc}m7H!4v$CQCP9Gfge5+h%Q^ER-V>d?_J4F1H1B=>uV1v!D?XQ%=j0z zg+9##3vY|;MI~;bhU;=YPvnbNR1aYaY7xAUKh4O;UWclq-2}f#7RJ`8@#Y-4!@~mu zD&);4gdl7&z02Ag({l2S58c>+^CGR6C(5Zbdr@Hb z9ai*dJApfVc4;CurupQ$FwOnEBtowfVBSvs0Og(h!4l;WKE6b|x#~t!Sfm~OJV=8s`&( zA&!}b=AIIBlGAayP%U(vi5T4+{WI(xE2H~}M9SfjW%v9(E!&0i8NkPA5VK`lomkv_ zxc;9YQ7EQNZB*?#z@jRp?jP%Z%Bpk1XlEV!#fv?WgQuN{aw9}yp>rZy?aGjynp+zY zI>fi`KK)0<&vbm!oVG;8=XzZOgMZmc+m6jNVNDdcU`$8dO zD0$J_$1&e+(+^N{TE(06F6M|;iVV7Lu#D#<;eX7}H3G&I+wO>IaQKg^sW|}IQhu|$ z=_#YD_Uj>cBh=j}5g|Mq&sV4MIO&*P64Y~8)BhN4hPL~7F&r~tDdO;Y(eC>PMq}HF z)yl^qO!5Y#zxlG@n7McuE~vYO_&v*E65&0w2oGhMbdfr1;4@Q@QCInC`x7w9nrtP2 zw&?Y*yth)-@FCpf&+e4Y&CHyO$cp?l32KIx42iF$&OUtzfyhF?*??)swr>O3sL+f7 z>Iktj?e~iPCyC4DbNS!sQ=uCWRa%7e;)f>L(q(Eyy{eQi-u_g&`;pW+&?G6wP!GA_ zafE7B?sc#fveahfRV>!esv{nlakE(a#4>E%=Y~3TbNnsXAyC6z*NtrWzVBB-*p1Di zx4!2h5PVSj5Ju!kL($nPNul-g9pN2Sz1GT@hdaw4W_}K(;WNB zImJoo%=tf^+OdK~sn8Z(>09L)R_X+jPlykajxwjE`orLz*AiB1Nh1g}3#Y;G;nR&B zY{#j+9~8oee-?AQC4fv@7kXyDi+8Qvks-qE&x@B|T-9yF@KzsKt#nO?u4isG3gSKZ+!U{`H zMQ?aqT%%&0X9{^fENu0QX%Fv!ir(0sMq&PVD<(~7c57HRG z56S*`FBy{3*g7nO6)DLO%=p!2X+Z0Kok(%6Od4Hp2`>-G5j^=wk-1>jfU!I{yrHvT4WZ^1G@?>lpYK5vL}HQ zp1|xsj?P@^(JI)>lQu!Hjq8t=|JrsSJX^GH9YxBK?^Qw^8CogeA|QY0U%{n*pH(CU z^|~7E!O^S_szi^l!MsImqy1~zn4f|7fG3p6l=S00mHHI?ppsi@hK@-*3s%%_bmVSo-I1P1ejU z9u3vB-B5HOAlx=ls>^#ChO~?~Ao+<>Z*`Do7QJ)SPKRWs9mq$;#Sqj=&lf`5(FhD6 zKgJ5?*V|&&o%1V1n7tAQd59+ukN?@Es42ur6|4$Jpa&t(6y>N?4}~{&R9fB<^xD;3O+cW>I1e^tjtLoe?F;j4& z-oZB-e&~>IQOmz0ayj8>$DdT})`$TVGu`3qEA%1>e`S0F*&jT4U}J=peTOET#~YJLs~$pya?xvBd|mSZ;4Ki)Z6bJaPnV-b?C5( z29olsSp}ouEc3yIR>JWKq?-B5eUrFO!e#3r~9;$iq|EUaSG^dr3fMQlKxdT(fCnfS1<){g7_VLLiLnMVOku z$(vz#H5`4HtxO)^{)nlK0?f)W&aVTgbD`tQqovz% z>sTSUZ>Os47s_KM?JI|cHX z$-7KuIn^~{53olM^@tjE6aDEuN-m4SR9q04W-#p}^;AN4;T%8KAZgT3_oVeGx;HQl zWm{^&L^`qbQ{k%i1hFQ=Ski#jd5xm_#8coCcoSrv#S@OwJ+46bVqRe zi9(Qx9FD#vsSo9I>ALr&`mGz0B!+cUL7rlqnC-0k)I08<5$wIM^_KX00$C_|HQ*By zFk-;m?V{B}Q7<$KI2`CuCw698&uBN_o^Ojjw_;5fB6nc3rN~_LK6Ip(m`E2wUS*(A zT-1nEI3;SO33HlH!Bvc;#XPsZ&TNRj@xI3znsl*`9+5H9Lza}Lzxts;uZDOpIrqTk zR&EhLGh|kA`R;&oC|bl$wwib991pV&(a1!hlFfQyH7NR4UiSoXm}jMI7-aF&riR{- zPr`|A48(p)%paC8Gy`%2F#%*z;}m~QvzYZrQ)3l)d$DWpn~!`~+94KVTw|U4redkS z^J0^HZKk>U>#l8=M-UE`Q~EfM?@Nb|x_+s-;UA^vvwsb8rTywO7jO@RkiFcc{zpiA zo~cFW|BiT@7NP35$!r62!wmat)7;~d#~CY)Yx3%+uU4z3YFX6H>pRBJp*4KblpP}5 zf>S$(*VcQbNiQ84e|@wt?9{PWS7*;IvOy?ssgh}`iT~-*p|mQkylpPw3bhCzrb;ZM zi3JlZ$9=PDxQxxWygLpls@%PDd}_;U>u@BaCF)?jAkjSYJ1&pY6<`KM2%c<|fVoro z_w2iPrSZFJsu2kSpa(wxs&x|3l3UC&Ov(j?6^r=6uAC4U>YeI zWf#5-ME#)NjT_s8egdsC)vNaG##MfBpo!&hFi`w)T6j6CfyD3Gw1(CLeUe@rowbVzQ+u7P zu#WNLT#~(=A3Q(+_7?PwVz1PVYE(|ZAMnNW)6Hp ztdJ>P*QhDZQ!cIDK;$*op%QeFK-N4Bw}!vf_7f*f9SE3ZqEjAM{6pmuDIQoqZ=<_S zFI||%#VRa3pYn6Afddl(x(QfSdd}}-&9f!?f)LQ-Jbw4D5~?HIp@I>*jcx$S@&gF| zq8q6miZx`%XQ3(1Lg{ZOZ`s>M-(hPN@fx4I$HgTH>#Kq=W7)|f*Hibn{0$y6N6A?SmUFEFQ=zn&C1f zd|kA&<4Ik7C`ci3h%3^Mfm|1Sz9S0mxN&}|XmauMNWt12KaRNrp85%$e0K6x9}YHz zaTE16Dnc5|1-AyL=io%LCGRxmpgQofXXt_DU|P-|fZ3fV0?Jg6zc^iUOeL$dmWW(f z(>mMQZCQVDwmregY_}ISvz0&$J*XH^J0l(cF}h5O;_{n@YP-ZrEk+L%eKn}M>s8+b zHQ)i?6J79~C>5EuX1!nR(ha4anAQk&+s;Jfdly4%CC^e8;A8P{Xl59jNULg~r^-vD zKgKm!(>45tIshGPY4&o|0qLXh17G9C+R;CJZ~1;Q&*1rOpk&=&X<4KMlohLI2{>yv zsa`=zM*7#?t!$P0_b})#JFBW9ZhA=MkKJhV3_}O8S`c2_ucx21`#7IH8ZJsp9X(%= z;tP-Uoa)ng9-x*sFhl+WV;+!fpd_Y5Xr`hanw5$c<+`b!Ja5_HNw(E}k5YLwf-whK zP;MfTX*gmEn=zE@FlGo?Ko^`urMTFI-{-Am-IgQg;>cc0YX0Jn_p@~j-^b4V&I>jpg~yAiv4{ebd2kn?{=iY2e=Tqpb}WIo&Z&7W8#Mj;M1zGO!HMgz*H!_oFj7IXy=uL-cy%dgD! zI*RVRTZ7V65F9Urd%%7#!*nmUZ>oY_(Y5{m^7(P9R7EEaRhP*UchUj-8@DK|?=JoYnBk8H&8MZ7h;Bemk zN@73MPT9S=Mu9?h6q6cd+2i=M9fmwO7a!boduw@gAP>MPoDY8&Vo>*ILEbyGOw`_U zIGnh;)-54}4N-3)+OI{wk+xuIe8=2VN2BMTMb|b^_1{P<*XKZ0}Qij|2o6KaJ2kOb}qL*!GgH@g~RvxzqKJFxVd*DnY8*QC4rg zVfaw}m__TmjwROpvf+pi;1XHdock^_b=DhfGRMlo;@at7DFBSKU8xj}(fH>A@@}nQ zpw^ngM!3V-q~TN)G@gfHiyW3W5_Nge^T;4Z;ZNDr=@Gz1SP^$)uX?=gyT23A*M!p_ zabNpLKtOmwp$2vmG`kjUU;g&6RDA+V;6e8otzT5WY#AGRE|nhX9@I1d@KdGC{DC%> zxa;x^vG&$zSFiP24rG}&JX{!i-HA5pO$b3chn-9GR15{C5)4|04=PR)R;h&vh^dumcH^Y^h><4f! z6kK7cqFh^HR(E957iET%JzC#9nbo^}h;!dOc5}d}XleYkz8n>hpxh%J*bb>T|NI4E zNk57L5VsLqO*d4mq1;p4P!qAfEC!g;J3s=7dkD9m7N%0SRF|fi0keC_igF+c9HmT5 z2C4-(ZbW0Jz}b6f(V*!eYP0B%1I5X>ISuqz`-fE>jBAnbgP3`BYDfpHWk(zmYg&Yp z0L;+(3LUdj1xSHO*YQAn5zR3$LqrsvJuYg~#5_fR_f(cIzQ9~QQ%l@k;`kgOy$H@H zv5x?LMdX$4mx%z2n{aRiiQr|K!wDAxv4m;c+tmw1A-e&j-6!axH8j7b~-0x{aB zAsa1j3aND@WiUImJf!&Dex+BP!D}Vm{;uZki6IFix`p#~{hTiH%3Y>is@5$D45w8> z7qXfG6KLCs`fb5Z!Ql=BEJDyVuGk1A0zbC@l-fsVgfxl-TTbIAtyi~P=Tyd_sO`#V zuzPCnibLBT|AP>s>i(@Q{AcR$-%9BD5<8;2qidHcz$dmsx z*uZYfFp=}J0`5XTJOAJuSN_HB{G=f?<|x)6YdqP22y^A6pCB`55TU=mQJ{t2?jY$z zGL~fxV7orNi<%}rcLx?E&p|?j_`gn}Ahr zoc%r#2+y!I<|%2|BDNp6L%!K~PDc?iP^Y1_-!G`kS%-ArlubBxje zx44K|7o$2p9~b*_6hz7Y@ziu@UH2OwI_8zi+Yg^6VsE^(%`Dyk^>mjNY<}8 zI0L0^X6BM)D^q>48+j39JO};M6hkMi#`w%k_KQz`_+Y~IV)4qKf_4+sR6pL+97?F; z-PP7{z>hp9XT;dhz<@CqW|ha4s97VCBm$MDE>|`6K&MNewchf0Y`mdy3`_n%RMU18D_I8HD;SA_CA;aP$46oEMaJV0!}COwnLA3FP=KtZB-isF|}dYPH- zGUTA9o|g}q@!$U)Pt&~jVKD|efiw<4Y{c@fsb_j}rY!$Z)%`oq zcD1VSo<2Mr%i+F+;+MjkC1gkh+;9g#p1%2e00g5Z>8|Ql5M?oa;}A(gw+q zTAia=+-a2^2K|1&vZs#h5?FY7p2+#IHnGl^mDPxRSF$)Z^dNqrM2AH={Pt!Uzi2)c zdOS9CZ}cY2RxoV%&H2S!PW0KtI>CJNA}D0S=d(`QmbhS;3@#l44wgpU8cV5j z6S_)I2%SJuK``K6K}ru&Hmv$N4|b&t4ouvb4ZHai=RV|u^(~MUM8Isq;%q&8<~`}G z=dZzMsW|gds?T}1nHT|vf%6FO6ZckV3&gbyZINL}df@6ZEglti0JcGmIPxbee{2Du zOq#%_7U`0Xw3&u!bGB_4YY3Duel&42T<<%7xhEI-BCtgmHnHl}m*0f!aSZ7Fv0?PD z8Qq{;UH-E93z+|B9`L4!AF0a5Q|q?*&kE^Gh#4fm@i?%(9IyqRtR@=LYd=Sit7-q; zj%~SVbOq`BU_E!CmBJ_}XFC!I?m80ij8tu?W6QWM(hG34>1FX@l=#s&ubK;GmMA;+ zx8y#Uf4{UkIO*S9`bl~SNN=jbKSdpMnfVVO>f3+tMi?XqlH3VkIlmtz++&V$#w=C!qxyqhf1cBSz6-Nef{3g8GiNi&89N= zSjt0)MB&E{z*IyEqHIT%PZ*uPJ$Fj>9c8`l9$bmzlwo|zq@A)q5mnmIovIct_aZ7a zr|Vp5a7-iqaL1RAcw0ZRU6-<9?CsZT1pH5TI>K~E)oygMNLXJ|t=~03QXz#-_L^ic z9#^5Bs#U1ppGaiOf!A#@MLLlH>ex_TH;d!x5;FL|y=kkpqX!gRL`eU^pa9YdH-}kv^7v^$kTc~&M}McRw$23v1qb!lEJ|( zo(#%8;nxVF2iq{2v7{;LecWhTxc3~OsnB-`zZeCpH`|DLhYHV_UP{Q+Fj zl}?jD5ynv?Rvd%qz#);4k~HYVyCcZx?hFrx^5gF5*jqcs|B!${Xaz7dye zr7~k%%2n7l{|&u{aO72$R?z!+`tzao|0@19uNahNi?IASW~;3@lK4=Ws9-4ePiq}? z^s#9u8ojFLg&&5jS&z%ZnOE-MOW2JrpM`b_?Om_>9}EeKt7iijoVRmxH$NAymN_x4 zX~A2sek#vG7Xr2|G+Unr{)VsK>@5l>NVidOGTrQi;&mlCWg`Kuzvk-2pfC8FP|_^g zlJGNn6-UU~tNotEsan!pg9>n!ozYUnDlzmPxGuSdFV{0UR=~){O`6~Vr|?a7 z0fD>C?GN<9Qkbyo--C*FZ5i)hFp7{T{w-^9Ze8w^*s$+-m7wTR=@v@Z%0K1=7Xxoz z#1Yb&sxup1zJRET_+MjJjd~%t)oG&+lIAa5-QXA5-nS57sjaG!rC+A8Nd}of-rzTl z%wY^X>S?a+W<#jR1S8`fD}Dm*-@XQ{pNGzxGcTtz1-n?ff=2Z#uPz59!%q3(@y-kJ z)mF-v8NGB0GkE}~OWqx6Y=wD-;xWax@vh{wLC{zuizOncaq7ZczePw& z=9!4CM)xA4vy)pV3*<`%C%e@$H-8=*>DF<~cBoGjhopYz|6Zm<>8A^WGiJ&g zN#3<~i}?KtN5PgIwX`S(TDwo|5@SZRLF?c=R$7xdL(sgCS{uTBPpMt5D9{Q4uB$T9 zL0D4DDRe6+E~GdoKF*WqNxgU5lPciu5y+ylvq!=?vTMSyu(PKJzoUld{cz$0^whBGS zjB`{&kC+=q>BILuSSC;cfHCEBujn;Z$B7QD9^~A)c}~e?=w-ZUvrkLk(#~H@m?~yS z!E)*Ipz(|wq#hzD#>gfafS;fede#>0mwj31(Lq_$I-mqpj&3jl2qC>{IJ5zw<(7el zKVS^V-r7(9z(?X(#+%&4Ss3h~12af5(YQzaqO{0jumw+s&?Wsn>Pg#~M3uni!0S>7 zf$Gumx2Hpe05H^6su&JCU?x*LEtFx|6$!eobbv>AsLLBVWC7T@Ls%SmR_m{8-Btz+ zI;Y%fe|gvmq#|mFaGG%JpV79Ep$G(Himu27uxYgIZ-3(I8TGIn&~-c#3c*gbG^4Zq zVDUY`$N*v00jdi?GV_?A0aqGGSE%L$%XP3-LoMHSB>^d|yIB$~Jvu?sNo+dc0dMdktU*;nPxvGyKh?eUjPvp6$mcQ?m z(JVdAQ)z3nHi=l1kjK(1zUu*}=GFan0%|{a{`Y(1a+sKW$HZ=g2P)Egdg=FeK)nxU zQ8k`fY6=*TVF0p|<$BvGg^1*<-kx6d6jQ5w!%@0s_EiAmi&dZZ5ld~7Q&Y5I7jbF1 zGNcUQj+OrOB_)wEybrSVVHo@Q&;}N9dK~BSK1>b0?QBp=UeRN*DE77fr5_7@`d&Bl z$)Eg`nWs5wk_wJ0P8ldpoozTYAR4v+WqV|*Q4)jd|Emx}&by2mB0W!b{$F2)js61r zIHU}2&0He;c-=2*8#fOcFy-liouvbXoKf?NzMt@9ZjnkyqJHfKb46V$->;Q^SGRBD z_3|TK2g#h!4NTSb1FmrEvoBHQ(Qj4wX7Q=CL5-9C-)F!sgVInHgaKBuq55zqW!&nY z8vUz5xO5s{$FRWOkGNR71-?rDg>;KeIqyIvq2Wjnu5brswu(3&%sI zaj7mbk0^cQnLb^pV^=<9S)hw#_CpJQ*-+?1>>R9Iau9F%nELQW`MkO z=k3}Lv@*a9-Jtt)3LI^&eZVG}8r7-+SDsq2ssSZ6oXBpUE@C%dh*!>91BBcUw_=3Y zYGOG2E%T|X-kv%IV#@!{Qa(vcO%d1kkoN}1cV=@B3F zdP=Bd8j#dfTIswI6Zai_M`Y-KLc1N|vlX3t)(@HWnhMzw;ANp;7j1)px);`QDhvXB zAAUmbIt>1{NLWg(ww(I+-pOmAXD+sP!wUO7VSgd%!NoQltPme2`~M;KYFIgRsk&j) zX7rv|?0WlMJ9qRZKT4Ip(MC4XiR)g8aF+Wz>c{v(ki#ZYzNh0x@QLuCwtJb=&qna1 zFKk?($e_?;7QvaSwQp>yjnPeL7;xHo=GPJI+-#_y%6hWf&KDxJX?}>N4w5wrgtT5am7|?MfjRx@o@RqXfcK1M96`Df98696)J2bpS%+dV;~Le%7-a6?F&R4KVV-y z*MwD6nni^lk}{db0|WsfDbz_f-Sb*yl1+tZ3+g7`WG07)M%~{N>qqQ2GP9(CoG|1w0@#gzL%$eiiuRZ@-eLJ;vcI6N4HyDDySa%4-zZZ=9%M7ga+RQi?ueUnESv! zzMYa*=zd>pY$!+@Yhg`KeC+#tn`msbkxLowqBy?ij^^ zn@RjF9eIE0`+d#bB!tm+$pKRQxYuOU&|*@J1LU3vunbL3N3j?b+@#qS6~`)ideg{7 zpc1A(#XHB6pt7`%j?z_qY%gDGH#~Cm3L4iPWeBk)sfkG8C;&ceR~gv*yp#}nM=Cp2 zHS(sy`-+29`xZ#aRB$Cwbiu!DRypdL1|JU4D-hBeyS~rnz)Ee);p9OJ!T_FJL zRc^4#5T-|Ix&;oPG)b0?Yz`UsHdO5B`=>}QoU|x4{RRU-6~Bt&BdBR=(ZSr*!{Kg= zH;j&Hioo)>h|`oNe;k*IxJv~!i_9ui{d>SKEJz)$+xw#Kpp_axWyc8@8gAWyddSkt zSa?PV%S4=oATxwg>5(~UGX-}5Gn>ytQpY&hq%RWbd&(?RodTb1Ifq0fwWHm+ugIR< zz*XSZknNZslh;F-IAYOg2Mb4MiH1GzZwiBoBh4u{24u8f=84X^U<7|Ldl2SYG@&qr ziX?KnDRt0_yuAU2lkkS$$B0;L_avPUB(e22DO;8|IJ3eqhgON#X7O5PjMf+)hr)Co zz0(8WtP1z${RO>qv=fKzPo!%c%A^#UT0&1coVO80S7K)~a%?L#xD$y#FN9P>*|@<* zF!(k-OQ(Q!O<1Mcmgta96MkqDy)~GDiXRZskgRVn1=?sbSytsjgK7tNlpAM4I%gm8 z>T5z3|L#N01x097{@S3$swGOidzV!jWYlmXF+w*yANzv6m<1vFTgR}GlxcE3Iwu&{APkI0q@9SJe+2-wO5hA zypX*qd5;0koNJr#WyboO{7BF#{zKng`y4}&T^J0FsB?$>z&4?s2D}Y788Yc|qkz&N zm=Yi}TE!=E4aHsXOLy`F(W2X7QEq*L@s+w8Gn3B6i65+Bl1Kt2cr3vWPG8NONVK>+0Z- z;D?krMGk>)4-tkr%~`lMQ(u6+R}x{rWwC@uti!7^#Jy9HXu+~A+O}=mwr$(S>RxTzwr$(CZQHip zZ|!}~i+CUJ?|$6>imDhnW@c4H&K#p(|B65rt%R4cX}RO*Y)lgOW=VJmepnf&El=#V zKaV6w|6K>u4L!Nm`5PG5NvXcuBB=1q;(;~M_(!FdFG|x`ZFM^lp1SM`-&Q4eB-<^- z%@QFJ%8V{kB4Pj&-E|&p>!&q$=J?;7?jJORcqygid zD?hm+Q|li@WVBs&dw$DN(4=oFU6anFRoG^9f%Fs$WR?sIY_%ZrNDw4a@hvS{{(i&s^&m)j}4`>O{BJ1#94B@-vlfD8bq+3f#Q@D zX-N5t``Un1M5WJ2a1!F_gp#n>w4wKxMHKm1DJ98A; zDyMt%2=oB>R9b4Q;$5y_wJILDJ?98P)huGS@efO5ldXFR)h;vLKcMsdSh4m^jH}dE z*1!}Kn$HvaO~Ar@eU%z0k@xn*0wSsB0o=Y@iqeW~wr#|R+t0)vfngPJ)9wo}?4KBu zfBVxS{1PuQhK#r_T2&bZgucR<;BN{AWa*ZPu@Krky2O55lx`%Vhnm3Dg;pER z4YGZR@Y1~FrHGj^ZxC+_gKGIKc4C)HPq2piC&)(Eix{p6HA_}23%sALoi7^>?Q7tq z?{5rBglRJuQA_x2}QxN=qt~iYgP%6(Zi5jX8A(AQD|Nz$JKhyFmN0v(4oD zS8+zL3g0w%Csr|eF*o}OVLZs^#d(CDqr+rn|HURc*x$91ow_$`vi7ahQ5%tQR&_?< zw|r+#nSG3Hyk0&|>qU{~A_Cl*d!FI?1D;kUh<*T9xWjGMsv*$X?5Mho3vw0!WIqy< z&Bv$x2%LLeW&#I-Ryza&4Yp&3gwvML0OY4B4Vf4zOV!E<`*Vs+=JC~P+oLA?ib9_7 z9dn*91)QV{NX1&Sj_wQpnlxco0bkk?4q1{M+IMEDQ29hm$+qvyu|%wubKHiB0p$W6 z8pbrw1Andj0Hiq{Y#BVp!5<4S{V<&iBu7nPz5;|F6Q4MpW|*3~KygiR`M=-cHrE-s zDEdQ-Fl+`z9Crrw7|3AYK=6jj(zrNsz?-9EQj-Ku?d#Gxwg%J)B7tA0U@I51yL)0^I!BX=aHd=dI=E#?1?ST!MOkl+n?JD;JXTqI0|F~O}GW8qam z|mC`a^lnrrk99DmwYXgDei{1{!Zz6Kj#~X8AKQoQ)TG3yqT^w4)|rdl2p6^X+yOQVr6_Ub*x{qCS6FX9{?3wJ=_`eM zCoEKz)^h#;yuC(cHy{^j(DWxb6wja4jFxS`nIIA1$lL+Q+u)i9tcLKB;*Vfk&gNHa z8c*s=^)DASoq%-@EL3rYG)w;Tb#%i4c&_==B|XHPFD~IAX8Z-AHK>cUev%cnM*`Nn zgvH^bSd5;k>i;{~`p2?6Ktio2n-u~nokjHU?T#T44w;^Cv#UZb?(N^Cjc8H7&e8A-BF|v5BvZ z)dN6Tc(t#*o0Nb_Gy(_TRuaoTV)*JC1WBCUnsyu3g-Z+;FV9OSwRJqN6kkqtw{3Mxt@?*nmDujl;d_o3cJBLU@Pgf-}ZUjzVL469l@U_A@blrD1$W9 zZUOW2LK$rSvbTh8ZM>qtq4ox8QHc&EEH#GiZ+#YY0wY9c0^%QTCXIdA(&e0-lx(y+ zahq=q*y9xT=IZL&xRBh!LvUiOt8A?vJ8iS))@4twd&F z;UM;=q1N3AqywzX$+(}9k{oi_bZY;G*J-wlH$;*;vLk2Q3kr)>B5=5JYw?z*r?k3^ zu|E+$-20g}CYH1(jOpn@?mtzy@7h>~eu^-qNUXYSS+RUz*VZ9UZ62b0zl}esvf>sp z^$ie{O01?u2hA^hapPYqj$Du=?U4wYm;k3-OqO0M=C8<3;*siPy`+8@ctB{rU9FO= zdKR3?E{oL2;Nw+)NxwExjWBSk+pJ=OWrq@`?~{X!U;^Hv+%010`pR13C<9Z9_}$#f zS^G?gK!Gx{BaF{byx#T;PeuO#z0hc$h)ylz^r1}=fJzYbZ7h+iCv19%hmcP>OqSW^ zELy%Eau2H+W|_JXM3TKvT(mbOnwtyOi-cK}2DcAo&x>8k8?+75Ki;ctzd|k@jG9L<(}q>7>Vh9|9l>8@mr^6p>>#9-%m=p$>p5i&t zDwE+0yO=vs3?ST3&9LnkX391`8|8FHOB7;g{{(Fdz+SX1`OXpCi-zgeY1KiWCJ?(} zm-1Mk;K#_PLz^_J(e~LE0h}4_7c0B<9j5Ml2~Mj%%R4HQXh5wS+k$>6s0s4*h(5hG z{aE_!v&UT!T)vf*1kA%uxW9=`lu-@1J4lPuj?fiE5=3jgcbg(LI@#Ez?*LND@`6Mh zt;|8yWJh>>lBldB0X?B;aWZ((%xG}Ng}4{4#IZLM4(_TMj%(ym4jHlNj&ImJI+^%f z7-)f^^m-`J#oxU-m&0h`&lGOZys9?r5UntM2r}xk@9#h9N3Rc7^kW-FUThH?;dE2; z={2brH)N9&C{gYPp6LqDdTGnm+UcZ`emP}tT*x>)r4w03jE#yw<$z)O82r$3XyY3> znKmRbSurey@nu)+k^-voZjTu{7(fEEqJpZ<;SidN*QWiG)uhTYo^jMlcDy5E8o}Lw z3d^g%+V7?!Iyj^P(3>WAhiE{zR% zCKNcb%zOrBNl^TxWZc=AGPr~mVpr{ur*FlKGXlXDV4< z{sYLen%OMaWh`Onu&2LDVB-xzmQ<;ksFGuo9?9x6xoS(>%!6~=dQ@t7ICwCd? z-@;?H2D;d#xsT4um9`4f@KNuHY7<@j^89C4^?Ip@_hDOKK|#r>e$|5N>(ok-rxbGB z7g!_caa>Yx55j4|B1+B$cL8STgs`^t*JwqaJWPSngS$Of-i2;j#8CwPf6Zzu5V8h3 zHdCCh45?xSmBXQI?cF&AB}WG8=_FwHDuj%&w)G^(2TQ6XLFHIz`Pj5pCd@G}Cv)PS zW9?m7kTCo?09)1oWKDEPU>x*+CxkfQL&j2Y&%?)hu^>UU^H${hA%X?M!6=VBY7o4# z0s|yc?F2X*6q*r4LVt&>6K&MI8s)<+do-HYzL{2;lzsHhNOVg!kpZb&hI<|lCdNI>|Qb>Fb z<(gx7)$A3q^g==WK?DA{$vTU4|FSL-!_Y+}z4%jgqr46+bh9dztk4S#4i9#LZ8%hR zUc`R`3`l~c$QzOfTS=X=HUyJ{ij;wDztgjg!sdsb6d!nhwJ&|VE2eq%f&)FiT9Jiq zBqn|WmVd=ZQAQZ&QQJN9J9cZp``Li8g7${E<8?tQv0x13if zFz$~W27n0vUw0I|8<^y85IEiE#XW`d^mV^f{@u4t)&ys(FcBicmQj5Y7wDHv*VjfS z2FCdkh;11WVB>v+FcBdlM1*X)!o@$vd4}|MIvbVMR1y_?-5_Ptmc%4~bq<8sWQmxh zHRx5OmJkt1?@<{#Clmuymz>jxch~_i-MTFpy^1B0*0Hr8;uL&@?GFW&f|Mpu3Y-xe zNiMgyjcNRkAp?j83Z)TQr8-YPN{?vl2?w8(Tr2#T;>WFHEq{G@9jvd@AlR3@} zJFwB;C%g0i2hG@4>(xRJqKRje0Kk7CQ2bwj@P9_g|3Co#{rnFE0>Nc}00qA~Ex>F* zsy4tNV2DtN;NY(OuDSKIp?wfB-V+xgwwH~#0?qu%G=qG$Z~(_{ThkJ%sQ zpWAnR)E~KT`YY(0UjEt4@4{cfUFMeG+V7kf&{y%>-bOse-DlsFU)n43b^NcLJ^lrL zS8s)1`%l`V-Phaa-&THr+=bpD-}_(Xo6Yav8~l9zHU8aCh+l)po0s^{<7;m{vJTeW zx9j%x)%uoGbG?xXLZ&~DZ;`1WN*#uPCizL?J=Qi)TUnINRkIaPNaZmK6|@%dR=Y6n z)glq*iKXp^Gk*M_lZ}?YxJ?aRRKick@1An=@vMXn2ej&;71V=TUtf`oED^-%Ubh`H zQp)UcT<*(+#i|AB(u}KJy!k3DG&$Wi71riLKA7|l5M3RVN1y)ap};Gu^3F0tGh8CX z!^$6me4e0TJXe_N$gcxM96o!@CJtP~txUgWOITgEkg7{Zf`#mD|8Auiw;wi+v%ES1cT){r1 z;qy>5X1xZ(X<@+(=6_>0v$H+1|HO8ChZ6tgHv}!~kU-CsTY1MXnGVUKln1yI%??z* z?C>}Kn#5$1>@xC~H~UWjIa~Ait(d3+*U0Rif>2eT!*77V6B?i@K8Bv*NB_>ubVs*x zbG@)_U7gPF*~2=M2Z$`hV%YDQ0BPiYanWMixHw<^{SOtUiu5&4ByD7o1-r)K|KEL_ zE&{gRa~SShlK+YlE^z<}o4;&cs(8~%GX_Bi|Auxd>ur`#8C*0u6E38Aoug9vub63G z(XL=7<0I;wPerVS9T`Y1#K-z+o0xZNpH9n)5L1**`1Kh28!B(%=Oh?8vGe!IfiSd0 zK+?L)>ULGqSuiIZ2j`_~Efil(otWS1Y;LjvyqsD~v_G|QDpM9O08hVZG_qoqhwf*+ zHE+Mpx14%D#7WHHJH@V+PgvY+KE^rn`SydV- zrF?kQE$Qs2{38vwi;@|85`7ReT19A@d3vwi_DEM*A#v;J%1Py!jG$<5=Mql@(^bGC zq+0(&(-`<0JdU+!O{+R%I0{J>|JGNww#;K0jFo9T1OEe@C}IjOfRqgI9~S(SZC!X= zc7O(yVA!gso?NKDz?R!{Vuqmw5PnM^C0z*QuZMfiitLp-=@AqE4URiZ*BbvXzuarz zUQGYX`YRytxF#q&bZ|^PQzjtmC4C@<3$ykQQiI7e*fv969^$H3VorK#gny91yLg80MH5tV>x1{l;KoN8-P{ zYvtxFJ{@TVfWaqpRvT!+hSRlG9_x@tKNrl%eWVSSIxU|kKk4@p%581imD+#8CXa|Mxm0niY zloqOqL;r(W!Tee2sF+ok@=gOqKgd*>&=HP7^fDQ=V>O_@PJ601hN{Zb8yEyR8jKJT zQ_txF#q|_GJ1AI{TIR#(0)-SQyd4i@0s7fMGG`da8@&otBNw{}qQr&HL+_tjXZ5vrXo+^`sKy zuTOKD57`3)lMS}#uIU`CN~`|e?@qJ7YX8_IcX+5E{Es|DVim>y)jME(P&Zu2HXMIk z_4y~zdXE?5AbV8?WW$^j9(k8m4T_8xtt}Gh zylH-FyJUk;E#Xo?&fH~g+_`;3U|+>lbLXX8F`W4Sh_hKQORfqxHP67n;zo@R!o9Zd+||3+th$?D;BA)unC zp0`inK}}FqUn3{~RR=3-DSj=Ft{+X4PISdyP|?rc1p%O68ZThi{y_39tCev}VOQ{? zo3Z%!@ITdm7?9M6*d=aT)Z2udz1!r|)R(Z0K+b2bw9#s9)3LF>#rk<*T>(nYGOiok zUr15aLcfRmh7MkJ{Q#NJ_{n42$E+WztGx$Lt1-^mBEb_F3^N~?)egr6En1SUf5RPA z4(chSktdL#ijbmtSQ%Pr*#Kc|$h^>?2&gj{^@IQu&(xUSvcTpHd1%sYO;dmz^-kNGjvg*T#r`*%bW4G!&44Z(k9cLH6WXV#s-x{DcEz5Uta#{7^ z(kq}WUtcW#XL%9jr^L_Nk{8>XGUj!5m*4CA@vRxzJ=*qPcXf-T1d^}Mq$He*kWy-@ zpoJVZ*cx)o3e3aR>pkV>5g|OH!i<&5!qdM{_kZsL8xey1kj|?<-|N6=*CzMoPPWX zUWKEMqBh!0sFuFX|Ah8m^%ytydVjbpgjpg;_Qv**YABw*T*n?g$&f()bQLCR8sRdL3c?nfz|=!bP7#QHH}w0%;Rx(cydm0Bt@6* zQ@Rb*^)?j17sH{S3T6E8ZIcwMd`V<8Bk7y+X4v|Oan~wylsOu4ePYY^fgVVJFQn$4 zXG!BGI@D3*ZYpl@!w?>;c`Kp=|5TIX=cVjx>k8Z7u!kTeQ)zd}!31#jtYB2524B2o z$9`Rf(BXQ)?e+3}bu|A~#>sV#3@h4jy*^~d?FSOU;D3`8J{CTY@K@2AYK;vyM*+tL z)r^eqImFkhsOs#(bWT_?KFo|TEyRee6{P>NHlo;2k+V!LO@G|*_%G1R=^R^hT0!)5 zy=9Ok#%w*SE`acCGXJ zf8PZ4JAU;0B@5P@7)5hoo=Cy#Fc&hk zc~%udXwGW(3+f5Jy*#PYE7bRC`@~})KMRgX|01{>7eHm3ku~4?jqqLKZHZE=6duO@ z92PTqB`Yw`miNSb6<**m4NlzRCNFK+yRwc8L0><{ENV@lX1p|}I|;+2Z|^PDmU;Nb z=5v_oaAiD_C)1B$$L?{xJTZ0m@#UePc`eVFjm{agfn~Bs)4K*Nu=eXJKHa~rF!Mgk z2v_S{z-k+0gL?sHboX<+7EA+9m>EC(`$xMG+d@D%KUYEX zWSyvEZO4%i4>z5VCJ>&}G#@0gxLlS&1X(J;fY9JO&|4PFG0A`-%Ac)akhK^&4nA+( zp@*gtUntP8;kL8yd+0amwQ=t=Mn}ha&1zb)jIV|$-y}fmtZjOw03Ad z@9;>ip-R4rl9dw>WN@qF8mT0!0xhzi#XWFG_)iAI)xsW`Ex?BymM4_8)*6O@&CrLm ze!2>zH9ec68Dy*HtIgS79`6V2kkooD@Ju_M!NC~(JiLH>+FXfCqd;K(7%SNV5j1kV ze>jt9#m{i#i+SJT8CAW{0{MhR*d8Ais<2kCT1s-LBey8sEW-ps)w%8AC{Nrc zA&m_jplHd`4}Opd8m9=RYi1;t5?h3o*9K}2AkjCzR60s}37!a2T?7MB+)`9M^I!!K zG0n{)NWjZ!WWAQeIQ$Nu$TpA!hIjhSmll(Pt)RZrE*9J^>GZrjb-P_3jCM1xqc1w2 zbD`lM&#u=X6Jd|+F5CMvfQ63Z0|mIb zC{z`*laz5nj@OyN&$ibhNjL=mR3>oEWbA6!q7xJl=4!55NR%kv=_*4+!U^7ZaHqaw zA)+7{wb3=WETHh3FI@+4q~0WX5f3WX5He0}ZIPWQ0Y!?2h#L>5K|Fopv8A97!ZhtT zIe57yjm>@2Vume_pY(@d@L^L4XY91fVa2>8YpJNH36Q!)RuOp}*_h^GV08uStHBB?U9SNJ3)6@6e)(c|R0Y2MYw8q0 za*swwx&~P?DEW%3XJ~9lo(v`na$sqsto1}zg6^TV)o=(yT}^CUG~&@p z(c03bbgkhC&T`=+i*n^D~B&L4?x#eXO?l%v4N`8PpN;^oDy2J0yU? zM}CQro3am3P4tPDSif-jRhM5da{}e2M4|+KQ(KZJ%n|H@Ofdi#DvsS-+;Dhyp-#5> zG#^#!BjF6Xqp{WVc0whL68^aQJEhxpUMnYkO%1so%dBFEZH^#RuppZJ$bf zmf(FUMOjG7^6NQeUL6b3-<$`eqB02urn2IGq?Atn6u)kYqHM0#ojO4TLt1R$+adv= zvr6BhcO;H@JSIJnyZIovrMNx@pq^eS#m0ZN7)JhP@J3Ai2yB-n#20j9e?4*3$gvdE zcXTU>iac%eD{zW)ZIw}#HUj;LdOScn^4%&`1Dy9l3{GqNY!IZyRyG>0 zC>#heyCb_}T5%3cUU_FFcF^B$-{3d%^Kv3%exOE(3%kxpfBGuEJF*W(0fNrYV!}J_ znlzQ6OCZVx8k& zm??CL%{55ptd7NP9f)u)_|}YnEgbV#OKkDb&~uM|(uZ+)8^eNw zUt^c9j=)X%gMffRe`~LD19(&|#v%#2mrn@AF+Ahfi{I~ZMUEfPdKT0ZRPMD`_W5)F z=aUWBf}kBFBJ1f~7q8u$))IW;Ui{D^RxCOTG72B1x&V~X7MIh@EaSZc$w^s6*H8)G zfVUt8@V!Y6uw9UVZSxi5B@>31a{FB(tD_(#1`Xy~M3sf+IUoynkG*g19%$FkLPTUw z&)#)GzZ}wFJnfE9u6o#7hptg$*$FH;&xR8>|8yyhw8{E_Nak^KSu8U_XZ)RA!&jkT zQud@QP(ui8G=^gbKMA7O?2vE&5fo|d+1W|mK-ez`{k~yP@)@m+Q5~dZ2jzXig}Yuh zWSL+@cW-k#-b>@=;*dF4?AL=|J5{|`1N1_b^1d>IG$N4jc`cXw9UryC0e@3bJnm9u zJd&Dj^oj`wyV@Qv6WP%!v|{T9hbe53*2wl;$xjUK1-WVy6XYANTCvQg%g0`~l=_Y* zG-|YM3|E&8pj$RMH25p`uBioBJpg!g40Dt`9H_-_ zlH6+yxhAlFatCe#9z?a13;L*r3y4qImy;1mySO?pVmyWyRC-d>6V$_G+ePZ#s(+FA zf@%y0@)h(BPK{hx-Tu!Gy@?U|$D#I;>}$^`8VHP?W`iS0k+xIx9?H|%C_PZ;jnS`Y zTRsst<>(#HY&A$6+^aRgt_Hf*zvd2l61LM{)AXj+v>-`?Z+9d51useC++vkhLiWK8 zr)|Y6HJpvxMAorSA|JC4wa=kr-N3fQx2P=sZq_?5KUK9!8=)ksBb~`s(@YY}89Ly< zP>K&LmH7yFH5dD1ufUu(dV07p7Hl{`ZYQmHogt@@6tyirpqfp{(Hq%Rjty$CEaH?X zLVW zu>$#y`G@lyA`DSOjg?S(+NWr)mbEv*11SuZyKDDxZf?B}fN*U#`#Lb=i9g4U%K1&n z!M=)%x&0oIxPXim=r{OVX_2Cy{N#n6!GYN8a8B+DA!l&#B~fDWQ<1XPTh3O>Hj1Bc zuQmj&vj?7&$nnte7cVX1HB0VWToqtd3Vjj&kc?byj>wz{{6aqb)I`!24MJsX1{~go(uq9YF@@ zmS5SW-?VM55fXY!RkOJAFORf8Rh{2X42fKz@Ah;T+m^oLNoi$d@sGn8+}tGM#H{dM za(XKQ9bcxB0!>%BL7tpt9H^`C4DbU0Q=ubE(JLo){_ z6b?Qo)^0?KWw&?Tw{lQ6D)~zs>{x|!(n-VhBjCLF&T2mq37AYdk&eTgRr-!)LNCt| zG0|erVcUNekY{R znYkgDamnqmuiZM@oKDrl;)i~7t&2OX7V&)P8PB}dvf0K3Ct4TLTy(OC04V7eHhUXC zcSqd5W1dJs!>0O-h|9J&e&G5>J!O7Qcn@rol^?G2nFAzkGgr{e<-wc=DJxtTNY%J8 z&0OcK*=w#%__m0NTO52Y@J_BJXK;dJ&XwHi-WhoGx(+AfXxE8nB1}MA%^uFce#q)L(h*&AaVmdDr6LYI*gK{{zHx4@imO9 zd(^G%eFT)>{dEuT@pPmD`I(u3d5$Xm`qY)N1rNW#MqK9bPVby3)|vfn=K=N zo0!qF2MK|7&p}Ym81qi6FD1&jPi^=6p_m!c0|}N_VX@}49%{=R*Fp_a=BK%%mh&h$ zHb8ThtjmiH|8`VJc4I`car`dXjL|2JRkN9qC>OMok&H!Y>Nn_ePvs|hdwgw*2amZb z7#E0{Qo?fZ)iX}`5rOshXXObk<=7x(5Pj3fgQUczV>sgMPb?&b^+mFGtH5%BjHReC zhMe}$*t2rDNkBrR_ZIt;S3uL#kQ7L%J5wIE5I)Hi6wa<=KrOSWh26BgJ+w?&1n>jn z$4^MIbe)H)XqdfnuM476J6(~32q}KZ?^MH-YmJs+IRH|53za`Ei8uSycXT^7tH9<` z#2j=5$@SK9vxcf1zfo1i(x4{n5Su{@)iXx3q0RVRwhaO#6?rQ9VKF1^4M4WP_?>am zglM>ruQS`}m&c!e~bppQwn^3^iyYS>+@CWNo zD3<6T;rU6Pb=&m*APwCUm%I>^VHf79-$_?4wif;7O(quEk2H{$Z>2+%f zR}-+XH2^f|D|oy_WJ|o*X<-h9*p0nZ;S>mCxNLi3{dVqhTi16gN>EGZaD7oatl$_cs zm^ZBd$(3Xj^ji33i3_jwK7bA+PcENX=MgLomINI_6ygO#q_Za}?7hfMibMf3RO9LF zng7bFt#V~+i}>jM;Q<%{Q^EEu4&S&_$Bn$1;pkKiOdiU-p{aCesbNGc+21Dw9%5tp zf_)#SM`dQqxJJYZbf+k6!+-_hdAv~vi7U=Mc5n(T zZp_!T6_JAa1KkMRRv=)O{^nm$!tvc!LW>_vIoHP-KvOKzadhw*>=FoqLt>O)AF2y5OUCI9}p&BMl^Zz&v|>Ykv&jo z-A}@vv@P>nEp6yfLM>*S3MKnP%=8@@o?-o8WzCIem_Sm02TAOYRl+?FMqx(JIbXQl zY7G6-Xg;2^h2cJk@wffI$MwXqs@eOce8sP;ec4Q%vpZUv_n1k9q~+wEJlvCf7HG^6 zpQ8o&EwUVAA*q<`i}zA2ue9?dUeL|nLICaAkNlb_V`ib7cxI9=1#uV2J_M0!BtgQO zG@-AL`wYyIGFr=`9wa~p9Q6GN4$jtRm}Nh)TR-wv*ND(spym4e zkW?=kyzp21&iFn}kPx$Wb3j;AkY8FiC`zK3Yv`#tS^zn_ z?2?^vKhcRYJ7I}h|0YF*IP_~2g#GwXLz5PQ1C>QDD*0%Ei4(}k*9ONzH$Z|)f@+^IPvt;Y=2!oafyKivRCZ&>ZcWaT z{pfTo2q+%s5*6`24s#X`a#^A}MKXucY?h03A^&t0XhBNBB~<5pedBWYo3TE3@yt>T z(i+6IgpuL6K{z=JP&%N~TRnwIbW8`Y3-Cd-B);!L@?Rr^{)N1@*r)x@;vvxTm%i3T zANP^$=Wj3*T_1Ys63h}_)mt~eZlWgJx5!~IK`-E!Q+0}~MdUpIvQD`jF0Y&dujeLg z)J#pW8fGAJ{f6--)s!ep192f-;X~PO6U%qCW1FrqKGzfe^Vvnb&%IO>WCwR=5A|kHMnwxll`FUEZNYjA61{E92363ZN00nFAT8 zE#1sl@8_UcyiGUn27@oML+9I{Y7e(IeRpPHuMbk2R{ zaoLFlAp!<#S!7u*<3WxF&bF|u4 zD%EcET$=`0LGyvE8sTb*l9L2IT5l!t^t|dF`7rb-c#6g%ZBxabtPx+vn`r0z;SCIQ zL7TzQnwHTxC?36RPqHH=%@)ETd-IWkN*w1X@0G@2zr-SYsiCdIe4m=fSQ${i(aHLo zkOY3L59=z5>68`LI5xD5@t#iYoCL=~+2uGyu$^2nFNb8Crw~--!-L>FZwCFwNKpKY z9xtmb45PbYGTF#cHv)@b!Mod%Fxe$by>O37N_q-<|Li}8?Ea8gR0 zD9sB}qIKT(*23~_^1j)5cj7K#zpMYlTP^-!ryWF|W&CY4EZ-#VQXmvoG|)Noc8j{T zifyGB_#*xXyw-Fw-TiEo`F0^S zcgx3(2eYO#tl@{}-VXsw_q|`m_IN_f^fsK90nwm(>ep_ViFXAJCzxW!4oS2^3dY+> z)8QaO@E5|2gmXe|+222P{|7%nI}hSgX|m;eI1@Q%d*;nev%*jpvm;SY%FfUq-u%FA zm0LL)f;5`Z2|3VDEY0%ZFJpJc+ikIBiH&dC*d8>601=R!3$L0( z77)K66}vT^L2xcjF3m51@dW>@O)TyQvDpdXL^AJsv_f5#e0@h#sdX>G~uEm*a%SZr;M&i_ztm8C0Ck z^6h#Kyuy(lLl4-!MVrgL$r}s=lC9o1^2qqWj7GRGS)4eo(?5{4U-jhW zEj@lmap6=o|7qiVqU_3wG8cvOF%QR1L}|iVD$xQVXtPCwg;;oE)X9lgj|%~r8vR`fPz>&p~RnFd1BkDQ2B)>rCCRLeLC!#j0FEnqYIaB^7Jc;Nf|q%dmyjv?#Uf!|MwAqjmy{7wo? z%_=7OZ@g5@OiW_k>(=iV+i&f`|8JIn&&TNL{(c;)>HB>|R=;xr7y2sq7ddVQ9jUc%tK4=M6!$w=#Y8vJxoS zBpQGY!B5{iNR<6ASQfd(zMhfh14XoFn^V<~;10^~m6`7ZLAt{7T1Uc^#f~~j!bDaQ zo&?SWLuinRuNDGG)xZJIhl!X@ZbVEzYe)B{=a&guN)J`14;kU1 zR?~#$dwW22d>Tdqv?=I2^PEMlM_u1vqqjXIR_ZWb&v4BmDkOLbgz5uxKq40H6Srm({$O<4 zSG$wgojdT+y7qpuA?b>QqaJi@2R`3I5>;!uI6V7N;w zz}p%e4wIMw7aj9H&@4WT8abRGQW2o>q>$?{Y^sWwP~jI!sHKt8llv0}HZxc~9jlHP z8G<3dQOLXsQyN9BTpWZEP)%|V1{Mr!- z2eVD9&>zYs13nu449yW-dyIaMfo@Y3`Y8uxaFz%Zh(l<;MXa@0aQOu2xQ>{PH040VS&Qt8|w{CjcatLO-!&|eZw}(edTl1ls#*$%=SuZCxBO%KIdNO8k4@Zw{ zb=V3^bHlE~Mb3-8H_=W409Z0_0h57X5CjF};ZVK~AiB)*01d_NNd4td(GGl3ZFY|wjkp9CD&ZZ{ zva}d3jwPYeO0P8BHA$XEXK4AA_&Lp`AP%T{vUw32&=ua5awzHH1wSOE$Tl-7=o?h0 zTWmA!Wgq4DU=s<9)gt}h7e<$D6Bt9BceO^h{DDBKEzOq-nZjC{Jok$d;tFNjRNk1* zGH~O~mZS{R3VJrHVRGZ0*&f3uD44ksM|1gGS@xk*^p~Z$pWkup;l|cl$Ivgunqvg* zga3p>VuO?h;PS43CVuZ-2#I7%Qd;+o0zayOr_)`+`n#O=QLW?YH;WQsXfDzKjqdDY zzTYCYZfVsLCLcBTM|ow72y>o(aX-<{wjKeJ;FfuyVT>P#Wh%F;B3cNUa(FbEBm2z1LG(vXc{qi43Y8iRVlYpV>^y+`NEQ#af$uNVbf z(xl0-$o+X|0Nn(*D1#qZt&M6(rqOva#C8qGjdW~2X>$19#<>x<`E$175qv8gE*;@I zBSDNa{TEms`5FUw%Of^^72*U@@}oi$gr)WbhA2h(FV@8Ghq(K-ODZQJ6r=VqJ1nE- zyd+oTsO@GvWT4w}k(%^gtdN^=m*6znu$L5kH&`nqKAJ`dVMIVy8vxj7$Wj`@u_KBy z`|XPdd4sQh=|is-9f*qtIJ(}O_3JF^dI4;aH6YFoV)~Si=X0K_Z362)c(vBw+oP{Y z>ELRijx7xJrBD&fgzvp(MZRL&FD154qPbCX|3!0GTuW$Q)FjfHz3VOg-^$_yRV?Ll zs}f~ioZzq8tP#rd#{!wsMLQAUqxFF^Cl%HK1IO!O(ly2Lxec-Z z2YWz-zbM*s@a?&LvlWhgx73>_D8A8psv|5Ci0eM5HJ`70J)Ms5iH4!X3@KxE{GRz6 zseyll{qJ${`e-1;1zjq;i8(RRhXGf}9Pp{TUCB}*3_by%04`uCnj;ysPq&c{uc)+J zDn9z5b1WPcwtvdk?RHr>>oQC?&>bPsKh2d}0n*|@r@(Az!qnZ!y@qLr2H@!4U!kO3 zoqXI!oe6r#3C6@lU&T0Fd6T()`Qy(6L1&~WG#+yHT%|Wt#Yxy`+|-{?q4saMm;Ja1 zjF~iRK$}OV0so!3EkE+!+!xSghq&tVp*Y5Sn8tt9d6N_sK9DM}61h-x^VTg`F(nie zo>f7LnbNHVOr#N~nUFx08@#P)u})<+{Hsq|e@4R)sDNZzu{_WVo%Zc&#uX6+8oLF3 zl=2fkb`ohbSQZ-?F?tJ<2@s$x#cz%l4HXT246ZOjr_Se9R}ttpl?<`ODhJ~-vZ zFBG)i+z$OfV*-H94^dlEjOT$!CUCDhX%~fRraE;r*h1X^YNY^a)bVo9!VU}!PEgMhbJ5CQ4 z%SHzv^4!2B%*V!|?!kjm!kcEV`j$~~-2S8cpo-SGKs*o+zT1Ast$G6(nAC@jK{Kpy zz6Aix)mMdtIMbB%|5??lDB|v(1M<<1jng0y;c3~S1z(JZ1u;P`EYdxk6(vZ1_Xv-5 zoM(pugpf}Q1`k#hv|sh&U6Jv81-mnA?XQYLIZ^24n2ZER=D5LN0E#%_7aMY_Vk?8) z`;Iq$F-TtuagJ+G!ttkYDNW}ggj%o`r$H5P{{ZBi>w7!`fAA?2>w{mTo=w2;oAq|X z06}7of+*r?Vdyk^`HV~=98$-{!zL04M_ffRlRVn6wnM#V0T z`qFo85qjhA55J{#MuH3ta3fOM6|$lIib*i;#76)d?&UVjSo%eO3UC z@#)Z@PJ$?6tBP56R#}|1xm}AaY{@HPEm<;{ z5mLc$VwU{qUR_X630jTy$>g_OW@jUMZ0#+p^uhQr$C>FYvZ-Vgv5#B0X{KG&8uh#O zQEa5O2aIfy3eVsiTG&819La!eTg*^0bm>btz&HQe9ygCVx@W;d;-8ZDJ$C%sl_muB z`!zy&vB_$fg3r|ikrW07G9-EBDM?B!+zKLm)kq3el#okJsjv3_U0-nAT-Q^B8AHt! zx)TchU0SWR`#Zk2ub62`Oyd*-pCI+UMvy&wyq2>dudF}^gFF8do>#vS+--8$EqE}0 zW(HPUGARArYU+}yB&H3uXti-_5LnD~p|BN(=#RV#|MR_q*(+i$Tdx29&JKyWHReVH z+|}@3IWXid3?2Lu1EFM+Z5q54+f6V4ABch}mF2kWoiF9)E~lEIQav*xBL6J}fH0R* z+JRe4YHunNDazH5Ho<6Tl)tyJ`oVZGm=zQ63tlgMY+8`VN!kEHL!7dr-y%dBYJcRS zNdn!S0L7kF!6vtN(>sLgB{@mNn)kLDd2QSwY>aV z9Tx}Sg=uyx!>J*tuvJN>DJ-6c&7I@wqHXM<*Ro#7Zgs1{>?g zdA}g-828JnffVI#5-*YqHU$hYk7ZpVj&rjFs0fCVKaG24#Rvf0pho%x<7J&KS(z@` zzUo`ibnNSLZ>OHY{=pj+Gx--(_KqQsUJn6{glw- ztX6Lx`bf5e&$d|xDoRzjX6HqXja}qtw5;dPn-41VP``gS~%doOT4 z;yl_`6A6)|n2bc*DTin({Nq}wduf>%)~aUr(M+_?jD)K~v#VNGVU!$hQCv^z{#-QJ zhBEBS@AYr`bs2c`Z%1{S+l0KEJ&yokZT{l3@>YXxj>-^+*Rsuio-B_^q4g$|z3GN` zz8Qoyf@kpzae><`()@m*=v($hP-PO^WIYcv>etTpub& zBnJpegZ2AcMPV*7V)U*a^2XDd^Iq|Fxb6|~@9)V{e3|6NJU$o+U5ZFzuhqyb+WO?4 z-={dIlXQtC&AO%McL8*K8>Dq6@NZveY&F3=JP`s-vHvQ5y9pv zw5j4hfKAlQng2rP9d%>S))^DfLu=7$dR36@oYu3m$DAg>fX%Bw-^wqzMbb713>b5# z_f~U@LquhSNX36XyU7u^k^*Xt3YX&m#=So?vRPqC&bfrpsed#ReOV9_Txqj`3Da0j zXCq3DRB~FLQNjS?FC*xg6P@QNu*g+h5oQp5jL%c9{*oJN!OxC9Q{fNQBv@Sy{cFIc z0E;N3#W`?|6G)F>K5SMF;nKeC78hX6gMW=XEXyWfiYJ`IrG2}GL6<>Mj#mk_@&eBp z2Wle8i?p|9Zw_*o4Rl1XcVL*vzxQ+;`d0mTs&=(MI02F{e|>h{=jO0XDIY#NvFza= z;2uof)XM;4(kKL8i_zu6w>!Acln@Wzx3ekypYy!o)yCKx>>YywV#+)EYS-jnA!mT% z;E_Dp*j!WxR>cp(FpUlpk_X^8hJ_yhHy05|H^gSh3itXI1AB1nGPP-n)Q*yf;vLpc@ZTN~E(*kpclDx$SweF5-PAoGvxqYu=+UR|2>EL_ZbL!7 zJwD-^n2e03ddA9)d6;GY3XjBZJTftlV^S2v-WfDxI6wfcQ6;AWW!?A8&+|j9x?-cQE$gI#RV@6NlcY>=)pSYG#5p%G$t%v~I!dtr)YQR`Isu-k1};91wjqR~u>lmIhxlaV$RTljwxNh?*Wf>vFb=b@8}7v>~$sU|q52 z38vh8;zCb$x(UMU>8Atrq=Ea2*=)*Q!>xP=I)0IU<^ys>Sd}MCfeh!poDi@fO`2bU znY`vxSZz);>arAYFXagKoI~vNh*|n?Ck`0?bN?x;Dh8R!aWc3QhPGugJ}w$^EHEzQ z%{6e5B#z>m2Kf$0CJMzVj(k%oMg)LSoXe$P899S$#B4{8h{|WvjtxheBTei-b}z$P z^*}D}4fAmU)26Nu*e1ZPJ0j6Z&k*PR%v@wwWf!ol8W@Ek z?Z(0uIt2=^+wuWxkJ<_L^=Mc~hFn_x6>-qD0P*-G;qbsp>{01``$B@c>O-<`My3knmU|j;(DecgpxlcPAW6;i2kn5RcCu3mVLv$ z@059&eiS1SU=05Y4k5x<+G?R5wqubM4RK=;&qfw^SBCo=d3#7Q3%NjCON^6Jo%hgc z@r+jTq!=bw0ij^?cgPM=h!!pY4p1pRgwECm1y1j<;YmGX{@3qX3t**Gww(AN0E=;W zKBB@K3d5gPj8*_c8+R(qJw4_BWgzJ>Q)NsF_iyct@j)g#ewg13k-aCq^RB`_^Os1X ze-!4OHH2mcrTW(11KY;{2lC8g@c?_7R9cAy_h4ghlNybX=L3(z6R+?TQSmf7sFoz5 zXy9C@Ecfs-S@Kl!zvu~xxoFs*g1UKU6){{+wy|KVzLO+#LKCJ$`1*L_?K1lw^W%B! zjno(er{I7Yg-cVyB<&b>t5i?o@-}IjUZMEdSdb&vl7UQwVtKsXN@0Q2T)u9PfzZbfo&rKcRV6Tyw)|_>laB^D3C)x@S+%`o6tU`s9cQSH#rL zxfw@69+y)h#?bP9vBT;`d#dYTiZ?Es!dEQ~KddVu;ZWz05)ZL1W-WDk+%)+j?(9?5 z(EW!|XIRN$AGPUlT=O5jP;~93EsP^h6HYOJ&`uzXN}B)^vAX7$MJ`C%qu!XbpFeJzke_mI z^rc5&op%2Gl~#^`_Eq^z_r`mbv7h=Vm^9?SOER3+UsD7Fd#BG3Ndc6(0)RjmzMjSK z!M`36o5g_#YXqi!*JYC2A@bgQV^yp8h%bijkIf5CnaWb@8k3bm zf9lZ`e8@=0-TWT)I?%mCUoF}M=s`)Bcr=JB9j!z}SVVQ&e8uX663-OZ{Q>Vn!~wp& z3aU4(DTxvBE%00uIv~+MUl>4bAH8a9veiPf{q+8pNKly|U1qDwM+`Who)CkXlSS{6YBbJ$s(R4W? z^JKjwoCzZksYf5QWgKYYM>T{QjtYW7T7Ma*w%@0midQCzygPW&Hqu`3RmCj3D=f}h zT&~5IHe{8t6mB{{o2nw|U+7)BoVuWRzli&~-T)XqA_YYZQ^Q^@3Y|aHQ&^a=(xItq z;@iRl_{I48*7^2T-16!}H_qbO9EU_rm%zwZ`jd!5|E+eU8*DfL0HYvufPnyb0I{(E0T3hr9^QEkGViP!I+yn6|A&@;iv5%S`<5?Y z|4;ja>|f;nZ2$fI0{)l&FZ2K4SN5N<|NMG){fqqP>%ad$1V5{Pjr{oc zxBvh9|ML0!NB{r*Z~y<5pWr{g|L%YP=`dnF|K$4t=s}EHRr|dNKB4vx>b}q!2m3?h z!`FXcFD4)Ce`mkO_^kEs@&3SH)PKPFCi4^ai>SZ*ui8&x-_`%$djR=({^$1d&d=;$ z?Ei4R#eU6tR(mae)_&Q1D0whke_xJm&2>IbT)*Xq|Npb>qlhPp;pu|U`yRcya=&Q) zBmUC)%6`%NyZ_qtzvlty>HhoKZ~2e-FaQ7j{sjM1{cFGLe&Ksp{_lJ?{@DNjCu^e> zo6Z={TJ~ZtLk8eI>|3rrMwsvB}n{ufq@SQVy5hZ2v z<28--?9hS>6lLFxp1|P^f{$SG8aS24(*FAJkETO2GO=h;S8wPhXx6fHg4Md%)sQK=}@lxj*f7doR6FF1mfQZ1jm`rpp-Y`|}@pKK$R z5z7eWgmS_;VH~iISVt@wj*mOo6uH{x*S_TtGmjrsg3y?HrYr_4Ye-2c>}+G4{&6gN(wJB#70~j>tBzw(*jVEJO>DC;6vU8PyP!dk zXirna{<&}CagMobOeC-p8w0O?QRrcTSH0t|zhAYfks<&ux~uyM1L;`axmt5>vZR{* zy0bR(`5y5u6PO^DZm!4Rf3GFzqh-{f-X%DkuLwByU*K86AvV$9QyP(kVHidZ(YnS? zXVy>fJ^fMY$&U|jg`~{q0y4en_Ft}Afwo|qj6yC$2BxJlNFuND9tm8&!k zHL}dWP$7K!*=WIz-fO3h-!#|bh(CMPoEI$re&u{CeNO_3A!eJ~5yzkWlQm^Hs zbl?$w7i9YR+hU zAkDJ`++q+Trqrkt&DMamnddc9+;s*{<<;uclxj*fB@;praB?k5Sn|5>U|u3cKbYD+ z2L)KprcHns1$q#0&8~Si37##j^CzqVgIa97_G^-WxSzyMu!-;To3VUh0?rv+uF9aw z3f)m$du1-5J-e1MN*I-EhLxwLy@7awlu|97$~0yJ+kc_cKO$2v!2ZupX6$v-5aCm! zXYC}bGXm&x=R?JKS^AxscU94d&PHRS(CSVytE_qR_Why3!xe=D2aAh1z~+B~hj_s# z%(uy5;~ORq_Y1s9*rr5gLkN_b%YYULi6x^d=}P?F_H>;hRV>*aGmigRAVM^l)2kt) zA07OgT%oPYx|k{#7}B_n>+EiOW?s zgluCFlD-bXB*ef-XeJs>kg#FI_t|KufMgipzy*-_6ZpSl69yU6i)59fjbBDXdN55N zvdvY-u8m<(OT(yuAQ|%(05YVnR^%)vbh*%NevR*qMm>jgkXyz&Q>GdsB2tq)0oHt? z)Y(jMur`+fDun>}s--0f-h0!g`S9?b=^XyL6)=mN@~1#Mkft>Fub<1j|DNh}Kytok zXgXcVDNf-2Co5G2FFUUc9|)d3|6H+qgkhRoV=?bK|bR$yR!>xZypp-%!i~j>q7-4dw>rGDc3`f@TW=ZzyeE(c|3l- z+Jl`Hu?zbx*xA7Gi9Lunz)5=u2`N+yaqjtOStp99bAWMU@17VGE*n3BEYD?lEC@DXE07CCrDoLUN_BwX`AkzXOg0(b%b8AssKx{r~#+6nxJY@31C2sR~fFkyYK% z6!W|>3%2UsQkU%QH`L_^(D=fmluM2I4eS5dhT&*9;(|-M7dP-Ji3#g~bmoCI(~|-i zB@@UAE<&uxSkFM#%0_%ChA9HZZ&66y*TN9P+vF4kQ@X=86qSJWb#u8Hz>J!=w`647 z68bKS5c!lS?+D=Vv7}Mx1!UC8K~3W@Juhyorp2tR{F_IWlBf&2UsM^q2Dzf4De&mv z^C})g|x5`oP``JI@>1h?C@up8aaQwr>-~k zpu)A0dIxqtx=nlmE}G*~nE4Dqjn<@1{#pH@Mt?92j}tpn;^lnNka``mU0e+~@+ zbSIE7PY8}Z9h+3C7YJJ5tkHu z9jZ6YhOLX;fN}uH=p=T$sMMFfeLWHLv;_mvS9uSfwgYIS08LWW;@YOHD|lp0i- z?Jgc)#FHj}E8)7V(P?od#cb&4`}{G?uIWnp^E{{?R8tH%Jj;&1TK=Wm8EV~DZyW;$ z0x52DxZ|bMFk=@!CrH4=)^z>whi7)a^Zon6PpISoew}ZCTP`L() z%^Gyp88DYLunFxbi#BW4{o8J@GCydl>xbRTk8^9L@c_)aK27~m=2Be{LQxH6Mn<>S zKPKJ^{&>IHqs1H`3ObL$t~8qtgdiiB;E-e&tm>`Wv%nH%dIkRn%+(3Uh5Wua6l=Se zW}ozO7Jo#L789cD;b*gL!KDscN$#Jxt&XJyKOrW9WEqa+{72Q)o?BfLI~o5@FpO9Z zhJSf+bFs-fsG~R$w}~rXl4#?$PT>UTf|GHBND_GJtT;1G6RX|4YMYSNTOQ`noZ{ek zR(8Xd6(pOGtt+*oJ%0N^YAFu@Hm22zlaFGn^Xgv*a>FW^H@vQ9D2G9gtj2!R5S8}k z+XmDt8pEW}xV=cPiwO5xGurR*HTP{_J++SB{dx3+!$oz=TwL7l^!1s}JLsHGgnHBf zYBM1{0jJNP!%+mjvN~zBIAq?dW!L1~R5w71%OxqVp4m)4d&l7ZT?B36D4vs$uQ2mD z0SZ1jC+5~%m$IWa~%>@*>%$Q$tSR{rU=Nw-Rtc=76-^@f2=vkbxDbW1n>`Bn zD~k696B44jrhlc8pUosobFb~U%UD&%1CeHBnvZ5)E=Y6&q!obI9;22}i>y=mR?%I| zAsHx6PMyB<77kOCu$pGI8E&gaBxlgd3?MV>E+0hWXCE8ELgN}>Kwdgk$PlbUVPR)) zwfS50slkFLrsxscXjTpIiGX}rBh4hC`@mQi7Jw{V`vsuwM~hV&-iM;v(!r@TG~{ia zSR3$f1$R9_)5z#%D}-qPw`7m2`df;t#*+xSQ+XteoHPeW!4R`OX#kV%fbZM_DSV<) zkXQ;8q$PW)NrBntE`0{?frfZG9uTb|u~s$s#X_XGR*Rl-5w;r@V#2idU1InZcMbDS z9~^wc)dsFE=n&f~Cd)!f98WOC1Dx(IWcbppJhNF?8*>P)8WCS+3fLL*TthJ5lYxsH z^2x*s%)^pnT5MRoG9lhE8Cy||V(7MN`Fc#19c(38XR&`q_QDHN_>#gZbV7yPP=O57 z+CCB@4~+kz97K~0d;?1?RHC$>?EWy23FS4fu0sy+vrt~1qrmQ_HjTNXNv*C+&Yu)f zt?6$#1JxKG`Zp9YzaJ?24!Lxpo?U9W7|YQ7gxQn6hwVrY;1Na7&6~r62%Lf=m#^VP z1w8;Q)KT4RfU`Bs)LPkJy-wj;J0V&JdL?ZO zMP@35rHZpxHrmntg_AE_ZNjTs#T32mHmX+tIEmP~Tb@r>@;bhcsskfIBmwL{j=!Tl z^YHL2+2`u%> zC7L9Vg^LAF5R7#+SV}sC6AVBjesXzf61aVuX1)V>+_+(lWO&pV?R9--~uID4dScmtd;SFR1>Hp+1OZ6dbNh_c&&#Sb-Ffwop=!c1@FG^Gkjbj zt??q*hDw2?=j6;@lUNJDf1q4HaiMDcUT{sQ7A}XE=^%j>4!BW~y5RJZvxGMqU8zk~ zt@w9t^XLE_t$v+~L22o7T*nHj-XY8@!(bM6;T}hY0bE##GGocNyJJuY1xm9XTTj=O z_QnD8&wgbNFabNlf#K?)Y2PR(nuQ5lxKAlb1r!P(O5|j8MsWQoiV#?yI@cr(6ibP= zt*=ZQgh3;>!s=}>PHMzM3xmcqW(CH^P$}6aUj1N9Sdh3n=K_A007famYkZ3_RWf84 zm#JHW96m)0n9PEO99}zATK$Hrm5ADRzZ(eJK~QL&rFM`8Lxy6KIj{~d`sO4-f*_i?cHc3{Eo~WFmi+`SpBh$nvTXvt&Xx3@1ge_^Mq!0Pw2tEBEotA zVY*&`{ws*m6HyhRn!;N3aT-EuB6=w@r`&!;dN_7{0xIJe5wGp&L0{jE-kc%=|EOa& zEU<7}C^4A^MJJrio4r&KmtfP7F9=`E0tNpWgFA4Du>6(o9?m)eopW@dgc$Ncb3%un z|GZ&FglQ43IhzwXp7HoZ1 zL*`wFPpB&lN>|ZqXtWEEvB2ld4cauOMAvJhQ)FUDuevV~cd1SklmQ&NxHkd$*}Uo6 za?LKNlG&AB#eNCgr*ZH9P1HoW49C%Xy{f2nj-r5qWx754T~gJT)R(ZrKeT$Xt{E|- zNtZzAmnPLTn9-!mkWe7@IrCr1wb!P0g&C#PagqXl)z8zXyg4WoFJ?7?zPO*aU9qO7 z4}$AuY{}-FChwT*T^mmNnu|n*eu~+OXhco!eDPrCe^FWHlFu1^D_s%QlnB@{Lei~_ zs3zh@>F_8jOgK#(>H&CTbhpPL8#V>-Z>}fp>P8#o>wmpI*jUz<#tEMb6>!uE#|jryx~aOGsE-7rGe$3PPp3 zPp@{JKOUk0S*erJ7dK#j>5DduWk!T`i0P{Rqv(x_*4gx}3I`6PefZk3N;97jQLiPa zv)hq#tjV)yzdrHC*(v2Res#zPDT0F(k58{ya2~gnSzC0;hASEe zN&>^DT_@Aoj^GXlD6ZW4OqGdrq0ulEElCkjRj1cdj*}ChA zLb3!1=krqn9v)wWIxxdT)O|kUakTuCpXkxUX}TS&1!T#ZPmgg|gq8(uM$UTYP!x7J z^#BeG_bgFcJ|r(DL~&T&Fw88f+LNbAIcm>gV$xy%r{k0@-b#t!``0t@L;-iikiRu7 z93|&Sl_d;>^U1g3z7U29Eu2H6wOC~(#KN)6BT=0mc z35@4PYXb3dSYFTo@ztrib&cGtYCjS9Nu=cq&44O!WiG&O&-Js{y!P1ZyXH4vnB1f+ zwR8XUsFt?*=$)aP;A(iCqIl{2CxSE`pdB^niYmP^I#&D$&6LqVGfvtUBtXzu2#7pK zjQNWT+J~9rERzT>m__^za`g!_{94YzJTdynrt&8+idXS#{Wcq5nK3R-b|ET1M0!?H zWF)7)yNn%BVw%ruuNSU{%5RUd?8HUz{vYDur5eKUynp4%)zbn90d8B?tKfoz@^q=4$e^A=ubqb|3Ap%jTj$Ed7Ak4rxyXT_;gM2(`!BEB$SlHnIRA)Kq?^a@J_>bNrt>h9_MvAt%Cc?Yh z6l(*WSuvgfP~M~;b*9Bh zcRwwg)Z<-NQ+S8^1_6`h@T_K3jSS#n_`2?~m59%#v?ZxQrug^ z9>438a;>H3FVK>r?@TKJwj9ofK|o2T(Q(n<%1o$ugbOKr=ZvF(B2IkGmvAbNAt9XF zn4+Cs8#Ddd8mJh%731R0Q>8ANXS-EjSg0OIc@Pf>5vk`4Ol({7?wak0RRE(yxa;xR zf;w%(-!)LtUw(5f?v_ZC7etv&p*#xN(M4NRUV{9PVM`wvzXqk`6W3(b*Bu@t4rV_4 z52;X%C<`eKqG+JYt-2S;fzv9!!V8P*9m{HLfu8AH`Z+ zlNOJNHh!B{5apse#T?UZf)I+a8zo8?ctg2(fcYkjJl2iMqsli5b;NVKO1}6`$hST- z(M0au!%GXUew|-NQ<4Up1O=-gg1_5x+4?aCqtTb$c;-it<^-~|KI?06N4>RweNm-> z26S5%@ye(i-|Je^@Ohx2z$}jbK&`J(6Zh1XMm!fL7{tC2*22&%YA{@k(+3^lNWIX% zOCo*|e#?Js8~AtRBpIP}_18G~urR4b>B-GuQ)dn@Da;8S7ZxU31F(gr?PQ9HQEs67 zkl_buI*$(gSNvJ{H$N+VxSB=0*?XEfGMu;&KN*H~X#y#@JhX>HYleOc+44yg(;Weht4hBOOhk9h;`Nc!%jis1{NEyb-y#^l` z*mYra>Jn@0%x0u&p@k`&RLuQJiAbo3ih-YW!1v0<0MdKNjx3fCw|7R zlf)lkaPB!DQ4YQ|>dr_7LK4O`yN2^TL_HA#m?e#8U5(+W|EC+3{tK7odxF6v4R&O@y*XJ14w@5o z=O6MYIeM<8O@cJq;F&`YND z@twJ*k*=KbsJ1nPcSi4u|8K)(BO_{EhS{t1a)utF6;8}@E*fU{+o;P_=EZixk5kXv zBl`s6+~!hRhKZYv=SE)qwEU@}TXSaje{Fl#=INyd=e1~j33J8J73r@_P9_SqmHuOE zG;>Fb636QrxUZvHW(qIajdN)C^_F4RfuU<*P0aOW{HA0*Eb?ZtRv2lJ@4!TP1B zsall>^mM>;5b4M7KnQ8Z9zZVvV7(){=aV<)rZ7HUDNTIoO)a3c_YJU6anihDI=y3 zva@6-pf>PH?!laR1O2UKsbT?ENAcWje2akTKoNll3_|E;Gd$N7A7oP>P6|6JivwaK zL$pBBP-7MOx;p>i4q#3j1Z?ULJ3zPfaGx~5eX)9-ti6?t6Il48_+tUdf7STlRWTy_ zY>7+A9qu=YZ{kTQG28e_mP>r386+y8ezhA(=2pg$^GOSPDPpZSbJ%eH ziD>EV3oQQDwRhoKgXI!Ug%<87_LR}o;m!<;jEfM=p; zSkKspzfR06^dQAG6w=QNs|}c`8U^Fq&_>J&?L8&&-UHTiO!qF`s3X|dGY#@Ctf|y_ zB(wvP)WfCHb@<^aR~k6ANK(E8Oj(WKioQ*|`DyPU)S-$HfUABNljpYI17B-ua6Bf| zufz0%IqCkr3@{HgPi7Ixe|BiFB8xp3(@maleVVSSFBt63RyclVaSEF=&@eBvYjo}= zW2wogF39__5kwvkuOz#7PIInCD(W9!gOo2Qx`qZiJ3xvo0kp7~OCs>W6|!CogK#~1 z==!~qBwZUIO_K7`(o(?JaQH@7m)#izou|x6;uEv~x~!1taHid}8yTRPP~Lh7(@I1J z&j}?yNLGb={izzZNsgD$1rO=d&mIV5bjW)P!JeaPnJcCz228~Px)&t%SO-c}9T~=@ zMQlEEQtc@E_Q_|I$hs_^KHa9dbgK_JvwOd2{qi0x;Fh9NLzm{0Ej?;)ZH@t^7w_zL zDx99eK#|S}GqcLsR>px#1mntl#N&E99Q(Sqe$cM$cznhzI6y=Eglw?h3WlAgQ8@(tZwq(-b$fkZHBV%54OAqRR zHCumu%XdqsZAWJa?__y0h_n0yBQsfmxeNVr5uy6=Bkr7vUH-!rI0Q)R;qB-3R?TC~ zWOfRuycrF})F$eqEJM82Q>T2zFKLo;AN3M^xJLLg-r}8G5(QSfy8wYHQYRN&pu&wf z0hUJ6_|=-nhHjFZ3A{MCVc*BB4D>i|wwT`ogk{ThP9?Oso~5L__4H zxV;69!t(@V(BH4mv1dxPt@P?45s7;6QD>ienq5b5y(kxl;<8LTdH5ttT``mUM@IY? znl)QUjQmU<;zAqdfF~`X-EULVJcF?z+eo9NGsy?qTko^^(2^30gD8+J`cv;bWg4^1 z=6PHxUN}sbY{p2z?A40)4x!IyHuT>fF2`RJE8pR10}Wl~<|UBD+<_3;2GOQTt4<@{ z`{r#-5H#unvFJUXekKcMv1ZKxP4etEv#h zvC)V!;UY-I`R<7XO(FOdh~7zp(u<)+_h7=uEX`PDt6GD*BTZ|n!Y(!#DbEQ8%_9!I zPdb#L*}L4QzlTPQ_>U|cok`_xC00L&~?C^2IuMXf^lRuO}?$G>E@))zY)d0<|88|lN}{!(rpXFWd+7cNdgRyeso;oLqpi^q$# zoat+ctJiAaE1%gGW3O!LRIMZL^u`m{8%|3;&`7_B(nR^nGQ0TsuTSybwQ?}$Egdvj zS=>0kY;c{gjx+XKR_pz?t9-*n4Of=X`#fy?G!Z{rMAGv13}p=Dovi0P;DZxUX6K5r z{}_2-^h=16%N4z8N;fiab>7s`A5iN&4KFyMNb5t*MTS=fZ_WnkC)7vir1kX#44Z+Q z!)gSVKIIo4j#;VjhNfe5r~Uiw#ZPZ~_8>RAn>!(1^NJ|QXYGa)IBTpiG4Q|_Rev3% zPP-I>Smn@1Z$>EQqFoRgg}&Tne6s4l&IbK84CXLt^%1^gn!d-?TuJW6GlM3oHLJ*Y zUW?hg^)WKs>tsaMkWFLpD@uR$ZBBoNF}}=KO592)1~Q&dn+tI)AXbBx!Pgv)-D&gz zL9*6q4Gq>g)kO(I@bjlw7RxOn3i0gQieL9d|L$o$p+ajK{Z>K(Cm(%A$h#=?_*tAf zAE&{)%H~|HiFWMtNXPRFchubtvlx9HLJ5*W3Yx#F&S_psO>!6PdeoGKJ=tqzjU&0B zkVmk0)sD2I+A^3(fI3&878`m)@ixuCMikn(2Jst2;!tt=w^Lvyuml%>&k98!ImzEZ zxg@Z{;PJdQEU8vl@xxl6on75kVSDCj&G2Sxqx+9;%9Ft)-N#lwXt&ZOu;A9YKIB(r z0s{sJDC%V~?KIw@kc#sLQeGs{})_|kcr4nu+Q{M;Z=_+<&>JSOBWV9#zs#Mr0&#@kkU zMalwT$V~{%;Ujr_v9jhEqi!RkaKBsZZE%(CoC_U(OUA(}eF{mT3N|ao3CV?shPhQW z(TWX5KyBKMey{2Aau!WQZ~QUX=8{+n$m+7ngAfQb$`VMP1$rwD9Qd0-^iLzDmLHO2 z@l{IHECI{BaIC^^v}<~oNFoop+iMN#DRB!dH*u^p^y+l_P92f27ueZ~5Pc^8A7E1^Xzo&Bt)^Vp|cxX*+o#F~ty&xn^^S&ILJOg#xe zeCdYUIxs*eoC>%|O1ISP%LnSp!4|}(s?Y(0wgLSCeGx5X&(#*0`E2=yt)JZM{IY}^ zh4-0GGo!Txg0UMIIM6N*fBc#_P!(p`D`dG*lw`K5Al~gvDjELDVC&aib=O^W*Ijkj zU3J%6uRUV4rdINCgz0V@J9FC4wona+pC`ZpBG~g{ZeEjs2{=54)dlvOtxd3}lJ;=b zrGcd@s1GIIh$bPJ{8PO#`6rO8)sHxCTOosaVC2>FuIr39zp_5(4-nrDxZ*t$tNnC` zmVS(`a-99xq z{-9=Y!oi+xrT<5bsmoASFks}i)RrhAFbT>{8vn>EeSj^5TRf&t#EveAr=E)=+! zWqTn14(LSvqOO-V_^@iPm{-qKAMStd#Jf`Q;HQuhK5y@r$hGLKWEdu{2S@p;m4!#- z4*iW~V#uUZ%+L2m&az!snt${XY*xvr(u+lRfOj*!=3o<)n2F#R@g758xM(y&SnycY z-}$Rb&2Wmr%}~Nb*jnjvqA7xU zi`-4d;?r)2@SXwn4^L~&_NDWz(g)HWINmJS!xg3m_s`gU8vj*-1Bb}klJdeo5ac%` z<w$PWfbkrpRnB#}hKLdKIGMS4_=j)WFX$Ra-0(47t2LX|;`Oh|a5ZjV=PS(JVz3 zPIS1s<3o(_fFG*p`lx~5hJQM4l2^cs+%G3&N(x0|?rWN9KC4J0?2_!x61TUt3aRad zZ_?N-Nt`X0b&yFRckM4;bcBtoK##B%^cKMuyOCgw7sAM_Wz$QqS zZ4;4-0BQn0ms>M7as~NHD+E1S>++OV2zs^GYX?I_<(G-m{D@b7N%4nz2YmP;VYIpRC|hSQB4Og`!Tl%{-wa+U^;N#bR)M@|h|j zf+#Zbo4VjxlbA6BIsxKx^VPN{t+QOk7LBFA%k7nCjN$Myn=U;B#lmk3c0?_RIP+zL zOx?`Vx?PR4&jRdq>YVX>f&p@mzB(1S!w-ejd-3T`uF^(mD&2*M2*v%H2^XU9@J!xY z863Lf<&xis@w+-A3F{|_tf%Rx{o9k@`$T$L`C=&-_1<^CO;mfYV;B%Fk*lT(>Ri-Y z5woGo1TPK;G)N#BD!EjFx!KYudaeuLd?f#en*qSr2~ zpT3;RdYW3 z6Ae%cllZfeFA@fvw3`~%`(|cRY7M|2a-33YT$E$i0TuulF{kVarWjG+=@%qna5qaa z(jqx5^_3Ya^F~Wr+kKMk_RhM6wv(T^q+{r&j+w>2 z6KgDo$ZAMQHsBgVF;WatW?@=9Srh>aE`$t_h=P>yb-vft{Uokcl?%O5&0yso?Iy0h z3N(xG)Vcafh0b+v^UI9QhIG?;m5ypc&X+>B(6duRQRYey&4W2YurACm1lA^hl8Ro- z<|rjK3wjWKWMqR>cqDQ5bh;Tbo9HN=d=q7qIPgFt-+izdzg2Y8VNBwu%|yqV;weRKn9{N23kFb> zcN=a+MXpvDl2^p)$_82-|Q)A6{i0kSdBan=Cj&=rj|&xy*v|zkw&yl z*$jxUHX1{7a=J@YP^GR5Pb}Hj7^3tYuiDW;(AN}e$f4S5bAWeE9`ce?Nb)R8?_h}I zhA!>`s!#LNDJ)mb*V2APi2 zH&}IX?3tO==wbz3!mIp!`pwxto1|{CB`RR(fDoP`b$7liO7StsksSW8+Nxt~_5i1r zdB44Kjeq4my>o)EdG(D?sEt ze*jfR_m)yxb`5zD6(iq5c%(he4=lftelg7yvZ9Ms+P;WMN!xx0ktL{?6j$cc&M)9) zJrB4}a92d|Bi_u4mK{JXL~T-gO6SDoS7uNc1kebmL}HCnq-(|GdFxrMy&GQ{sg@7` zl62NYyn4{}d`$sv;T)7Y6u5pAE3mo|VM^v^#7f{hZSS`d4NKM7rQFSnH!g$1-yTxx zlcx~eiE7%2I}hj2O!J6D$HyHjp9AKj(xa)S8!^G3$-ttp+?Meu4j=@6q#I&P7Rr&qN;8u zjhmfHxwKi9d zGEX6G51&|{tr_oWV9LBg5NP!?kNIW|rrEF!G2-T?EYV{PA7la$wK~wyQ5Iq+XI%hK zwS2!QGBMIV%!1w&XLQT|AFJ;G9spZ~cs#{D^=S_c}ZY?gVWWL@R zCi`}*L|E?(yRWur#b2K)+``vJ-a(7M^j8-u~g zW&X$nIgbqN$dxCfe^1`sXl;1cO3Jc9@4kg_bEa+n5J0T%H;CEm(VJsA<=HB3XLc%U_oCRsy}h#b2B`oU8P6{t=| zHGN~vSme_E2;bZ}Hi>J~0$2>GwWxp==r`Ioww(8=Do!mj1&$3Mf1g+=E|RXWS#4&f zcCHz^JnAdH=jdT)0>LFbrlX*`cPW0W#Xs5@0WiUu#efxuR?uWMLQX!b-O3WKPGa5z ztu{qJbCk4SpPNnE&iD5Tmjfp&mkwXQe*XeevyydJz8SC*R@|f_foZEf8JPQ3ENBxL z)RdaK;ECT4kMT4N_!b-PKPDTWNI`JfjM>Ln>cJEo$67K|h;!yluvQKU=>kC1G7>eC+s zlqhkpd$Kn$Q>=eu(e=&93uurInf6VGuKmT3x)Nq?Z(@gg6xY|P-u#X>)J}g@Edm9a zi*y}j)vjwf_`v^yQeNqZ+ z>jcAYq#>lR_M)z8gyNKX%AZ7vKC&@JWhKVukQ$bYoiJ$a?Whbdog}NX470c&U_cYN z_Apjo)oRdzZo;U?fn5pC2Uze<#{3mJp*YR|j@dSr#f}0}QLGlRG8J5Sl5IA_d#ahL zaQ*(5ckeFQq6r_6Ri$g?+jTe7ms299huaLmp!&7NKn|RkBVIG2TAu)i_9@F*aRFW% z4Ogw24QiG!Ww1r`5gRT`CM08l24LOZAEKgBog^>X3rrHShR&!?K}zwRLUh=5MT~wd{f_fs?R;SK6;4;55Kl!NwQeyqZv|=J;p<+t3LS(m)S@3C*iBjx6N;whmQn^tn~QSm)P-=mj8R`!B_daN?_3vIm$6&0!sZVvzjDYl}DaO&9=d z03mb(L=j9GfK3C&7Z3|&G?LL?kRHA;7#B{zKZ7roqTP_1IdKJ&Zy66@bjASpKjT#N zb*&@c1#Rw?Ng+ZQ)K1xpEOiOIjF-7G4=iJAF@a8c5z{9bS%;Y)5Di^&Jwnjw8t743 zsL_NmR=5Kn0&pap+rYsNR8z<;b%h*b59KWnhzJx;*^8?~q-&u?W}`-n!8n=mZK%OQ ziy3ATIxCi#Lx^>bi;EM@^o;(CVt5HOnnp;5pqt9GpWq>kDs+T(&}mggVs{;U*j_{+ zesY_n_2poxOCLc?hkrMh__vuQSQIed zrMeG)pE}=LL>AbP0Gw4%w!n1Bse!1nQY3^&#*Z4|GbzCS!;&_#kB{{&kwdlBVaoXU zTDTC0MBaA}GVNsFm2@e}E~I>xy6jK;iOCXcuL~&cQ#9Rma>{IkY~kzcutqw?2WLc0 z$6QS#7lx4&dzDWer5SwvI{u~Qk^#=~m;KVvSJr=Aiu``ZQa$l-> z#+(5r2qX}#Z`34h@@5C4DcZYNd-|R4j2lIk4Ud9s%wmS?Iw%+IZWB~62&oSI65(p; z4Xf#-cOd90A+~*XiGm=n12C5;S&;w>Q;MjrQ>7`ge?G`KXBtNY%3vW^PE99Y4d?bW z?CA~|#Cr=4iM*piX5GW>;9FFK3brrG`XX*_)z$=FC5qB4mN7pGgrqQ1@zeSlHGKxM zT{nIoEp0idT`F0!`wO|dETaE2@Bg)QdqS4^-q>BQ6IR8QfV9)yI9Il@K`D||djB&m zs!}1&W&AB+fHmr#jwAbTCGn!0R|g-5B@`L1fZXj>01_0Ze{k=RVN4KErSj@HvVty7 z51D{)PqCF#GhzK%N;OU%fEP3Y5O(^y9&@Q~DUUH1Eq?#GGdLn}HW4ce1W}kUxTN4~ z7;LvFxiYc2drDQq=OxPK=~fF=V4@NK*uv%+sH!8VODS7w?W&A}!@*+Xy!)6Fh^USY zJh_%bLeXN?CCuq#bY^vtgf?1a&c$fH|Cq^{grzz=cohan`f!P}0=ZKTHk^d5`%PP5 zj%sUYwbj;{oc0S8Tdn`_Fn{yNSs}C{XxY-}X9^>nrottxq&?Cme%sklxzF)9d`=I% zqm}W&ctnl(RpVqy9IuWGu8eZ16CIZS_Gx>fTO>~=P#&tLz9)#snCV!0W@(1+>V~A* zS}i;{-;jwki`y~H$$Jd_j1sy636VprInE_2)pqC3R!rHh$P=)aqDAC!{G2{pUB;jv zgT#3d&8QbfwlMs@g1d}%)|Anly2w|S1F%k>SHvc~jxhPqB<9S`8t)s~E!l;Fm6%wXnFY?b)~DqO>X%b%gmQW>ryS zpKxB(^f!68@g3wsb`uTc;q!-$v*1qQT=NI!^r#YvIL?$)gWP2F6B_GhTNj|>>&|=2 zHys;}d`-2I6eCjp&BV#zFkXWUHM1U~`(bws31?{+E&N`6Q9^Bpyp{3W4dx>R3Z~pW z$>+>^fVRCB$7Rf-LW{-hg!*)je%u_w$Zu(RZ8P*%KF7#*P&2q1&JS58^4=3DM_Sv(!XLeGX5V9e zcpDSK5P=OTAZ*HFmP_V>HPe2@RK4$@W3Ig#*-=AT*L8`?n9}`r8U_rYu?wFZGAI*u zkFKp=ht8fxJ7@xW$1EU1S`KDvs)Ys~Kp$ zP|<)zIw7Cb8bf{cEJT5CzJ`75kyMO5F|`)-@}x8FLNEfuw{Ue>ES1iCq2D~GB_!V8 z{V0K5;o@77K^0C@XQluR=2%WH2ithrRg)uydRFm>Y*{|flo<2_UQO8QLA0oF>&1QL z?Wl1OeErJO#M@3I6%3U^OVmtWi`p{uk1w?tu@SL7K}>!@=2vWdF6W-%F_E2j{xYdm zvWeM#UIpr)?J zlApd$x9X$NWxWnk#^JTkGz^JeJXfQ7ktvQf%NX^yZ(3#sLO>4c_0HXA(YzO zeF{y@@p!U7yeBlCu(LXoJcm6l&HP)nAsf!}5N#s%WFdbGv~tDV68yTk1K85EE=<^N z;iItxt`&kx;nH@{YynwT7?n0uUUqEOQ2na!@=>Zm+7$=>Gofgm$HVCHYVO!%k08g$oAB{xYWrZs~`cyfg74z;jM@Z~vDJN;XoOg}7I&U(X?A zhOavo|M(}RkocJz<+YSEFpic;Hp+$0f0c53R`A5VV*Yrl2j)gdLQ;cST&^s&Qvl+e zso(9h73r)HBvE=X!Ro4O;&_aCj+KX|W|(gNsA^4>qR=o&=sLz3lG_G%A>W)jhBm*gu0yTmhW0wp zn!OA9DFz{l&LNM9r3z9gtLWeXEw}iWzNp|5Y@nnJWpA{u9rI_Y!Q+nta>6oqwBQ8O zLM)Ow05Y06!@Ls`+G5A6r%|$4^G@2g?@E79Lr|y9!exYe6wl8JjSRygVoPVfj+fcS zM3)Y}L6aarG}R>svAKQcUzD(9VaPu3?omdhF7H`${BJ-y&UuzU!s~?x{bW(jRMfmO z!w{3IbX{XRN3Lw!K=0eQ8#i#4>7)dQEfuMyYHv|InqG;I81$s?zU;M&Fi4Va=<+8S zum%&c0;DNIFMLX?;6WVJ+%0NS@Sq$xX*#s%Gy4T`0b~Y2Qa!3sn3oVHU#$-e=^kT2 zOqQL43CF}Z@PT|C#Cz^JM2%aaNSmbPadc~lljZ2a?h<5eS_AD#3%bxOLW+X+# z|JopNcU4Y|vSB?2xSzD9J9ou#DKR8$j&Al%t;C;6fq}0P)!lYrN-IVr^2f+R6=77Z z01`2~qFGcZJC6h%WxIIBwQBQIAJ&X=$J7N_2JPmW>ZK_dPNv8569W@3G{%cIvLR#( z&We{%C#>ass<(7d3$W13bsJwW8UFXfeKz%zy$>v2rGr%GqAdk|P$Q>nqBBNn)d*M> z5E$vbX8(XdV2+aOuKB_cZ zWO8#_7;=WDzP!VreJ8hf#ONyuiD3TLE0=lNFED~j&+!kBV|&uuC-wKzKhf9n<#=Td zF|#f}y7bKcNV4nQ_R3j`kz^p_|$;aJsl|1gln?NrJoob0@_!#6mf&S)D4`4nmp3P=LU{8iO2TR2J+N@(sQrRD-8V}$gGZ3JpYVXiuMuReSSlLqG=RA|Dx#xD-Q;aSxRlT`sx`Hr@nXA0A=Nki-a@xW=SUsSFvCp&7v zAiH7bGh?W#W}ct{zW-BM;0B0+V>EwD^B0?{&mA<+7U|MsMQ>wKhs^Gl;o79X**eUsKmhi+;BGLZNH}cjb(-Y2^ei4 zHM`~;*s(_(hBDZ-L?T(n(%QIW7YXHw(OeANOHV3FVC864O>H4+jNqFqZ@-F)F{-WJ z{qY%T&^DAs|KD}TsPQx&WabSSsCTTt6vy51IP?6f8@V?ly`os|Zm5q0=w_&@vu%12 zR8%9|!W1qhg)A4f588C*)REW9?j7wTA(H43Mb~_Bo2RYLP5Otz>%^pX1cyjty(*R_ zkaSgDoSyw#Ea&Mq4YOhrCT@{vRlLQ--X?Hnkg+xaxS6CO+<8x7?wC+H$uw+^;l~5D zkQ%NRNi*jSQp)3odSzwJ*B-1BT_5ByeWRP6L= z^VF;Vs!Vd%ncO3e(;qW_*N_{_)*5)4X5ax*0(c8B=yzDwmGspIrm8N}pS`E>sJ55X z*Q;)<+5KEu1S^C!sM)H+0F4~yrP~gY`2@NRP29opZQDLGUs#a`{NV)|cLMTqk_1)e zUFJ1Al?&_1?hQ?VaO09Voh<>;c)B12#4(Y8S8XuuZJP7NzSNe^4OP@2{~y$*)j+}30fM@g zw94+@)R#wf2-NaMG6)432$6-)W7QWuYa*6GUB0D_hlljr(vbGtmgg9}a4+zbG;uU9 z305&c7d>#do=q6*O1CK(+InQ@v8K~pvMJXB=|CxX|Bnq%n$$}d<+(Z%B|~2}*wN{a zO?>g6>1Efd;hDgU;zc-G2PR%Lbu1C#c|NilE?m7@p%mka}w!9 z`}pluYNCl{gwW3~;m2L2*mnu~+Raqh`3czDz`N;`6+5Br{0nd{Ao&-JaEr{o7y1Gf zuiRS7tFPwR;YHu?aubWxWCbEjbsAQcl2#$`sLJzB#?0SMY36r`79SA}Lu}|Vx%eyh zq#44Zn8+jQ`pyv_8Dio7KfNj>EcBA)0{q^>M%-d&(sk;f<6f*wI(b-;48I$^MDXtF zrZ37TZUc1RhkwLx+rW;=K<=v7&Tb2BiVC@CL#t>%@Jv|!-X$N(0&~6yq$o#aT6D70 z15L8e$qJZVGEWl}8E5~c?evw)_x6|>i|f`xuAnZp*(3`aBCw?!?TfQam{6NRgkBxBgM2x<9_OwEMT z!F>PNC+nSp4)sA#gHrR&d&_Zx#B+5RwpxLtZf<_eWw{|MwKqAM$8YJj>v{Y17KJG+ zJ$=ggne3#vI#Pzg?FBPeUz4A0pK!j&8j&0*xKdLY@z#5{C8@`gO!Hb|NHycFQf`kE z=NA9_9vbeX*J-V0?=mpqwX!ly1m_m<-Yi00}sfUgIW`E@|JcCw?E zePVW?1!X@#jGEdGkmw#GrTt+*uE2~#?}G^zp&m_&$g)^+Hn6l~4&c}@FvDxqX&>Ue zj)$*3NX;n}F3T-iE%8#E2d~TIAK^>_H$xS#uL-tvXJK_Uk5hRX- zTws{*Ttt%tfD)q<`NAZp&8}79W*Qpg3TkU;w-&nt%F|}r+eYSME573zEsomo#5O#u$C-c7z>7=Sd zz(z){S^dlJe?0BED>NCjU+KB`&?|rZxMESVl+-Q3M7K?a#z-|sgQFkDe1MO_>4LhH zLH<9fO{#$%{eK_SpRIhHhX@%N=v7wyAeqPKs~w(D>8ENxISmtDj_Pws;0knbo85fGp^!9+aXBcC4D$3~$z@>~RQtvJ69RAkag>;pZ; z*aJhyWdusXSE&~CKNObZO!OR|X5JSY7u?E$;<7xS^7TeMBT0~REYOakT4*L?T2#E+c%S6)UYDaHCCL*S1i7}-4 zae1E!XE{>m&(WXZqp!3U{xflkN@6aOI3KWXQaf!tz|SiZb&M+M!Ks+uUPLO@N*tc` z=HMN@+eU^(R8d?{4_J%c<6{B`Ri+n&7Q1#M^Kqo6FS9!0=E)H3pIDo4$DzTX;Ip+) zHX{%u^1ARG5P_TxUl%#S$Qnv|Bz4c4b@jPm^7KsD^6kCQHSO^qeuOnnI{G{FR9mC$ z@(7f1WUBbRuuMUGzAI0kD_I9e&5?~(-5n)9M>TlO4_&I)N?%w$>RrRiH%mH4;yBqt zOjV5$vaW2RL#cp5Iu^y{yWv!h;m@TP#AzRnD~l))JFVniTOgcKwFo)QPN#IZOv8a2 zvZ1;{SModiC*xv^ds;7WO>SaU7b2AOek$7En~gfo4TBdq7?bU!AV)w;bDb_{k@?atmnA)2ahzp( zFzXw(Cd+PTf^4ibPF)^wSGWjGHteX$7n!%!=#96ceG5~s zC^^VN>&&X7SwqbA@s%jP$Tdv+3f@Ug4J%z0>Vs`-RHZ`m^x@5}g_!24c&S{{hx>EM zi|%{KjxGlp6I_8=-cU^F?>!dDtW^gy2;=qjz zzBWZl$g5+cz`hq7>zFTM3W3lu{iJ0=ggq}&sSs49NHDAsx)}lKTc;I-;bC{!%}^Rl zqG$l|*0U^r#iNB}`LlPkc|KnujhBDMuqCTHvhAl;xGn1z?z6;CSK?;Ts{^MlphTkv z2CYGYS2YpH&ieMc@t)WEl#zxnxf+u{{-ZROzlf-1V!>@Xcjm`9CxW6A2D*++~ODs;EFy% z%>Mh<>zOnd=XDG5ME3qWc1+M|-Ef_Mb;_VVn5XJCfF3(_{8oK4STF<``LL{7Lb<9F z-D9Z^Wh2s?Mh!bMj=k4(fB*x6E+VvNu1H*vxgZ#KgQ1Yj234|NGou10h&REv&+9f~TTkXKH8e||)?k$}q!i}k(eL`zNo;aZlsOEL zVTRrbj(Lj9N;?4yxX~8Fo*Hu~A!Vp_vgHv0&jxw2RfDi>92%2&Yeu^c_Rm~sncQPK}s4iusrJ-PK$U71dj=RN{krL>SanqsqiH5%+=^N%@Q+HvqRs_}+b=sSC{i8)Dh=2Vn_x%wk&kr4$eb3xiFQ}<2}Y0C ztLp~%imb9Y7jN1eI*q;_p@VyHb!&dfh!aeP73z9i-+EE+Ejq^bZ&bvtHZO zSae%S6z5;O_yo}_$XhFBL$6O1qk8rP`?~T3mU`<5yRl2e~AtJ zE(T<1BRzSJL#Lkv(IN;$MMe| z@^O2N8+DaV%Mx0Be2!NJGMPp75tV!e$y`66!TNw3EuDFT#C2$CBK6ljKtz`x)rKEN zqp32pY)0MoZOlcI+qX{3r;9Ufyk{t=MHBrDrlsQM)+#^d)!6`*0W3Iz&x@H2q&R#e zNo6=NgaRa4JTquN}p6S?A;h7>-1gHs662S|{0YiKc4oLyCEk{VG zG2*Z4t7n?9O{z$Qe(OL$Y5A`i%r5&C*bMS1t-0BzO*pV)bc3oO{VvCa@}sOL@zmxW z!!>CR*4V^Z5YMnuQbf9>`JTxuWg7^iy>n#r-m8D^o*R=q7>dB<3& zeUI$8l^`uZ^>Wliv0DcZBsMk^!eI~Y_=Rt9XF#Vv{C@7kz;(mT;&j)r+ba;ZobDiryl?5n zKyI}h?Ttcr(cGkP1%cv-7#21s)iujuqh-yUA#%kxi*3X zT36PDQQ%$RUQ>m+wt-P`5|g^UM?yzQ?MuG_u?0-}PMo&{T}HqzIr$hr9&zG{&rsal z2UT(9HShZ;^#KR!+$l(wmU`7@;5?^jR_&To>#6(o}CR;=MSz&4?NEITjKA~Tnj6$py{W8gp$Q@kB=$4@ht+!@;=qECF)IJ-~ zRzQ6eR(!|%PzhFAVrV(jcC{szJYj^l|4V8>JkSR97!^CR6#62)x**7~_g33Y=ZSgY z+A%G=w$HJLa;`%k7K$JU$?XBYG5$-gQKpC=hu8gu2g=SgT*A?&=FgBDy4jY*WLP}O z=)0cK+m>fBE99JOxagqj`kru1(Pig?0^1E`SXkFc@Q*|zD;q}}{Q@AaYQ}J32iamp z>84VK%`*U}53jSKaM5gH8NW640>N{oU(#V!9lmEhsmpQG>*=*9b(-OwEBpGdAS|nI zf6JloKWd?fCqcJ{1_Lxze`ypTV5yqpNW|C<<84l&k#DJZVyVKOz6L^Ea>|+9nm-k4 zetT{MBjz=l{Zc+c(vTY_>&u;e(6)r494C_heTJD#;1ZV!kFY9O-XCb|2LbkN-dfL{ zOa3o1Iz%j0s6b;iLibOD+y7w1 z6=htnIv)+Grzq%n90Soovu^mLr+lfNbuzqHoJIpO&kN20WjU>UbcO=I5u#mKJ15xX zQc%QvgHq}PVrD0F5^NH=JgKn*#9sV9ZM5^}IF}E!*(wGBSM==u`I7 zNyr)W*YA5nlGl=$HuGu2a58ug=rSOv3^ZWk7w^+m0@p+mgdrlB;GHcQ>(s{t>1YT* zhpFQWIO|NwQ=U54HZEM>{{$JN7~6aakGI_4l-sCLVVWI+D^L zh1o(zf(d6ya*K%$DMx{%%60~`yPte8#_hWN>sH_K-2&i_FUF>5az?mr7-Yf#G#tV0k`ALCg?F{!I$mc}eD!gxX6x|l=<_U0|o8{=eqHky3(m4!hkv@#(+ zSmd+2EUqFLg&~bw)o>?xNC@gJf@ButZq*bv0xb28p>JKNUaY)2WqSp$KJuu1ngJKj zW+WlgqV#43u_$X2E^q-u1f8!RneD}htsuTlevkL4JM38N$XPk+xo+Ypo)(cd^^k@{ zVYo(shZ%>jX$yv_<}m(Aqbsp`Y8WJDD_Pl78WSK1S$}Mm}cHRjPO0=22?pha3?;QLAp7maUc6V9tw$ zM#2W}kL*q+AD%(flV#B~==62@=eFZ&oa~bnf|ODAN;(?O;EOTu1-+zIALYPJ8IBc( z5RdV$(zCVKH-8u0MONdi%3$2;aqCCr!<^V2g4BmGIqUtuP$6u`%tlU|qs>>-c&+CwnU2EwwDdnHrg%PSkvQt{dm^Tkph+kv z!b(dhDX;fS7H>-GfwM7WfKu>VY(M@~8=UoM?NP75lWt&9F)S9)8vHA*=L5$b08wlO zRe*v>jv4AHYshg*hoDLE@5_P~=U$92G76xLjh#EizCg}jKz??Yrm;-w_V>-Xwe~6G z0aSEN%cOVDRv)rLHV`C_sHj)QJiIN+=UIXC@|T)l3R+DomWNg9ZWt$xKVr8si7`lL zfnfw~4Ve2+9GvCv7)PpwFSs0$P1+01Ss=NQomfH05pz`90pdxnUBs4!=r0Z&9?Z*! zS8zq9kLvH9;Tmc@*fw1yQ#x(YX#gn)2ZRDpW(&7qgdC>%kPQw%!U3h;Vj#BYUd0iP z+f#H_&5`qt&4Jx0=QazIt|GJ1XG6kto9hC2+9?=M=o2}xiJHcmQb|7?>EnomhZMeK zZEThkXUl@n1G4)8AVU#y-s?yIy)}1mmq+?))(*9$3(!0$%isQ}h@&6qQT5@)+Z$7U zmw1wgYzF2%SNXhwwxt;bJJ-Os6*fl4EAOHzMRdF`KrLXOdo z9796291Nz-N64mpyT^sW+oSZG@*Eg|(+#*|tcwl<$p#bT=o499n8N z`mPX8{NJ{-hU70G@|WeAZg9N#wnnSu>mbmXYTi$2MJXkH&@t|2}SrU>i6% zc3&N#Ew+=&(2E|dK$y{Bfhq08cI!m@sdOgC%E{DU-8R76{Wm+y&B$zP{zL~m2hY=s z9vh}DFqK|p@s;?>#PwBIGga@zLm1Zbl4phAl=;*LiegaoPs;73sM%5C@mNn`5V-}% zMcfM{Ahos%U&)|mZ$V3fF0)T9>W_|FKg_d|`V2cJ>VpDxl^?4_HJ?x9s23+ zeFS6KxpGoPGhUfNkn3x-8wJs|@ZG0GaU~4fU#HSHw8vYQEmtpa=N-N@yFpq0njci> zUg=}55!!{ooXws)!~d~W+h&al#Pr{HwSFbw6?%4#)#Vhi)N_-n?*BF&A4B8qLN)QB zH`dSTB1Yy_nLQu%f{MN>4Z>Q73B7W?>V1}WHIu2sF*1Cx-m~}}c7*kmd9<#Zvt)vr zT|s&|fc$@j|LSAgsma711&$iVE2z;U=(pa9>opD3i=3Y2M^sGi2t>Vb)g$(IZfvrE zK%>)@L{_A5baxWx*|mHCwD4gKlzaud6>QBi5>L?p@k#wAPUb)C?%LCqnh0&d2k_p^ z`}4)0y^ajbd=IqG?Wp7&^X9w+wPR$cHq+eGJU$0~Ho}bFv!pV>lM@_3S4<6F^E-8X zD8p!r5`&^9+w!D)W`_iIl0&TAn%)|u!ONIrlIo7t2<)+&2EkD2jjXW$BkNos;Yly#pqH8nH^aaMRy0bRoek{MDM5#;u>0Io0-MWFov+F?Wu_qN?*=sen{ zZ5~gk870vRv(2t_pji`1(l3+&al}la%2gW1lf^Fz)pcWb3pzWR&kVZt!ivyoP$Xa~ z;)8q?s=n*Hi;Fk+0TmQaq)1xx;bfT5q#io)?Z8wqnuUe!?it36PR=X2rb48&aFoS9 zo}(t_`#F)>q>c=xz;fHkL??@z7-FAk7CkV!Uu?K_7Fh8BZV&Q2y+i+4SQ=|?( z0Gg1YaiD2c24y2YW_Qq^QVK!gv(zFFz|C-*hkPPZ+-z%N+q#mR)1ZI6q_kYtBO~Nu zYO5*42`cX|1(Q@m5;{vD&~@({+e2e6$#u3^RZ|oq<3f z0WeGntc*N_yz7-!dMa`z9muyNR67GE?frxyHc5Y%-nzdjM^w@c6!|iybomYPhgp$r zzlK=wK*6SJ5=@UzjP{AfzRjSZ1+(-fF2yqamV3Dp%D%Ksy)rh3yHf+;42mre& zG>^#q(fvN#3#oOtPlr|)ZHqA*_%uJO@|+hMoyX06u~@he9=~BO9*9&iD&ua;nnd#~ zx5tN1$-Uxx^qO5KUU*CS9H-%uA{eq@Bvh8<2;MG3^p6&GDPnTi4J4=vY^B9nl%D## zSTs%xGCL;05_=BPhMg#QwQ+5d=|r#SW*ao1D32{BvV1>p_U+ATrZ{R1X6IM|8r&=U zz3U-&UoZDO7u!lxwz1_ZRtUsHadBaLY1R_tQy5_rZiGx&HD9V|pf*ka65B)xn93u4 zEQ_H(Cuk>@WI+ZcYZ9yP7I?2vkX1l5*f1oS@yIYi{6doV^#n(3h(|diFmh|xqAsU) zsNM6e0FX4A9#y2ar+*5WHKdOMiZBde`b=2zjD?c1{$gedk}OI-Z1zyKCnm7~L?w(# zCMHpI#U-+OnhZ0%VVk2waow*sboi8Aw(I%4kaOa&JSiNA(Cs=>dY{TvU!m`317oYU z*TOAg);!1Mo*Y|YuQxn|ajeRD$Z>r^BemsEe>hWudI-sk_9IX~#Ud$x9zD?-B$1JIR#vp z&lz_J6}9VjQT|QP2C$_-C~E;Jj(oR6yYV(NW2G+B)gz9E%}o5E3izg@$;}C?GK6f5 z{X7b2N22mjL~59K&nI}CRZgz4_VrXmMY?F)5w_-pNHs3vQ`HUIS#<@uovKDW7g`Dn z)t^XI6c*^y7$vm7gjoB&=E!bJ@##(UHm#XVxY$5<e~-kJlSh!tu&go)pw%$CPdaC}KTU-ma7bB|-!rTwt&qM*)m_~MQ% zZeypNI-3-ZGc<`wYnHC{XfqXp%HvqFtj>=Qp1`i9PR~Jlpt10wTlDH+y2u3+ver7V ze~h@xdxzjMB)iN;T_jKGLwlj(KC^L(1GuIOQbSg_5#^t4d+gNDqAg~K-)K0?$y$7M z2pG*zR?;zvOeS1w9e_8r^?l-LA3|(1USo<5e>MlgGSdcv#nbGON1fHI2ogR5V~3!6{!Ph!anMeDdPu~# z1hYib)&B*@74AVuhAU}u4A*2s=V(TsouCgAL{|C-=Q72+&Gh~%JpRpNbh#_J2N;@D zt}x<1J?|bTpwN2-1!XLJ2w9}6aT+0f7H89reUv}rM=FfqpFG8BIj9e!lL&@U`3g;R z5MbZ<`^!1>G&xomEX+rc-rg_*|9*gwM{J8Tg8^If69 z6Uw;OM&9JfALa|j1xj<9PFuL|E5JnV>>{hS`8*MFRWD;(H05bJPm$?TWS*^wpu zt6gDuKpHDY$3g`xnjT%3dy*(_9(E`mdHswGcb9=ARwyjS`@&;SwvEwG?E`8YlCMycIISMF z_?$WiupQ8gC-x3zedh3$&QNire;)vN{NHcKw>LKn9PpN`FRB0Y)QTX>bo^@9Uhh3q zi}YH;ja-hUB>7@r+8J&Q(_gQd!3u2YUv9XCA(-i>Rm7W*UZIPg98m36&ru0}J_DK6>cTp-+~qX$BeZwKhE4!hz!a#d(pDOpMsoqvsEySBLxG_P4wNq`vA3RwnXam=fkGzf_n%%5}LIqvM$ zauUF*RNe9^MHy3Ade=ktmR0+j70ACU82p|++4~f@Mc~02fx_l)Tptj=zPw2#?b@=% z8yDc-_FD~>QG7{RHI)jn6dLpxibsy1E%Un@Ow~ZkZV{!q4#i5@)b@Y_<+Xkk4LR);?iKIyNf>dnD zPO$?og4z?nchyyz3`&VvizY*eRdG?T`5DwkRkUR>MPm|4f28o86i&%o0Av?=`yFm3 z{tuBI_68ue6j>ArXzX@Z%NH)c{3$-7;?U(t3)#7z;S+lbA02Kdh`I}OEG}Y`?2&aa z4<5#BoCP6$!gaPT9+d-U+vs3>WxZ{0hl@REJA|qU{+-it{4=2yUS|S0Z2?YMHUXxw z9ATFv)Cx*OLhTj+&HhyQ$Px>>N?N-b7t`Qz!LO)K>MKhs(xY-5x0PRNyewf5d<9jE zkelzMRNdb^SBGJ$lib2eURO+%8W}LaUc$K`&ABIy@#XGA`-PR}=>?d0=DXRX`DJr| zqWc9?LAjQW;pdPo#`#OL5#`(d!>KI9vXNqDph}_buWPjnq~l|BUup$GuF9X`+zi>j z`bA8xrGd1+hi_W-871IoH_CLk#;7aRLYfh;Ue~2E)s&jCe?w-vU@{9(C^{vwMEc4I zBc_we>r#`bhV(h1)t5zgZ)jyMVR+jJ{oTFacoZNsgH?FgLLtZE!vq@u435;I26rFb zOm{N?4x3<7(mdms=A@F^R5v3fGzJyc?8;0kg=8jGzZz9F$C%F( zvvJy;(2eBTPvwQqy9ze_Gx-&(8KtD>NeDCHC~Z9tu9Z1Xq$#5#hufVEZrh-dLlDO9 zQ^1#=68dQ7Jyt^ zHK~(t`t!13U{=>^rji^ZAku6g)z7S>52uLVLc$vyp94{#HWwT@eO zi5Qqs>-++Q%DBwUcca55uYqQk7X6xwDACRFZVD?CgP$o4)@e7=f>?~YptRL;ojK}* zPLw=pUsw+gCirGd`VBq}7;c086DVB=OEB2Y3vDeUJjTN-*SHW@>D%Wwtz5IHQsdIK@hx)7eL zq}J=1n3Z4-mQUtv%LM3oE$6ZUUowcm&FO@mRaa}FPP|P9aFa_P%&YG?h8bf zSChOHJU!Mf`1U*%jKAoE_wMY{i4EEZmlJJkzlRx-W~&W#wP;@cke z#}*JAyE7%maLlcOt(=jj5?V%68Bzi=AFVrjl5{y{x=jXt85Qew{%UFXh_FNfNSA$2 z`a1%wSd4ZjLMVfYzv}zpN5M))#?7AC-==e30JX&5aXM}ixF!8PM;%Xx2%n4NFX>_& zNRvgqSJjAw&OQX>6>Loolx#)A>jGorl+S%5gRnGLQ!H%tnQ7qa_MH~6MyRMIaD0QV374{Nw18G#;Hueu&_Wfmh_*CKpKE~3#KFW4^ zNOT^P62tl$#NN%>pgy>IeAU(j1p4dX)}~_ovHpy}_(DA%*J3El>_`ZxXHV5C0IGlr zKqepo0&CL&Qt9&05WkSVACZwIEY?=qF6bP~6L;#pLI1!ZgFqZ(Q*hKDVq;(%<8WHz zEAR*c`G`AuN4hScLc}l%z+D(}UtghG6q*lX)4f^o1Qzgc%0=SkSguN}THPCUZ zqN^7;{d0iGodMHdIWY%G1T4K*Vq+2%<~uW{9B*^#k#*~yKtqsZS!NOR6J+iHInRm; zcqtTAfSXS3f?AWX+&FBoQYsHONHCwIk#%_6`}=JBQi$ga(?Tdm&4S&)lCp;S zsl3+D9BXU9Yc2Bpllfx{?PX`r_`jflL|wBoUCg+JEP~VOd{& z?dlZrOLAKlzU*FnlYlio9)%p;CzH@tUD6sC$zVVP1Ap8o79w&iKAUn(8*lR#YfeyZ zb#>B-_%58HHV4^AFZ7A7?27Ie>Y)?zed;piLDLrV{W^)O8Wlgbh!IWZhoF^f=^{}2TNE6FW2>; zeGfuUrO4>{NOQJo3UdxzoV3N6>1ocBS-T%n5 z&Gplp>(|u+6bPI70ptQO^8Q$m(g5HcE|Wa_eES?JYgzQHeB$!@OsIW->)|ecM7?)^ zmF&Pfh<$(UoqQO4f3HpUe0i=cEkR{@_dJe#f3N=e!UXQD{Ng9Ft8ak;pk}}fp9C{( z08FjyHT`E};`OXpUV?9_1fEfQ4q6WO*H<_MigK#Yz>iDhF*O61q$~+w{m(_&f{@b7 zQ-|xa=%A)|p4<|%Y(Pzg88QWC)bO2D)o0+RNgL97hxR0i+N!AceEO8?P_GwM z4nl*fBIN!xseR&9p<1QcWU*9!Ib@nI*V+uorqQ**y~z>XVy-jybx=UZPdipR_$-~$ zwuKk7$u4dZfKK~d0WL2;XGR!zda+g?!h`Hx-%UEb65*{To84E|wNPu5#lvr1Um>k7 zccgDTi?CL6$-OX5!+t8nqD1q;BFa0yDyKcSd zz>LHQRp=bKYhz-X27FF1HUUd}$V}VO zRNf2ZmXq@i%0K5yMaV_X>s<~Dc}QS%=B3bMo`B>S2B$G;h^}0nzxUYy-vRW$fD`X0 zs56j}%3I#YP1O`I^74#Y}#2B-aKC8 z2q4f&0ob2SeMCT=;v319<0vY=@wGf0fKvy!4bKA^&60aSWDE>=q&BLjVlASVa7)4o z(yCb6Sc?-7zUY=-m2$63n+C_(XC|!YZ0~q+*}tC8e9{I~2bvK+T4(7j|sYU z-R+?u1=8Vf7G*iRquO-^{+<{$4=b#`u2nE!A;|H;aNf|49Lm=#;o9@a zcHtg^wDcWWT$4gUrMWl;HAFpBWIoG%O`|`6%9lAl`dfOc&9p>s_+hnz#`r#6v4w*|D-K;Op-zX4BbW?*Lb>TSAVP>=88DDW?A^) zO+je`d9mE!>?a#z@80Dv|tYM5U;vwCcH*W#tkPM(qa-**5ElzduFh7!l3nMjuvh1C4T zf`wKrR<5lk`Z@`>>l*Cr={fn_K97ywAj8p8>%pj^DeIDxhU}-y@akhL!)d+zNUBX- z_aSv(?`inDv605Cc=Or18o%qhhrj*0G2T-bmy=3UECa}}=3GXp?99mMwAzA#ry zd^`C>rcyJI|4ecQ4;N9QR)OOC?bh-+$F;olW=Hj5)t)4omfBpaGSsx=^Bv9UVMVMW znWn{R_P3uaVyjFGD?<^%L^3ToNRtAxgQ>Ah%ia3=t_x0Y_4^#TcC#$$7g%#G-b#6z zQbzc2lcItSZ@nA!`(5tWx1WQZ#wUc$&k%X9IlOya-HohA@U>~;^(~BNzw0dQkxl=U zeK|}G(e@Tv8-)_&(MZjsXFi?+c7c*Lq=QY`uMH|zx90AbA$6{TARg$uDnVXT25P|t zz${(F1&Y{E%t@{?S|j5I&u_B)_r>CBG=v2>>J7|i>CloeP-YGQ^^+aG# zIW1Q@J-o<{Iu`H~Kb|niE?$Rv@!dwzw2LDb?KWB!LJxe1Mj5iVr@`yY#m;Uoqq8nV z=uABu!Cf`>)K=#_lAtD1xE%;YB*2AHg z7a^{OS&}%7vlJ1>F3kE;Ak)cW2ybS9ns~m=+ub zRnz-9mRf&MSf&923?E6|P*quDYOv3)w=*CpLJ7(rXwb&CcOoP`m%TQR!V5C;sqVx^ z+>JsH)N}((O!sL$6?dzq8zmc)yB!tbwgy8#hQWsfkiIZPz@3<8qg2DFOx&)oeaBX- zF#j-wF*2;qkAv&GVx)RgNMW@ws zarVfpC(#X8M8kC!gUz{|1t!)*73r+!H-|d^US1$KddXmUBGiSWDyM`W4|cUJ%rwSQ^c`sOoX$TbLn(jn){{SL;W!Ai-GY z{x~yT)DM&ACzm`rx2&LeC^}@aQY&{7AU$OjTDP-rPkPYv?ZqD*;_!EE-PkyO z2Df7g!#5IpNg|i<-BJt*Nmk-QuI_9MpMJ}zWige&yyf+rEq$8{Jp5GDb`h?l`ZtxnF*fFbj0N7G}1s zR0Ya1$?thYnbPzp7b=(VFE|flRkvT+a%aQjNxihn9L%C#G*fvWY*1_crrI{nw^P?) zp647@p=#&O2(_xYq1wv`0xuLC-Tsu4=W^(k^K6(dYl^bUI~tH?@n7Ic4eL|sM$gw7 z9po$bnhKNnl8t(?qq_U7PeE7Z@mP%*!uIT3ZF<@l*m3I_XXx~)?Ws{Hv|YKG)vwXLHTN9y>99hF!xUn2tY9|P@sGXaNoZ-2fFdDJ^M4G6;K)UNtGt{k(SyC<_DWO=aW;Tt=H=nx38+n` z?x=YE;IcfO$Og4CEWzoARu2^r2B$Gf1p{KlVV-x6B+0V3y#a+`dFBfm5beq)=jN}Z z#GUY87na^WFCQq0+fj4fp0Z-s6v4^~Xn5dppYl_er+FI`^Qmslbl%9jwalli){ayP z_KB_aaY0LB|aD*^b<6M6eB!kBfGdCc3<(Q8YryC6WdHwzN09bhKMkB5B; zJ`E5&7or@pM*e$TjeRIA_GOCn^it!`^Pu_5G(2R~YwKT#nmcz-6EK=b21=KtoUmJn z^XLU+J3qt@@gKGY`~Wck?y&xYZHzF{BMJ_reB2jbM~nsE(SE{k>TmYOQ(wW1B<1oX z#d0;wu0w9Il?So}h)y&X#(ou|Q;B*B#@lJ)R$S@Dj+Zatr z|K4=&ww|s48<0}P;c^zJi}ue@z^F;?abMxkUvkXhk+t4czt#4N71y*N>$y2)j9ZKl zKv6Uy+efg%%JTTqRFd1?mLha!(<*ywCHA*z`2`v-iKd<(H8p$r8!S`z^6zg-DZa6W zn~4+jBbpRX7AFQjiyW{+WNUc+Mv*(&HmLW=+WzO+}yQRuVr%qIAkTlgb;|8Y&3(^Z}PqlNeS4A_?>7p^I7P}`NZS7SNZd$v)=<9d?tDxr4lV#}r~Qm#}co|*I?x5ATN z=R*@&G}0%fs`g3;P%El>sda;C1o**c*cGa(Nz5%njKBTlFPlIJU|%Q;u>W?V_Efrt z<=@BuA4i4928!qhPOOM@9(WXbIb7!z-yZ?h1my^bC02Ls=6&`SM@6NhwP^(*hO*Pi z<^Jm^h?BMn6FNa7HlrbV0`FeGCED;}1ETYBxc`G0Zr=}Vg0KI@jHsdqHP$C};m^1c zkFW^`r-U#u6eRpjQ*AcDa_j0U^ZI|v47t(qo~sK!N(5AK=fNzklAR1SlTDY_%DmCEst^KEDmW zrfuDEd=jiv-rJ5}rr*C%VxeEQE2h3JKa!z*zCYm4K*c^GzV9>{8eJSg3PHYl`7}an z4QVhUz=--nYxT;-@XD~%pOVhoE>LFdmql!I)OZckE*xpx+b)ptx0F;^f0`z^R|AP0 zVgxlVcO#tp5DUiztHUy7-bh8Z2U&5sMe)z1v42i8k(N(q9za!4`FBfFnNcqe!E@R@ zi;2e9qV~Z1k?rFA=>|%L?9-w`H@vj`pH>cA^a!?4^rI5En5^*Ac{+}Jn(wE-1i##DkfG6Gx8gZpHLsM*5qsO9`hG=NR z-KOjJEtD@&nZ0f9QM_-8<^vBQbNxj)wJDQG*r)KX6B=!^DVEKk!rnt$?GYo3QSqA> z4njAS!zk86IeQ8ZlhlUPNg`*BsT_#P`r{`~W`)nQU6tOp@c==*(8jrwSBv0``2o)JUr2X)c{Lw;## zvguo8QZ!IMLz$39%zw!-I=RPW+~0CWsB*j8MeT5Z!BD`I^xxb>($Z;}1F#?B0*Pdb z2-7Vn@)0L};NB8b=4ogS+X7OG(!{6f_NpW@X87d@YwR8e@d zAq>X3W~Uh6O%>MSR^N!kw>zN%0w&Oj+>6>7x%qTGP(4?7%B-XoYVLZ8WC1YM+)zJz zqMCQy8?qi0xg($-%sI1QOrE=GZFQ~j<%GDl>L|iGqdX(6p=M@JNfbT z&#p&VB0O7{mIPE{_Akj7N|<@xh*?A}A_H#bI;}CeFvx|!y>3j? zTH4$9CYjA`T?g&E)hfzXEt#)mN|+4KkC~k{AS0~WHylUy`sFIq#n#5}6!a%eO;a~y z8WWZo+tLlmE9{*mF)ELDN|>)48Hq08)C#?tfEo#541p`n{V#SEpFX1Z9jRwc)3O{- zy)Epk7=ry2y4}mYOCXSS{)x%Z1Me{M4I+}^Fl7b{zRx2@94+zKAju^8Dp44{L`Z`x z`-~R+p{;2(!XW;c_3PY+(V+{}6Mv3@k~^ZsiPkl2=r~nltai-@n$cT3H6ceFPp^zr z1)dm#_PMbJW!ppS)yn641i6{Vj>^t^*`=Udp+orZtBkN5G!_ATrzQiRyL`i#6O_LW zE;WA{8M%M3wR8m1@<`R|@1031-!43SKF_Z4q(wg)weH}uYV-Yy%^%+BLga97AglNc zZ#>tmTF{5vhx;XSt7QM8Si;$IOA40?BA_4l;^m5>A*aRN0{%Q_b?!y}_M zfWmNpBDqI^hH79k+QIE+>g*dnBWZ$VZ_hc41~im< z|CF$R;A-p~mYiF_ULd1$z{{j!uewWa2T{Pq(?^eCwag@L;8i=T_}dF&Z!22ng$2 zfRe1JbmE+c#?%$?nPoi_gt3cQ_O0e~^LH41{9AjHQSz!Qe-fhiv@LA=bYyC-Ik@Ll ze52K>2j3s+gF4falLHn3-@|XnZk@zJ`)N9sSoAc*w%9iROp7os0Yc1%v5&*+4d7*n z*FqcNX}g!;J^q~bX29dkPbO`*CGhsD9y{0?F0$J^LRlL7Es!D+EH3<^I&;NXc}DJY z&qL&>F=ekV7{LFRAq=BhY&s3guOB;LcC{dQax*juwy{Pip+#xdRODwOvqsoFXr~p@ z+CJ$`yX}yJS#vXq(#a8;+@=T$TElT@^?Lv1e{aMnX~7EFF{Y+;J{J@~HjA0y#pQ{(bP3No0ERHRAo)MK*!y(#M$b;Q(3tMX&y!ox~#~k8Bo&ZVhU? zw@2_em3MPbbL62tgzl9GVKBu}ilG}!hVy6$QX|%(0hX0kS3Oo4z`Jms()y@FT7_JAy>sI=^|m;mm2F3G zDU*#nIJ-_#BbW;O(GWAX%aXK(Eyg|Y@CB=6$<6^Z-pfTQnH0!0tcJpaNms34lFoCl zBcq|%g&*^{$$7$~{ddRWRA{qWKr;aBMI#_eDP884A{wX!l~WT*GD+1hB}ykL(JsVF zgWz^;W!8Ct9t$?{0W9ESwM;)hf|ABd zhB$SI6<|U%Txol!Np@uqrb_?7!gZ3i?b!_{$qAtIplS}8-#AboI3+7c&NWjBx$UMmn)l+(P6-W}WhW zQq@7BF|RIh+dLNOU4B9%dYCbOUsnRn@k##9hiVcY&G6XcZ~4OsqFAa$YC{^csKk2B zSdRV+@Z3y?z8;sWsM5m=TCOBL`cexG=EIl=Cu%C;9BWyrUc${%l*fpY>7w4_Y9-@` zZpHEfuXtyh)#I?xWgC!l+pr951YwMoiK4M)E$=VT{YG5z-Z;&!nU7KQeKN^UZuqY% zpe4WS3ulpobAii;XZ;JkrUG+#8=S(q)B#hv9ml%%P|{@I+koE4ndjT%?4LV1Y9~xl z@l_uH3xA6k;+3NzX_3;EmsZR18f@61DB6c_H@#POb}5y zNIq3KU+$o^T|5eqRZYyXF6K1Lyo6@pYNlI58ulrh`+hH-YvYl(@uN%2kEjGOX)<8@ zsDJA1s6vx^{V3kc#0S2JINtDv;^lCdMQzPy3vP-SocEjIZ;@npN>X$PldM!ZGj|}@ zsETy%-l*_$t!k-fdO@LpZWh-%NeJ zTr%p^>y~4H3*AhwxYNfjO4z5+Op@QQO8{a=r?GCd_wvqK<|$8l-w5aO*=^5>ogBML zvCIx5S)*qdD*)dAP8dv~o_{~O?h2@h4C(h`x9^#T1=0^=nw)qsC^t=*}AAcon?+%DQZJRA6&FW_3R(=+NGverRHY zCx_Z26Z~TtEd32g*uhjFL=cSE046j%ClUzmIbd*Aa~aF6_AQukFh;JXRcZ0|+svPW zuQAXo8nxx2+3!5DZDD68r}AXy4=P{V<_Jf7dREj&X&57#3vBs9yT3V7tT%_pjTQrg zl%ZaxaNMNImf-VWSCbc36ES={>(BUg4B1bi5{o?eQQdN!7<%g5#{Mr815m*yZBab# z)>4$EGKt;n0vw&U9192e$0OSOcCQ@8jpeCid!#h6yRgw4&ABa0z6HJEJ|p%ft<#q{ zODFB%RrZ&j)~lsk(p;AZVz!`QNpW$SR6A6yy{<2|k^++4XT7 zHomq9<8e4V72I3YhH|kh%uDKRTzo0+j9~2vGlAuU(%-eMhgL+DK+zT)r}T2?eJ&;y zo1z3>`M;zx=Y;wN5ynn>v7W(03f4(}4@5F%am)EW>Fx;fHY;A8hait>IZUR7@G`b% z!FGpG&{TjTg-dII)g}d2k{VD$YA9yok;IUmu^oQ=I!ZNQe((a=h1>ntL4+i#N$D;n zL?oT_O_A5sZ7s;`rW|P(&SKv-Pij3-%3lDWi!+wQ$!qcu8MwBQ9J;11w=FtiM(aFk zrcddH$Fb$x_KN<*?y^L=M-~;Q3&X20 z$(l@c1qpiKn-JjP5u7I+dbSxoqnrb=-MIZN$e`e0l`1?Z`jsh7NQjxvgh_U4?DSqj z8W{xX#P-`kat%L>oCs(wuq(bsLG?np3M^w%?I%V34wbud3%wviRW0N<4I)HQY|Ht-K?Ul^ zBoa%xV^m9j+nxGrG2-B(q`$tv7Dl6c*DF~xeN^WH){k&_AD&O?RwPvvn48mMlb1B_ zWx+|d=ELO}YSgEK%IY{w#hkeZKTzt~ndCeKwsAsc;mEYQ1R;#H5bPEU^N@%rWu=;R zOb;Y}d;fvRRDj8h7>6!CwI)Sl2FYBp75i9_Oz3J|Cx%Pb^-X~eHjSz*mvZMe0T~6oN3IOvC{)~^Q{b) zc=d4D8>Qw?{)irf^GCQWGxkL)okh-735SQr6!`A}9C+nhIJ#j8DF$U1L%BA|S^Wqf zz8g0VMZCkS8SgWFRsW*=OHiA5hmhAs?ts*7@(H;YH>B;3Y|5AYuhJk?1E+p~w$ zlBtM~U%5;)Sx3^C#}qaPcv7@CwaY*@lIeIf#1BW)cpd8bEyV25JDIT0PqI|qNs3RA zV^NxOu`*cpsWxL4$RQDy=*J372*lsvn&vKTVXp+%o8E@x$?bZa>_{w~ow=u{tWMrV zBn)ZEhuls}N5JA^VU9pE5Dg%Oj#S8ig!wS>qKGRV<%A<-17ped>(BFedpI3;++*+L z`jLflY~?WPK!YkZb2^ z0rH}ARtB5hxw0DSU={>cisvW{gDImr{uz-2IA^@&y~&9jb+LS|k5+!*wVE?{pM)~? zN_q;S4njmb=?&5_NEaPX=2h;!jspd)IyDhJ4f=g0h1@^lg1=dMTDsh1fk^o(``Xh& z{}A-@ZPT;sir->085uw^pQ=A<;-vEya5WO*CvL{6E5y>F>2q-xaUpc~w}|7I;XWhN zG?FG6+T5rqV{0>k8VOZy_c~O6Nb%i&B|(XWh0Q&(c+-Z8R8MskkJblBd^egQ%n~Cp z9sWKZIIx9-o|fyv*Q?}ABU`K2^a{7pIlHg*$@#0?545_rIYN5$mHByEkm*tt5W0Q# zl)MLws2{PVt6l1*JtY{;6$EPFXgk;)D4!01eUTOq@Bjd?87zOEGZ|0f(Adq9N^OIc z>P@)LnUu_M(Etbj&!cY~sPP-(A59z9A?HL%SXXNMRl|1MUqf|KX>DS{Xbtfj&B0U< z3|L@Tk}PLaGw#c2MxezV+A!6ncXqHcf2fV7H--W!lmYk!z-BgbnKXOMk|m0e18`XC z1;;uurITU!{F#0IPVZ`16y)Rk7uq5GS9vZJ3vctI>90bu+9bf~7!*1gI$t#fbkH_t zHAqs8TE;`~uQ`lPh)X)LB}|rc<8dSb!k#{W?xb@J5;7 z1f6}Vb5;ce>#tfzQ!R39^BvFzBlgER<`=GN6*)y3N)OV#2<>!icWld#n^JtZ7At6( zPF&l`=TteZ91guDJZ!FNB^GqqafJPmbq;hzks+v6NjRWE6V~sx^PH`Eo~7H*MsGJ2 z$kuH+7^Z@IvNH6Mw%GxJj6UoxVX^b8KAUmTf*(s{-QW;;l2ty>V^$8F)?q9*Z&Ib3 z(4aYLoVvlLp7@=yO)|WRs$#?*z0{}7qq^@BI%HAa7m*sYbj~fkTfO`wGEC@489BY0 zqqV~!c)?n%8Km}i+sz!)yAo4MsWAk0Z=+Nh(9H^0Jvj4P;%XU4^o~FQ5iq@?sV5m^ zr1p2(o`*C^aOq(F6Y`*|FCxkFOva-wfDr75qG7~PyszK%=U+(SSZzvYR{2Qz>X|5% zdLHMhZ8RZgg;;kMHS5&yv3j>}zKR?d%lD=<2`7fJ24`2D;$w9`97G+$nf_jE|CV>; zFg75Q8r4oa;|_+ncSRkv<7AbGWIcEYKE4+R|HC5UOWVMvrECXexeom<3;ImuG!Q}` zoqqDHJk-@vx^(69>*?Sd27fES&EKOjgn`UE%H$*0_1%k!F71RjFW%B;4VVe}z|iCg zqwfu}uvG3C-M~Kbg}1Uz$`xeeqzsnoQ7CsvAdYo4?$+=3OM6KWagC#APrQ|F*{u~D z{nIQnWM2t3X|EFE8rPqjwb2ck4HkKbni%91B^Dlt-2m#fm*_l1`m}KX9YF))rvdhjcwIfn&X5vih|E$CU(OpN`LIA*1JxpG6tn; zn!q$b86Vm|F0C^Wlgf_^M+UT3-}0*`%#LDw;o-@^BXUbfqUo)vMM;zY3d+k7w6S)+ z-B0x>RcbBOX6PbD?Yuh_>E5iB)caGAX=wf>3FE3h@Dv6{g;TZLVjLWsFuPD-c z%KF4pcF3`MSBTcqn@dS+GIbob(}ZAS+@qq3D{^Uw2l_-2K^)h3ea;(tXtd?T`zvWL z$HZ}JL`Ed*vXgqI_xJEv3q#ZXB4s+@<;IwSu%g6=Y)MVNcZ95Il^=*V*^j&WM%X`p zDT0;|93=non^LI}KmP+ZSipoj2!eaE`23eP-~jUwVWn?sfl!(sU@QRQziS9?226Ir zZT2hXVuF&i8x{>n+XZb z6)oEPjNojW4P$e&0vC`C-6J!~b7Zlkv4M6^-dn+&h zDIthPWe1D_#Gc&GPG{|1*ad@d;YdC&Qc=bBuIOI3^iqrRl^+_*xcpL4 zuARVNW(c5!Rq|Gy0L>+C5eKn{qjWa!huViOjLbl5#|-Qg=N}qHsm!MT@#cOenb!b* zvhedg2NN9y{iDC-9Wu;QxHx`JO4eBB_O8%JJl{qbZtjL!I3aT^JzP5Lor$Y>NqEM? z!&fMFZvZhm0w`g|C!p=0nV?p%XUE|$gCr(a-qo6v^jl}!?i3+wdo{JE6>=ITR3QoX zeb~kToJ5p$#PpEKp(o7-SuLZRB9?W0yu47OZ=ZiR3%Y}RlJ)u)Yy=E(fZD-0kbIX7 zxQA0>f+QJB_8`J$(x_)OFAQga9zpLemn+z>%vN|U$FV>tX(w7%D<8Qle*1MqKc9U= zxU_1kvYoJa`+$y5#b!KnaJ|q|P~H3K@k>b7li#VJz~DpP=nvEI>7cl2W!uty?Ji1T zC&`YL1#4hv?oUm=HUBq)vDO~31pqxee<+&6yw8mmHl(=kJy9r-_pGfd@HRi{pQ6x+ z?(vppa7z%>GUVMWyEPJo1{w>}ikD@|*Se_>)0BRJT~QF{kp|?!!1Z67Qd5B+WVrLkQOfdU~CKfu=iEP*T6AC*%7U^Nae%=wdP|EU15 z>tY;zT!CpU5!o-xS7tFxi$`{r>kAY*~Eu!(GcAr zXwQIQil0-3@G~XViRIV~ z-d?CPAe|v?9UP8qwetMjOj+;7Qp-MYqkk%-x$)Oq8w0C zoHy(wtH@Kuu5s5up^yvD5079schXAr_@B4GguUkFg~RJoq48V(5R+C(r7)9jpCEpN zoa(HxrdFZNDF>YT8oPnZDFr zBe21d?Pf<2I@MEM`a?0lFAV|tG)WysT;Qp9sh{)O8#Hx^9!pRlMC1p!``;Fml1I3I zW74Zj+2cjS21Ug3|AG-biJv2<^S>gOv{695ApY2{{bm)g0k9kL_^%klQyU*)*CuImKWe3D z4^AQ>+~--bVGDWHj(SVxpbzBH+P=7{?h37S&r@-+Atz$sCpEZ28&cXDTjH`17KX~I z=`o;8vz}(N4Jmwqr32SEtqv5&mQ@m1m7OxC+kq%S$ARWqJr+wf!-{R9fDjFHH6TPzSN{O z&eECy1mxuawJ(0UgZ_>|{$)$(-~oF0FGv{v+d}d`k>tNDlBoZ;h2(!C$$wcS@%?WL z$^S%>|B^_e4d@3{3J^#7Q*Vfa;@*2q-{BqJFw)~QGM$ryp0{ZBsCRSa+uAGqT-vMk77VoiO-=wRQa7Y3BREXdgJe&1 z?AoiX!wEo8*}y6aG=GOx0I2xEfGY2w07&Ws9mp{0OYY#$!8i&)Xx1x0I;UD%f|=~c zWD8iPqz_^qAb5-B2?C^pY7uV#TUnp>cOQed0|JB*fhXu|E=Z+8o}I6huK*W;PB{(X zK>?uf?O<-dz5q`)KZEQW04Y#2bVLcYervluR%FsDK)DYCVR`2)5M|v0ygCAI1lWB^M-5ua5R3tUhvpKu~<-7@-7lbAJ_QGDsf@IaaM38lf*W2d+L< zoWnYpmd*U@;c%qlFR@YIxQg-GezlesFkB;N)y~MO(A(ZgJRMO2qx5r~=u574Q5$Ut24Pi`1SyHNf-WDNUpn6k& z3i#9wMM4>jOBsbkz~UsCUO?GC*H76_ZR~M|Zd8vFxE3;WUl`OgWQzD7u@QtYC|APK z>5X+F28T&}rc@Pt!)Fm~z#X&(4h(ja8WwZvocuDxt4_vVA09RI)U5h&y@W?OW7Sgw z`x7|OVr>V$A=uai>-7953ECI!>l5>wD-I|DC`96hJljhJ0S`U3uD**vV^F8s8Agd(!ZOB_hUc!iy5P%$g8f`p(WlvY8*R6CzOM`q(Ia{Ew%wIHrybBT=b`!&BXvXMkS|o*!#amBhWvbuk^FR` z%icKKAb5I}N8%F;MQh8)l`?zNln-Z;z zt|aRPmlTpOsDfw$BQ8zwP%4V2I`}u6`LFm0#|Th- zOSn%?WdhCy*=4pkU|eSmQT5t!>UI3USHEp&1LR3j?ONrEo@%)zSqs3#lgV(|U#7ch zpQfgJ`xkC^bKobCS84Oy*+#-c?qT=hqM3*a+(S{H2oDY6y5z{=V4pq57Pp0 z{&PyyGlKV&xaiIoAOdDfmrLKrMAZEqPZ#V~Zl^#AA;0sHS}}4rBI$94De{qkJ_l-z z6R#LM>VRcQ)Yb6nqn&pH>RBcf99WIRSjV*duBN7a|g0w5w3BIA2dZ&l&Tu}L(bc&MIw>b|rWByQzmv_0HkVo@~eCdg=;@v>;@hBZpG zF@;?}lcS9d+Ey-aE8NiHzK-4FPl`V=hUZb0q%8|1Pd_6r%8l)?aXC`6zs*M;gwP7s zSxrK59?Fw!g~swFB@RCo1!U}@EB1n)eN=YbLiJ4#w&zB+F_*B7>a>0qSI z*is0mHbre7XilTeUg3P;61jpQ)ATTlRKPxOEL3s84td)XdM*lN^%~0G zCzV?eUIBjlJ=lGifV_QkI(2|d`V<>1|Ih5lh6=9DN`f>U(&yzW861^sDmqJeMV}ll zozLur=r}v;y)e^R(}{5=%VAj7M*;}tySo{8JK*5FX9Xv5vTP#o6Xd*p2!3q(H3G;5 zt`?a?jYMmrgP}Xywp&8zg#!SPSWVOIo6I&%sQ5QXM-9CUCt7iawqK1y;S&=|jN=to z(cq=BQl=-^{iQl}OAb;3)8wy$A-9-ztVt+bhzjU=TVbm}W>$=qO$XpGJwM?I4|8>& zroG8w<1&zd!b?{5;USf$V8w z4K8Nmf9B7dUQFqsjoS~D1wMGGzxmh|4&_Lge{2PA&@}y9j>|k1kFqMUN^h{MmA^VK z-KsV{U{KY)`up&4J&XKpv*EJJdR=pTA(gQ6Gr+OpY6=I%F6O3wu&&Gi?m`U<(_(u(#E`n6`{V)!7}5@x%OMP0zWN47wu@5A%CV%G)?7 z9Xv|FzM%-hpXzi+=rss3*i}JLQ5ZyVcO=9vxZaNKp=WE;MN6HAXB}aX(tH z9t*_wyc(vsh1Mge9@wcgL}5p`oyRX9F8S7lX@&3(&eDv`APZXgcfRU&%L7DW&NegxDI-4cE31t+Uo1ncJxWkKRBLxOx@ficRXsR;+9K4R2}1 zw}OQl_J~G;iATyuz@3R~{HgdI#CDQUPaqJ$L&3utvYi_#%GLEc{@YlPGcm_t^{}Dj zNKsi+Q}ej=7cx+J{}KSikX@VSg9`eY|?UwyD!+o3;o zVxP2IzX=gfq~Q_D&=6VqMCft$*PVcV;{@FDOijC__reHuO&vSmUa@@}IN?m^)4tD} z!6PmR$Uu6ipnSM7A3+4y-Enh*n>ve=Vm0S;oI--D43)H}tdHcO1dj@p0(j-P=KGC( z*hw&ElItTvwcQ<^jxlT$pUBZ>q?DwQm8TBiZa}TipI|7+Q^6!aD}P_!?go2Sbw1Wo z*=*Ia$_zx{`h~CH4Br#LiNdFArg^TLf=#okQSd_8Aj}I2U>>6GH<8Xb8Y#V%HeW?s zgpcB36EMKNdYasx_KuTWK+29NKFxysC&yv9!4= z6VjriP#k_{-X(LKQ4n`l4gHGsxm6Y8AC5CB@tUzCvFLFLE@YXN?30O(wB5%C zI^(fcq6+r5GpPGYm;v1@?VQ+5CRjlI>HRJnsd(R$$wV}vE@^k!A*4c}hto+DKv#k} z%>4=N(ZV?_!j)0#0CvPaUGX$Ni|LPSV58>-Fjo6Eo%*bfj>d+^4WGcmhGnFW>R;4B zGgOw8=R~9MSbCDBp;lIaVu2=?{6dK1%-IU>65u@;IlR7S3lr3J(#HE|oDdJ$W_6Ouo+A4Q7OyeN~b5 zlA1qG)~|e5g{6RyMd__d?{&T|u33-kuT2|B_vKYCdSi`cdi-w$X zUEfD_MB6I#Ab8BR6$7@nMhX(lCx5~aM zjjG}lU#<<{yMY>+W{o}`v&U|#<4q%qqI&@N%EhY|9DN#4#bpLVep#a!QxsWkV`H0e z$4xtdezPGTOm0tlyWdgRi@J08__rzWJecEU@KH~f{?o0p@qLt`7#tfU+}lXz8;{m2 zbQ!HlLeAn? zX!U9#SvF0-(3VH^3;+cLm?#>@3q?7j`bGQ7GHBzO_3mZ+}PkcLgrx zH}dFl^ZpJ_I_HQL2*r;Ohw~L=V4=|EE$QRE@l)iw8SDD1=V&D@6XiE)k<(O6Zcj=X zQICihH~|nCju)eHR`e6;^F4Rld4J4-z)IJ?r5INwS-!KJF&_n;=OXZc;VNE|eS+vn z?U5zK&75xHJrBu^roX#2Z=_Ki`{s^pLixupV0m~L-1NsuR+Zgo#Rx7XUcktTVe6IxwG=T1Wl=belX)#-DHXaa(k}v?TPsfa5nDDAB>z5%_=Uzxl|OQ?+#{9i(-gA)z4)4$)p?cWG&O@v>gm`8N?-W1SAQ%DM!`08~1t*^im;H#Qvr4ua^OXc*HVbFs5G*5#+%0P$ffw#jN z2`|sqr;J@$3OT zAi7)w8T9D@ZnyRMdt0{p@7RZOdRO!(H@t@!?wzN&rS#ph#|6AZ~NOVzbu0L9q`46^W_bn&Tq7 z+w&uuzn&I`rk#Kdsoc>w=D?oTwl=D87<(sJ=6C{ZJwl@E^bzn+S!QQ*W0FA8fkUv9 zhQZ_Om&ySXpy&ekW!Yo6tt43!#Aw<7D~3z3UKdD#gPh3?#eI&=hY8toe9-i8fa((B0l7$4h4DQ46&Z zdXxd8okRv==T?|V5`ISz;G(Q7)pR1VlG#mx4SAdZfHM+PW^E10&=nsTo8{G-LZfu! zN+MCkcPVb5JbJT%%F|}7emRyfWvj3&W`;|lXsI`6f-%3cT`ZkyA^5$0>CDr;*!eoSrNU&-pe6b_jT_(}Vc6w>Gzbvfu zyT3-DG}N6&m9!UBieU{oSAfI_WZ^{$@~$^x_yVC?$$F?HLWV}IfUs0RUA^ENmKT!j z#!VykPPCI)T#i%2LoE2sL-1R$EC3UUxeLg;H(O*0IY9kpMX-gUehmh4n0Oy8raW23 zFZ}A*i6_u_ptunBG2eNnvkd=jVQ)N~lR?XJ=K1G?TyP}lP`B8dv(tJAv;KH&+}ddY zuncP-Rd6jY+#1$ujCL_!Lhw$oN}^cRg2zNUMiSotRlX3gh7>U78^P`jh!%z_uYr@7 z0`+t`fFZkz`rLgDryY$l>67KSKd7;!VMFKl-*rf%f{9WADX?WF3RVWHy@_XlAyy@& zMLFk`hmjmf|8Q1oPU31<3ld5>P>Uhj1dUk&HGS=p}?W)NOyJZm@Qoj7)G+7Cno0-^0` zpG9)zEWW6FDY_phU}yOFE0UE#n^h^+!O&ll0^hh~kcP)BEx#y?kl(g5pE>A?CN@%a zp%ADyBID5Qs)bcrk}y=m!nj_3UONdJ9O6~B0@i-wO#Mh!f`t{2)4|s-s105AW>f08 z7xmt_D!3%iYn8d}+Wf}{1p@a_?mf0yE*yB16G`304WTfAZHrZOu(%`^t@}jZ0;CoF&imkxC;!Y!qE>IUs$eq| z9hrU?R{w1SL7SHL?3L7ktf-6SVVg zdeQ9+F+z=c>|6(42rm1fwAv>7>V}-W*o(xs(RnV;qfN$f0wYn(k#cwmizm`tn-W*U zvdfBKWb7VB<=Ez1IW z(9i0es!AD&#`a-%SQdw*E&;z9AF1~;wq;#YLTDiEVoo!L0P2{|#p8}AF6f-Md(@4v;*`~h-RgzQysoluU(=+v^e>xm^t006jR$Zx& zAB+pczUrQgeDCvfQy^dN7=?&IlYf1=Ykao4A~p0A9(u@hEUXi8Vd$w4KIWNDX=S6F zn)p&qwQ*fxhnKxkOWiHaELb-=u4zY*k_Y z@ZSnWu>`w4V6af*Bgsl*N(fZ0PRyku5r%JTRfGay8L2sHtlL&$+4hi)NY` z@mDm^dJz!+1DjFED>9o=M4U9br@zX%o>RBJP90FY-=&^gq08jEGL49Bu|>!Nk{O_5 z^{?PbF3kW+V|~?lSN#Ig#}~7wk`rB)4^`j`vVd=>EIwjV&K)umd@tZVLnnwP)iGJF zTF;v5Xh_;#)DQBF;ZW(-bF+|&vjKZ0rd7RU42rP*;X3w_ zA;s5gEiU}$fF@|(*cRFJw~ECz^P+XLh_5dw2+9eiV)o=>QBex}@*wJ<9eikU>{2oK zR4eJUqL~L{4ox+AQF3x(lDHFDpV7QS-{f4K?8P&}Si1RIaB%@YIH@+A}9#9C^ zDg&GmeIitefS+W7ID!;u#%IHl#oM>#zG;Zakpe{pTrf%za(vz8c{-x3rWN`^6{oVF z2=o}H@DZY#`%~sh7gdSws&Io9K%(9;!20LBCTF!tq7*}ICc7GblaH0N47vKMU!cOo@l`2m%r=zKt5UtVaEK>)PQjv48-eSAcr7{~(4T#0mT~ z+%1nJgyS$G+*c`91IV$_Al}mDVVYMNQc5BS=!Bsq1G^6Kf$aKPpXMqL1@O=WK9{wS zE~>gf^QjswrHaB4! z%Epb797I7RQtkN(M4#F?F;P)cqS(V?t;X3%q?By}KlX$K;eQSeN=>SAyiJx=Bd%j2 z7R^ym-y2ms%?cRhyaC|p!h&GMglYboSO;WT^W)}R0jp< zZs4tO_+!0mFN6&zp;Y-V<#yi1J>d zg+AL~2L1uS1ON&D#Q*?@{%^FC_XKsrBz1tZ>w!2JACR&WoA{ByuR3WXRC%a`U)L-c z{dsrhU0hOh`Cwb{Nky%o#`_!h+#e060|VJ=uxcI@4`+0egFb02n*=-vVH}0?{W=oU zoFS~K9wfxYLKh2rfFoXCg<4i^3jBnT)QUYVWki5R#L0gjw+z*U?37a>77a(Y`llNQ zdGOysRYG0E`k84H7W3HI(HPPQ39}?ztK2|noSV`AK)@6FzYuWK65I`#pLY;kZ zS;G&7=+@;jrJyRx%l;>Vv30~m3+2`SL?Hf8M)0p;f&D)j!M}zD>HlN|{~8wb{*w{> zYglmlPe$-BVF3ujKN-Qlgash%|6~OJd<#U?13>fv7zqE)%`i~B0_L2)r|v!PN;$!9 z!Z3Ur`7~$rta{$FHg_fDO^yRAUgVu^^N+}#bp}AU_Hk~(x2D;fejJ7&^(>L9s+Emg zYO_AB_xGP_`H&q z9dv6271pyFK+3$nO9{H=HE!b3nCrhZ8P)(d(R#{M{VBLuE#y_x#Z(kP+aHEt-8%-N8Qa{_m$jKoK-O4Jp)c#J5*@mvGS9JQ9Z^>AK$^N8;4F zqG_>C#w`))+03YDX@U#*;eFYFmiKVJ<>X+%`Cl>KGIq~i9c|=t#0__$jy2LKI z$^2xYXJ5`@FA5o6jlxR2^4EZujwbuLn`M10vmF|xyfXOO7BW2}Tm!L1zzIoR2oK%c zjKEyzszxe4#sgOjwkSZ_pp2#9dY?m*{B5{DE?^{RalPR|*}2F3Z;2ZZ6i{Oywqg0L z$>^2%uOR+4wuclNf1dZW+ou$5tkh}filQTu4Dx0pX;AY+>%`X{!X1x7J^!vQEae5} zHt=a#1K!Y$D+NK#EOH^f6~$>+ z5a_6_v9I7ovf3*L0M4I9w&0`7W{t~x@X?2G_ z13#1#Kx>cp6~T;AF)vDc3N#rUS8w6aq$4$sH|PU_>gaCxsJkQq@+j<3-fA@ z&eS79_39T6BTir(=O<&Q&#X_5Z<)i{60ov*vuP1Nm%cexvPt;C)Vp*WmKG}~iypJ& zDRC`yKJ{0@miAVP3&y7C#-05UU_UUYFu?k7t_Oj=of!vsl*U!fu^xp9&9f=83i} zkBlYir_%S@ly{3tqKNgP=~#;mOLAl}A)n~9W$81BNg~;v!bBj|&o=WkgEz41$dNH( zFa&7*(%fHy+Otl~Om$ki#8)AN6SQ|ED|2t`SP(04`?xBy0+TE|1)&~0EG>$0M2mPd zi{$I;%jHbxi(k}45woj%WF&BhbW^P2c#pU(g0QyHsyw6kTp0p`QGXV5WQ?oB|7Ojz!OjV|<`^H=`UkpSLvB zFdD?vSeXZ14)cQ)l?^PNcG4P6*cT~^0##|1*&}&tK~|^x(&1yvPiVwGr@^hbpcZvB zSRT_&Nx~zSVJ}djr$#w(BI@wQhvuMwD-Te*7U*;`zBA&?%mpGgZ12S?6R5ppzhmZC z2uIk9DMCJJvn8LscXo#5!jUktMbSfzL`dfYL7i>ir%rs=-?&ptpS~Fab0;pU6Mm;7 z7LoW8FP`lP89XsLC|n?;o~MyfixdSfF(11Q4cLYE<#UyB?}y5id85nBuBbS1opR}@ z^V_QV_UH}vJal-~hge!gBxJ~0e5zgJ>!4Jp=Dz{TbKmaJ-b|#;D$X;Wmd3*`#Th1j4 z+8O!TIFmHgdRHvwU@o1cTrfDe=WXe=pQk*7fQsBNcrXaBB`zPLR7|0G1`o%j_5hEKT&r(tuA$^C0}n41;in6phqcg_i!1 zzK(MVV#_mHT3Z$1qJ4>IG)wFDLhjqP&w#;p7{5iaUfFpZ3RkXG<>aftf=C0+AQNu3 z-huEqocPi4Q`%InL=I-&%&mc;la_<(cjIZV`yxl&43oqXx(-McfBiBtodfhB#1FMT zHJH1icl!k6SYNaS%c;cg=@5a|o8Cf!r~r^UfIIa6s^JELfR%L1qbU174^qp()sm|n zh>iEaYTgp1P(_#KF!qjNZ55MHNJznIC|f=n`H?==QFqOg4GG7s93kf8Vd_?jmT*;w%>`3o89!U}}6E<*z**XW5`^tGeiY;vSYFmLzJ z@muuQwp3JB3jY~YPr|z~Vp8Z^YMy&OZ4?Aw3bNyyQgUjb7 zdnA5*R?$M?LV>k2_?E|{FQipMEnz;rl6Ya56-ul78!Fe8Y_N1Z7{eE#4Eqq26!<`j zf1yA`0LUsp{eRH_z$yD%&5e=f_cDB7xI|2?iX9h6?;1rmr|B3y^r9;fU%vYLq$JfX z#|za8vESIL159pWW0Wwm2{f%)-clYi$&4B)P0??SiZ(!jvXs zS|{4rRVm2}mo4ooCZ>d*HQAes*+(qc#*U*|m)V&Y<$5L~`bu2>g)OOsvky3PJAUVi zwxXtQ6S%4jrI?spb6Cr}MIG)5WrlYdrzRvI!@;=6m+=9I>vQ~n#G*lG#I`mKA#+`4 zNkrId5rVUOXx_zMHTEU{h9W!W{-EDh8*4%@*j-{34g93*8^3~R&k{cYb%c=sA`SZg zDGLuy;68A0>J(@lcWAS-9td}Vmfoq+NL-9%)K|ud*%Ci z3uq_Xc)%a-c+LBH&uAF=>iPK&`8xBf*_!+M*<0t>iZ6ZnVP`>l`N?Y>6<_6H;l3?B zJK6Y{(bD*`c2Z?UOu~mB_~}HDc@q0Z{`rxs{rO(#Y4rO!tNHmk3z+-4()x}osa+m; z`BdYxI0}YH^dr0fd-#kxXu)@rn{?~e3~{#?9UdO~P36i;vvEJUQn&{@Xf=qIvrDI} z5h)YRA%U*Fbm{4!_5f1d3&0%CzO247zYo?I$1jnQk>G)dM97hmkuK&tI@-Ulwzp?L zg;QGzVO@V68G&tr@!Db%WQfNP(KYoDuVo={DZP|aFL$H%)_Vr-OeZ7=5z z{YkKc_E-JvSS8kztGEY<@E3b~2i6oF2M74)9jFK)iHJ3`TOgrZm5_3xig(QzC~CXq zDMWYZsfyyQupGUx980!k67K-V-|KfjY01?O(OZz7I`_Wc9TVxfMbh&2z-E-p$NprO za(ur!q0;9t3sJ<>DMsOC>8ZGeMeDC{<)*4E5|$vEY0KBSM~?r}3%8B;{AB#&-17u_ zwT;nX8dv&hjeojL`w@;2UBpIxL6WPIQzj*P+s zhZY*m|Gbm!)fK7X@^uAT?#NGqM?j)hqxQ6h7HOjK@F|`Q=X-&yb<}GGYfpPys+d>Y z<7_=L3(XhH3zXn}^|u#z{xGLm|I4-=)oPMiAR5RczUY-1&jSnQ2*L^#8cVY?Cz-sQ zQ!X_n&>Db=%TID=<(t>GW^3y_X$tyO??07a83DIHrn_u?{NeG2+3?X*+e#3QX zjr_djsM<~sZu#RMJ5^jbKdEo=DkI(L9OGRKZUU6s7Wz>Yie^5%3BGMxcEJR*s1>>j z@1db$5VZB1r?plX%Mo{z{P>or3OA~c3_ErhKLokuGKa)AQwGzBvY*<9`e@G`mkp9i z8R4Kx7sey150C5v5BVuTBAu{Z*v-$Qyq=ZDEEQQh`5JJ?OJ(6QT%o(WxAODb-Ps{j z#Qw5dSvEW2W3HKBYO>FB1>Zz>J0vBHD#-QbcU%NBD{urk3_=T&fR>_5ObCGv${d5( z+t2PX9#NPJ)-~h2I5~7?fCW9dsh;}C0Uco6eF6NSxX}#aoXAp{#X;Y>W0S(?9}CF+ zn`x;c`8TE)+}$?o8I>G6rbr{x_#AZdF<(m|xs}eM@r&3Tcq<)VD98auI#Wub`cIi4 ztT`TkE~h))iNlH3$$bem8Xdni4a+zKE9aqv-)-sI09peC% zoP0{;CL;9HNN@F_H_E1S2@Mw7eV(uhY_J)ulmzCPlB95I zWi#oQ7@(_e)2@%jvj@|n=;T#oOXr|CCPjcSUNNi$2XGQ#c=P6>uPBf58Xhdl`Q1 zNnu9FMKU0k?}0+wDv0;JE>|-#wx0(%mHhghS~v^qiF#L;80usH+EXSP0e(zxT7hOs znp8?~gC3k8PF$SWY_)$$f?x+=WZA+hkUZqZYZx*(1dCozw(;tEeG*B5 zQnPT;jr32o{c7wYU(6Y)_=&N?F8FnV$7_N;;O}zt!JysJj}ii#Im1H+HUlXlX+{2b zrA{k|?9U#1*2|(62tdVApesEvd2U*GQm;0ef*OBg^!-z@?TvJ$pd8 zq>fYXlH8B@TAA3eRIXBglMpW7@a5&|AMp$<)6IWK#0rgS15{O&b_T@pVtHg!2fun-&fi5By;1ZFS$}=l;N-110`MnYxPB z8@;gqiG{=Ue+34Kt;Kf%5ufp@c0uh3jGj3`X&UCS_qOdyRDsF5x|tK2-YbUWT0C<0 zl_r#e-O-`OSrztr%5Y19*hQUyKYyVUR5m(Mj4G~Fo77o^2TJU?5mb_-3Ri9RQ*8@e zaf^Tpz|2fKA4ch6I63T;8Db{II@hXGT=fv>$hG=oE4Iy^(DDPQXF%-c8&?{ixOY*M zI3Raf9&E2(ErYIBRU-_Kzm?Of=p%iW;TNngrz7SIBK91F`@)mz!5kvD8+i12y|M11 zJ6oWL^fuFBb~PYWR{?~Ai7UN}GJy9Em>XO^H_=D25*@RdduDMt?<}I-~ikTttF=jkp z43-kV+g3O`)~s1n zl)O5yYJii-fqaFKRRGBAG<_iCCOZHoI&FYr7>-_9`VIhy)*f5HwYV41`DoQ7~f`%Fwxqge&tk9q)FWFhiNPbj(_R|3#}a}&%s0g;dEL4Z~ntm_73 ze|kenAhsIKlF^E!nD~{eI)Q|)7dcr=P2Ts4Vt)!5*FM6^`xj6fU1#m(E&g9i7~?sl z_>SnCE#84$9dW=aDIu#KVIn^uyz#t|8YPtrn`&&H* z=x*;fNc=2s;{yjC!-d0Jkmb7Q_|(BCll< zt$0X^N26>YfYOFR<4pNB6A80gF>2mj!sO|hh*u3*{d;P-$xAj_Sz|RcP^WSgaiMW0 zWJ;2?Yv9e5O-dFdY1P1+C>a+liqWZp)=@Dnn&zWY`mUg&o7OEv#&@4XL^W-ge;?U< z2^P|@W-2(g@)RhfX33DZY2(e7{omXOk0&d=2K_iCu_P?Tpi?*ZZlIzGpN^ZPU!kH~hK6oqkd1G3y_)&yZ}a%EJ#a zZr?NC2k{+oh3(pc)CmiC$Zy%fA3l0V-J$Ii82I0KYSop;9$4Sd{w91+VC!rCSlT_~ zMbzrOR6KNycs$u{*?r@Z!;!K8;>^M>JnciS(!fZ&Se~$MAW8QzB6pb5BOf#&f13ld zl02iJL<_2j>L7)log~i|9u9A*ILqV*E~L*VSEYNNyEnSK#g!M;AJeq>whQ*Byc95l zDi8lo&!4`jj+ry|0VR}UV}=gpNG&*9yG0gt|2#)xrft-!e7@dKD~C)wt<~jBPhC6% z*@cCmW%_aQKa!xk3RMbZ?-SI2L@$ha>gC3_RaB$T&o4GNSapvPLITlF^0>JTph5Et zhb7F3TC+X&4xJ+ttO3(d20((MiAaPLt;7xBbVs8-TYMUGB_isbL`3^I7kIqn|F z`|)98yq(b%qtfUjf5)oWto39C~ayq79Dh;?uk;jZ*ld#>@y$`4_x`J z*%Z~6)CbgOWn<32@i3rflTTP<66R6i;|W|%`g{`6>740b3L1e>GiC016snr>g!aM} zyhwTlNMlq<^f(lPr`ql=K(u%0Nj6 zx*20nv&crJaPOO8s=WE7dYVq5zHaFMu^cE~*tFg01fBS*kLK1((DrjW6f!U?2xook z;W}b~3ta|R+tWJ;X}$}feZQx*EvZFwg7*~rH@!mbil`5rhaUsiW-Ci95>g8#BimG4 z-(rB`G*$pnqS*iW$eJrqm+4DTA!lb4CJ$Mg|6yMV7)a^R#|OFslr7MGV%4G|EZtX3 zZ}_nR{IFp~`?akR;<2#I$Uo4F1QSA;#oHi}2MlTeCIdIQnuved&80Di!&BI~isQbg z?~+=gy8Nmi-848I0dqO};eMm?3jit{E~dcen`+#yKgn;P_sj4F;VE30A@&rn%rEHv zBa}BHcRyi(?uB3%aoYz7teYF@PuL-etm4(UfWS+nGZlvLi!2hCtNNuxL95RrIB0Mz z%B;-136=Y@&(h!&YsS9YC%XDo4|@DzA1%b-{ik7_QySlNmg8A(UJ6QSi7FsJT-C&M zoYXrhzYXJkhS!iO1ZxB#%(0aKuwcuCZS5lfB|$>aj}@^=tSjeoe6E2XcTxiHbCY@Y zUI%5VRbE%RV`dx3YAJBnr^4SIBXtwjGkSx=;>n5z9g721x>AN9D}8ZD&&m1^GybTE zXE{UyP2}B$)>|>bgR&KQ$}DqrP&F;IWXx`sD3b1LZ&Sb-?YMr!=WZpUOt?4OT5#P3 zpynkiTG`Sf2;v{lBy>~?IUJ{=7|)6w|q`9)1UMD z5XEw11U7%n)h@a8^R=MADL{{-$X>G3NZy{gd*7J>Zw|jv=pc%W<`c=Y9}?P<<7DR}{4#9T@4(2AUXhcGwt>0F%E=L#hshBb)_qiCTYzkabI~MM)4lT7p^w zMX#h82O(%Z%m`KmhY);AXBo2aD0h4&AE9E_gEn*;d;o#mK!K@LrrXs+ho8GI^&ocu z2`@(C!x|`uv`u7{rsyHTl$RIhE9g8J-GR6S{PNw$xvF=1xf`m4F9yI}`gkC<_uL-Z z2u#CXBmaCGchaXaaOFn$s2OI-+&x*zIBaxuexhvi0NRtVxoE#&@3(cyB55OeqIlx- zdmc2!y!G|?&LauY*Sz8zT^cXA%(T*VV_2x(spVG@dZ^MyFzpm1J;jAB36#7YOY>52 zEA7pYOh(==_D*a0ZOZ5E?df$YAObwtx%Y>ROl$GJ=ovDZEAcp)O%ICZ@-VC7t!4vi#;7+zNP+J@!x*X$>DT5*zd7ShFU+79A#LV>?68|XdvDk2Cm)?texMC$5? zZZArVz67(jT-t{D_eM4@qPw;h;8I=(2-&B7smxS8w0V?%7_p42gZ*LSFwxW-us5lB zoFQtLe8P;R;;n9foK1a49SW^!^5EVvVMOYznRe&kcL(RSD2=-Vo-z&On+@M(St z#Jox`hW~A74!L~d!Iu%pBaIt6N+Iw7M zojgxL&~$oq%Opsa`$0ta<|mN4Z*lCVzQq=|&$2BG=I0UaW%5*QcZT|Q!yMZRPm zcxbLG-AQnLm}`#2UxUH|*M4lybLtl#-!RL`*?P5o0#%h*PdPkP29+o2wgX@=@I) zqc=l)i&Qh z7#2Z?tDu4DBF7vGX&$j)sw^R#>7Koo!*rVF?kA_!g1oqbIkOG26)An2OD+o3e~N4)9VAXgJFRN z__ZbddtTd#4760z$c^{eE^CCvjdKr9{L#TxPHD1dC&SDwdJRfGwO5I-2DW`n(lR5) zhLrgJjGtr_Iadm5eMOp?JX{S_*{A-S2ac+fPcc99=tn z=wJkLbgn--Ar)f6H>UWv$)>0Lxs-V=r9m3yo9GO(H6bESNOtm0gK@?sb#F;>p1cJI znp!D%!|7U_OXq8YF{GCYn4 zCfHeO)QqLnm>j~A2y-aCszFY|#ZOj6tunPlAZO{MJ{f3aF_u4GLsyLFx*;Qgq195m>Bl_R(%|j6}oMT&&?!| zdQhtqR1QGRH8@S;``-xnbzg(fjD-^PJxAWbp~wGpSqq*4Eb>cNYL`gMiT#9r(rQS@ zQnqWhD=Ja8F4cLNMy;Bt>>zr_EbE7V<L`}|qMN}Ij3yDHv%z1BJ?TxKPQlVMb{Wd((u zi%yYM%ScI5UXt*;_^MT}hq2$$t6PGtKPxuVD8(a{DaMqje6cDzXqsPS1>13f;BdA>1E2ep1;+6nG4j`7IsQ<8Le4yg_WU`HB)X-H}!_u+dwHjf4#siL@aF%aHuj|9Hvy$P2hk zc$yL6voFL^*mF09kPB;g$`b!K?bpO?NfC(Of&%~yKOc^r-40LZKIVF~*nhq-2zGL( zY<`Ic$70zdK2gPXue@b)Tzn~G-bwt#Jhcqh?2lu%Tr`Q96*k!ON^S9kkB z99*DNaP2+lY?!gPrlK>H;XqT&*q*`%yU)k*nUo&vR0gXE^oiJ~4NrKI$nF&i-doV@ zBJi)HfhQ`|NE{xP_Qs{?B4A%-qM4Z`1$VszPcy`kTl9;}fn37Q=+C2vtU~K)OKy(+ z3FmK2C7S@@YBS4A$vzNpc6pzrO(b~WBQ+NrHHXRZ5SkGjIeoEBpfyvuML35*SGxwI zeywQy0AL1n+iKb|@I&L%7Xv$9)A@(dSwwLa6=bX?C9)Nxu8vDZP?va|GympIcEfc? z-0F0iF*hdnwct+XV9zXkEejrY@#B3BdGlv8Cv|)`OpAbE<_&j7VpPIU0b`7m^hg*! zMBtf)-N28%Zyr(&ae}8-&|crLVgqbsUUf6p^8@IdeR|Fr&^=0Pim zU8%a;3GMQ!K)^xu2P@K-#*vV!jigq0h|;nj`_0kj^jnU?3zB7+c9<1W)qaA-EkVF8 zvw|F(eFUY272jDWFhvbX01d>T8RlcSrp3OVS8C|=fb^?4>X(}avm+tEny8YjjcfgM}4KSo}QUouBfl_x`4)>oZ} z*`iu#O@2`D#;sVu4_4NAoB4aq2)Wo>zxhu%evfmK;nj44xgY9|{utAe$ujD1rp)T> z98YtS95Q#z%db=$YD$_>Q`z-O8h=fq=9UD*k2NQejwMf=#Zs6EHTQ>wAD)J7?tf9% zb?hGJlrbzc8M}9>z`uA!d<`P4yMvDst|l!RJf(4+fLiT~W>+6I;!~A4<6j8Jw^f9~ z>-_fWm7>bPODbrvJGxm(Cp8D1S`g=#jwy%lKPlQ1Smk6v{1Bn=;?9Yrq?EO8K0F)D5wQlt7M%$TJhe|d$UVHh+*O%+HX=Hrmi60-bJ0fJ0&{^$ z5O@?u*_0G^v~~WPJNyOjntX8Y>V7t}2J(sDlVTi(U2!W#fk{*8cCvyYq8ihxlsx$0 zl32-vQ}-X?JR$FgBj|fXD1Sbeo#C63Y6sfv!wfzcj~dbo+M6r*>3M69)vIiL2@^%| zT2PU3axwgIYI;;4L5FIaI@Wc@OZXLRjZPliz!Z!Yi}^QX%Y>mn-MeKdOv6 zq-9HBDC5D=Y3>Qbgvp!5> zfJ7Q)srYYv^NmCrr&}r=0`&481I&0xEv6a}?pLxOAji^Oa4&y=lo4Pzq8+4eL3vdT zaCZYpoAH2J^bO?y-ASPy`I~NAZRlcR_q5b6l`@17>~kV0vN2UO$GVo}%n}nft`h%E z(Y50%<4_wCT`-@TJlx@;*58qx*3-18Qcv?%TkZOw^Mskt|5bUl{5+vmgaSVc zZu9RQdo8IK1b|7&Yk)Qqb4P1?;#-2o_q>T^DB-^YQ%4zj$Jzxc)*1}12C~nGVRwK_h#r^~Oglg@79aG|b4<5#9n*x%7Xc~RJLJ7et zawzNj=w)5s&3^MitY#>>4NXjPy$jZ0-2wFO(yh*Hl|a!=DX_d!!tQBfLpLx8LAcy= zdQQ>Aq#fU0d%Q9#<8Lb%HZ~qMToGF7$7Ro`t&pNIr*&_Sr!YgOzMOweZ=t& z+utw;WTQq@x4l-5AuooQcmfOi6~&0el;>c=x4yZ}&bDEvDk9cd+nv_ zhG{}V2%0@=00E^VW3Jszt!uq7zh7k@JQd0<(De38)y~K1?eT9 zM;Uk1vd@cYuG3^CYCg^L1i`{64-L^##ic8?50UFSnRuytoUngj|`v&J%8Y_9D5imQb!W)eYm>-jyBa5zA?b zlqikEm;FtlNFxP^_HaH)lfqJ5_||jeb$|E~>G>1<&X3%JdiUu=a-}~&ROAa&4Y6c} zYJM*-!56m2UQfHwjN&mW;%5j%+R@@7VYa?y5eV3(dq%PGL@HAAi?*4AV99CH#O-Mf zg-IGqgYFd?N1((ZGJABL(Ry-uSzI&-L&Z@WVz(slSkmO{S2t76J@A*xeudA%W$9CbP3BpujO0@Q~3#KuUWEdLIdLsX9bD$-r2caCF|i zn#?(Fqq04gNM9zaA0>)9l!%RI^#(wD!v^$Fr$5SFOZVB%TP6y6HJeX4m*Va`@pt}K zxor1kVio8hvmZ5vccNcpEWZZzHrGcx#N06ePORNx)fT@@lEHi(he--m=f1 z#|STGAnriR0Ao2HDn^WCJGtTySrJ|Io+jz+G>hMT3`-dl=RA%_6JC-ODa(6vso7U5 z5;C9m5nTu8Apgh;8b51&oWzfG4 zbUzWdG$lGr`XweVBq!*q0GDGG(rt_E{^syoP90J2gm!;gTKC2P;|hdR!(@IxFhnR@%pCk8Pc0RPEjCo9_R3M^QyH6sZ&SJ@pK6M|YR)|6Gq3Y%8+M z(RM=8pJ6S_U4=!tJYgupXehWLpm8WW8geAG)QOW32;fIewbM%>7>u*IUqh4eO4C12 zesM$TBUhlazaRlZ|9)_yg^}5sFC#H2M7i2UZlRuhTLpFaq}t^Fip)Cj;BPRT67_*- zUfd5W0877MCw_`T^KmMtmKY1J&4)WOAu0Fyo%#KvZE3lqRO!BsVm^3bzExm*1%%#t z|MFhU{zx;CPVIqG^@#8nm+!aIlvgl5(_#pDBzKuaYH*r$yxz52@ailDmpxkf8n5P4 zS$hWxguRn`_Z0$KSBX`*@mN!{wi)BaNyh1*`zKNLSU7hzQ2Wzc075-)5nRPutuTb9 zrH!YB7uUcC!)E)*pj_KMUZ*8hH!3(SJG!%+Fv4#}Q|^iW`+^w*6`Bxr$VZqv#P%-7 zQce$kPvfB-8fKoOMP2Wh!7`69kui4XGtBotGYc`h^4lCYuSKgd_5OQ-{<-C_*S5CnH#z z3JWONqgAKdLST)89>-~z&$rT>#HE?PUcW#oQ^D$je{+N&Y=|a-6*!Z5lBTgYf8iwA zC*YzhB=wq`_%|@f@nbFc<|Z4(fJd6~Elk(#HgW%Zal0FbVfb>rX7=ghGW~d0$g#~R zgb4U#P9lRkq&I=Wgw#mcZn0#DHNVUT5Kdc48qy&AJ_UFHP~BnYa=ki@j4((kcr)@J zw~>WSYVPzifQQ0bO)Px;0Mu&~i2U|_2N;y{6o>AyDKID%DGogoGhk3EQXG0FX276S zq&V~p%%DbSVzIDsD!l3&{d`2@tK0ZHB?Cy1a12UG&webSMX_+`DM8pTzPYzwAZTPS zo*Vss0rf)Mk)^j^*f0t@HZpGutUvi<|H7bT)fv21&ZA&r_6nz0`S|;ThIZn))9(;)(!^q6laxU!d{J2} zZ`>JR3A+A`5IV!rTFG>##xypvluU0BXhe?^Zhyms9EW~!?^=`3CReFVQa&30nm|j624BYNk z8VFf9eUtpG(0pc)(vQWl#vs8bMa@9I(n>J@%3wO8gd%K5#xZHi{3^RUso?;I^aZ~1 zErCb+T<**zbnqXZo*X|>ola)Kht9T*Q`df?^3E0Xj3erT&3XZCjT0QlB@&CnT!i6y zUf%dCWHX&2I8d?atBT0xe$PDWBkU^%O|vLJP;*+amc~V(!-sOWUO@?uWP|Dktt)X| zb844@TwtuQTHuEJG9DgH$=NJwA0Ih-AgbXKXyL|BznB@)srn=%c)@J`tq;HfCTq?n z)`=&N$y$5J)M5LJI!2Vm&REg7Y;bwX%_=&BD}+TI%Gz~ON6qLZPO;*p;#Jm*mD*sN z$PPu4aq*V6YD}A&<{R2{3w(Y~Ixxv|=)?qXEw?&jX;nn-Qb!J0poJhCnd!5(+PSxiCi+2JpEShHt z)Y58?2!wfS;#asmNA|O{+XeO6~~g>f1(ZxcAD*YLr~31JlYS@BVKNO zz>O?t3~0OQq{*{;RF=DcXQjdC)~SuD)>16?wp{!m(bRvuV1t15x^F+4H%)FXD!rPk zJJA`1<5((B6RKC)Ri!HC>%@F7`sn^jylMpCU-rt6(k808v#%aBr|w4izCu!NB5_T- z@n9$*6^bn4t*3p*`=tVp8B|V>5Tqj@neU+mJdBm5qN}nPIsM2;t2y6^VI}JDB2?W3 z!`L+sH>tG!?}}(YmYb>oH_F2yX99c>1kR#fMT10@G(3Ye*D@^@(noW8c8u`uk&jgB za6Q74(XQlRET5BQ&qJ0XqgG^jEpw{-hehhsK7KkZ6_YXzU5Q=hssRNJ8PJNp?Nyf;rJhp(nw1PC#NqAX#UzXTAr^9fl!OE$xLm1QwE~OYo-b%DCp`) z84`JH2+0-m42vlWSe8DOw-77_{Dleub#v~Cofo1Kw1+Mnbm|CFzWIoL!Rmw{x)=xg zD4IYSe{?APAyO8z~DA!Dzs2g3y+-3T|b z$E6B`-RIi*wXEl3vXa^cJ9FuP8;jQ``DBL?eGDD|7YrLO?8hl zgMTp+TPk0uC%Ke1E*IgTVy^M1i4(JG_sDDM*ql!Fw|69q|7#sT#Sx0oekD+CN(O#= zV@L~$Ym34(0v0p=ZPQ~M0_`M>%qoT)LbsO`p<5=txTO91X&6u~^IjjoI8J+QGGkcL z0r4sLVI2PBC4dzr=E_S~QW}pqS@fOw>`a=4Mt%A?EPWy-Exm3;?#?ckk)#da(iY<% zIeA;lB7)OE=)R0|A%270wFZSG8gLgn5bCkDyFU7P&?iJHaNz;CPdz65>$uTeL*s z{WDA|{5rCJP5Kls_QJ#2oiQV5A3@s*ZhGQ;0<_4I9L{(h~1$ zKha^3ki2zKFVR}IVesUc*O=E68TlYTSude~ZU^Z>de>`S6>bL;S#^8W{_|aKPLNC^ za`~EX;xd8ys#N;-K<#*_>vtZi8Uk+X0>^p7JK)^gtFG70s3rq#K~+RL(4qyOEr%jOSBeYWu-i9YVcg;fMf-HD zhZS-Wv%LNhQWT2AdA6~XA<_KdV#H57-i+|VmqYQ26RU5uw2(sc#Ss0(s%>}oB8#nN z$Gk~!_?$W#qhu;moGDZ=IYNV0A=>DjQ(kmM+y=? zt+DHvb^6pjwWVN;*^`y!udwa$u`KiGL)@xJ)1A67tm zU=eIIN<$HVh(Ui|4wQ!;S@eSsBT0=)(~g;C43CH3@w6LP3modqSgC(PBR()r#L0>9 zER8n;izuHi^DH_Ig-)lsNkT7m&%8=U6$1c9euIV{@sLMyq`-eF;LZv_0^)Zo(vXy8 zU>)pW2itY|B``@D<~G0E?e}mW|Da@^7QL!v588s})^Rc93t#zCeiR%Ow#226JmvF% z%{5iiG4Ba4z*OZ4`uKQd8{aIS|%4gk2y9vZ@yHp1|*lv$t$De~=d5l9G zK9h(tQ`FY_Zt?Jova8m+G!rXW!TfJ6hAt4qTgqb|*F&CwzFe(3)r+o^?G$2#`e!|&mrT*eU&pK(OF32IwIk9hcH`E}cUnz1FUV8M@OLpKQG9pwp+%MnjN|No+P zpYEa%yYEyTXlJ`WfF1peudQ14q|tB7yvR5t+*ulse`XgJ7@zdskl;C_X7U;g&y$kv zw}KD6LGPgjqrWOGEEg%=VUTQP)3(vqVvkIF8vng5{q3krK2y|+)7ncDT*qT=Y2?tg z2+zY`w>8}LmfU6ni=6;k=be-=YUIU&)-_3S{{b`rY+8wkV~B%k`Ga5mCr1RI*0L_& zTmT!@`0xr!hSLXS#+p!-@)ehMKR(ZJgW?`S5}h89@MciA=w_R}IuUovaqoj(T7Wqj z?`|E!MyD93?SjWhZlgaL@>~>Z;q1n`Fh;wc_g&qj5#tAM=fd`Sm`6Dw17irbv-5N_ zS1$I*%-Uw2>PJl7%BMz(0FqKeQnhUG{SIqRZa*pT4{xz_h+i8aCayt)ig_W5w~9-s zB^gGOpl~vwJEz52yKWPPq}O$A^0SpGkSN7AS6I^l+9T)gC0RJ!qaD3?WgMX^Fw!bP zIRU@tWjmoPfDxxJ1q;{cQ;Re&w!lLtJy7NQ*=~vA7}JDe;p?rP>0P0Hyg3D`*Fa3| zu^7^rR1`%VN?knfMxs)BQjTT($b|(h-`!#^B|J6*7r-=myKj zEN%`MLP0K%aQvDgp+sC=J7dEkT3JYfI-{leC0t#|n+wEkqS!^F-4{`)^pz)fGV41% z^AWQi$K`uAw$X%GO?Bj0{cmYPjJ=pAAVUGI2e_0zbAd9D0cioYzq_hMm|`(r;afGc zARqWi%}SY>xMlal__&Cx=dUVdRE@TV^}Qg^r+l<&ZH!)d(L*I8ej)Q<{JeNzJ>y!w zWl`L{tQu0;R_g)X^FEVB@AS7+Dr@#unatt3Y+B?8OC8!%qFt}W$?SH&yXSW8eZr$= zHQEh>>0r6SakGobf`aN&tx^lVubial8_NKX?FW|#XL zCByo>#;hl3h*B%M3E=+V4LA7je1jRBO@#Q&BfXqYV)7itA2~BLTX`>S@HAtv8~+okkB8?fwcO2A#psR>NI9lH5U%p!)7#*?p%5#f_X z0*zyRfez##m*pv|%oJ&rP*qYolUAct?#YVZ-I zXYsQF^Fyi3*>C#i{^2I08j?H`Qs@;)<9|?bATTA{X=(~v9(n1aG79dYq$S}jm8ip- z+sl^EwB==P$NUo|U{OQuiO$D@)K9Ef!hI(nNEz}(`GcdJik%JIV+rt1(2Q)e&_P3H z^wcL`6kBf$jmgP)Kwn#VhYo5xt5Q=ok^bNy4!S_{T)vCTAZ8Abu}BJIQwYIFX(@UZPiu)0AFTz&)Ag;tkXvk0%cQdVTzcZEaz~leRO2( z56}_jt_2GXP>RKC$C9bHj0C+*zu75Lz)h zS@fE2*Qmv{Q-o{>(b}MDNiyRfw>d_<8GE3UFKo3NBZDkiCRY1%gz3X^A{qC-A$;O^ z{8dTBbjc zj4~X^cLk{^K~d?OmIP|o_GR*XrJUY$?gFpNoGJZgG2WyK1&;>UAlV0E8`WsVrEL;y zqC#sJl#+f4(5s?|OY=EUOghJgYZBgNFIa|G7V{Oq>x8(2i=UH=eEPub zAD4*K=WgHi0|rccUG|K!tLTH_%VAp%YfgA}ul9G*jhh=*#06 zbTBHNS|4^i=Z|PipZaN(uct2n6R@{gfO&`@jyqu9x6hdN+g7z{EX8ewl8t=GBav_Xw-e0|jKp67A z$ET>PwJ=ra5~3&EhtM+{Uj~4Ue?{YMU4;=7jk^or+(DA;j`+}w4_QrR)07DyLzXY@ zMO?v9qsfuQU-YC&m4_qQ6Z3kNu7> zg;KuV`i-QHs3t1*1@|tncV~#s9>cx2geZ~=6v^TB3878d7C|YJ3*;;$ax~E(TwE_@ zqyXpgKOd7UX0rj)xrKWEuPd5GY(8K%&q$BIy|wzqFk4fY&0j9%G#{F$q_A>QfD3uw z3ycPjkJgycor8wNq#MRDY4qzfx~};jXC(jpTa}Z-N|VFclg$5oowvjcKckzysBNvk zFaDctK*&E@YebN9_$ntCE@IHBRd7Sg#Xj!=jHj_UB!%sV};%9-b?T~w&x3I zS>PL<>KT~BAe^>9Jl-;!C#vL_>fADuW>{I$@8nv&OXC_5e@>zuG?5&?1^&}}(QEB2n@mW!zEy;bfhqSEi^i56N?b0(gPK~Lb*>5bO22+U zBc7&2HiW|uscnMl6u!8;%mX)N&^p^sEC6R$Ien-cA$6Z8C&(FvEL=OH%M)Ltb`=I0 z&XnuwsJqrZrkmB9Z8Px*6h+*inEj6^W7_`hT9>wiqtm?szZYhZrm}NQXDdJPB8 z{7Um8bkG5^1QNDsPA*erC(ZEnK@5mzse0?AQs<2C98%r9q(&5%xPBD7zyg5OC=ozo z>9>;Vlyf`an_!)n2ybem>lPSU9bihw;G<$}pFKQT%i7=0FW6UI?;YXPE#)b*a#(@e zp}#pz9H5q#>Q;g`Fcve!+u;Lqbz#OBiLgM28 zqL!y)Ml&8-pZ70j)O~`ZpQdFX7h-Iwsei%|cgW0L5y(s`j=kn(u3GW&KlCwZm%U)2 zJGCf4@4ZNhURR}hQ>Ns_+@9OAGD~G_!rLY~*niGy?W!>2sR;KcPri3$Q5i_7j{@QZ z>x|SV9=Ge@bDGs$j>*2VmNh=r0txX6RzaWg>%Lned(Rn<&?VT{p>WntY zSb}q}02ZGLYb8K%LBE9!f@cJ}zH4QjU}6>inrDIpqeb%v} zk~xcTX9u>ul+Pi(Yoti<Y&tXC z;jd%(@xNx~wTNF*egO1^u-2DM?_Wko-Wx5E)CJQxLs#+;##m|CmQO0#B31uZ2-Cps z8pbv67kI?AA+f=}C&`bfRCxbEXC+hgGR;DwND?PUFBN=kC+|6W9 z5*!AMw&{Gal8d_tg8D98d+6$Y>q-c+@xMxsOz0eD7i|47ld5nTUXu@miA!~9EE>a{ z?{%K-?5_&0U6iX1Aje)!BJ?^vUV_@mt*)}M?}sCjt_1?wNWtG=*ofkWVgoAv*vdZxbXO zVQVr$_jp5w_8?Ie6mX)eXbm?X|R?U6!C9ks{KbK50GeF5CdD)+%(yiDVgAa8rykNogsJ8@-0_WjPBhs`Z;&eX$a2grx|Zqz6H zYz`bQ+TMj!*vCV1hcs~hIf`7)7hYQMAY(ta2?)W{ari2ZQZ%Mx*^K- zqzfA)=wsB}g5>Hp^c-NQBK;z9ZlW7OIpavOEgL@~Ey7Ozl zV^W4@X?P??wetWEJ}Dzxbp(O-C?nnFN2R#NWu;onPlQX8y9O6win?R{nN^(cKfubu__fh0f(7Ac+nH^rtVi9;TOU&C)sj!}ibv}5 z{+`P6+=rr9aYCK%?O|2jt%50F(CCnHar;Rh5wBV$#@r zIiP@LYxICUQhy)}6asgZ6HL>dVAQ14ZxfdI{uoi$+&0Y~gk92*TXHqAEyy_AITr+- z2`*I_QPnw`@ERe+MQf@p`qyg1xlXd@WDB%*7mWqaQEsW`v;a5SPo&}FMWYo>klgtU z+#EXtR^=#9LZd8Y1lUQ|N?L!1g4TN^;1*^Kgv)XUowd|eje(DjB2V@w!R$K_oe#a4 zVjOT0jaN5taK-}^%;=&R9@L8Df@hKLwE_K&Jc+$o2NxlE=4lO7xJL#)ov*|u*q95wA~vhl+O?B34kn(!jpK0*D37}HJ7H_u^20sw9% ze%PiNTg>*yrcd$vl0bJnm+rJF9}r^a#<)-&{bj@pehNi)s9)~WLOR(b<6E`Ltm_LQ z8nPZ)>gy3t<|x-;uSREW$}^8lf(h{twa~PdN)1&?d?iwVp_Ok@OJ{2dl zv@{}%1@PhJ^1fkIV}1Q!N}wSuc6D`iLP`iN;LBrbW&1CfGC~d|(6ENiqgyo2MKu&Q zrN_c}yj$YM+4y`!XV0M+Ix=jz*KR>{_H1%#Mfa*4yz!~`yCu*4wxRFCO&lk#Ur`}B zV%rwXbAO!QHqanV@lU`OcWOKNyla@~PzF#&hh#^(vvMifeJr-7N9KE+grsSlH`dNg zmHYjGB5FP)%_0v0fl^GTO@*rFe?)*#5-19xV|p78jfuQ}mNN!y^$O2(cWK7EK4k>R zf3TR9;UQ4`?%NdwIq!b{oDznec{^lFML>7EqR&S@ZxQ=nL^~uT zWpw1}CC{ec38?{F1uV1ROh(dEUN>q6YEXGcku{%+4oD~g&0SguJa3lJltqYF*^FAk5V4_4k0?tyF#n5hv9b8 zj`swXjV@gCWib*t_ULcO#>B!%{am;AS?uNJe=VZ3mW6oN#e+VnUtsxj?B)XC5x|4nOmLTk6XCUh0Bjs}2xHAy&v$LKY3#GPEJ zMoxo$;7JkADp)J1!TC?z+&d-`NSLtkXrX0V7<85)%_NcSe z4PMg4f9%O*JU=y3(3+z2}A21JJH-ZM%_Pi?N;GoDjQ>#DXW{b|^RIzWPUKzND zsgt`;kAAB0fI7bV%1MCY2cvG3)^pR-K{0lJtyZg`9r$8tqKzDMrPK~5N_IQ7> zOwQGMz+4Z@bpE(wH~A8w&ShI`*^Kt&*!1pp)L{}`JCU>f|h(x&45c@rCsMx)_E zJ&XN(L30J=^l&*}%+b2jTn)=`@w9V4dM73Gk|TLg#M}A@?J5TS2BfKXA0qbBf203@ zkO~pfq9MKijCnQ=|9Z0!@M_qS5FwkqyQV??2kXDL1=S0~{|0Nq|2fwGeO}xD&$0gR z^Sbwcj`e??S8#~`bFBZXyy7T#S(kLhHw6N;MZE?B-Uk8J-vt79B{c=10JO#YXCxR{ LUH>i!FzWvUxsRlJ literal 287595 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

0V_4`3b(%^HtWD##gR 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

} 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

TDU@q)#Y@q+c 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¨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 Date: Fri, 9 Oct 2026 17:16:31 +0100 Subject: [PATCH 12/38] build(deps): update Go to 1.27.2 and golang.org/x/net to v0.60.0 govulncheck (make go/vuln) failed on main with ten advisories against go1.27.1's net/http and HTTP/2 and golang.org/x/net v0.58.0, fixed in go1.27.2 and x/net v0.60.0 (GO-2026-6609 to GO-2026-6617 and others). Bump the toolchain everywhere it is pinned: go.mod, the three Dockerfiles' GO_VERSION, Makefile GO_VERSION, actions/setup-go in both workflows, and the docs prerequisites (plus GEMINI.md and the version command's sample output). go1.27.2 writes a newer export-data version than golang.org/x/tools v0.49 can read, so golangci-lint v2.13.x and gosec v2.29.0 fail to type-check anything ("export data version 5 is greater than maximum supported version 4"). golangci-lint goes to v2.14.0 (x/tools v0.50) in the Makefile and in CI. No gosec release carries a newer x/tools yet, so the Makefile pins the master commit that does (x/tools v0.51); CI's gosec action already runs master. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yaml | 2 +- .github/workflows/validate.yaml | 8 ++++---- Dockerfile | 2 +- Dockerfile.mobile | 2 +- Dockerfile.mobile-pdf | 2 +- GEMINI.md | 2 +- Makefile | 12 +++++++++--- docs/dev-guide/contributing.md | 2 +- docs/user-guide/commands/version.md | 2 +- docs/user-guide/install/install.md | 2 +- go.mod | 4 ++-- go.sum | 4 ++-- 12 files changed, 25 insertions(+), 19 deletions(-) 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/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 1656a2c01..b731dab78 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/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/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/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/go.mod b/go.mod index 04e92bc73..688cf54ee 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 @@ -57,7 +57,7 @@ require ( 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..9e57fc6c7 100644 --- a/go.sum +++ b/go.sum @@ -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= From c36e64b265b482e92a6696690419e0807474a3cc Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 18:13:07 +0100 Subject: [PATCH 13/38] refactor: polish the PR #463 fixes after review - Court calls come down once their match leaves scheduled, from any device or door (one effect over the console's matches), replacing the start and fusensho hooks; docs updated to match. - One WinnerTick (side_cell.jsx) with sr-only text instead of an aria-label on a bare span, used by the bracket card, Recent results and the queue. - Representative picks: the member check runs only when the incoming row is kept (a stored hantei verdict replaces it); the typed-name resolve is shared with the inline lineup path (resolveTypedMemberName), so an unread member list is now warned about; the representative's id is read from its own row. - Shared stale-side guard and notifier in the team sheet, one daihyosen slot key, the buildPatch id stamps written once, slices for the announcement splice, one team_member_not_in_team code, merged CSS rules. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/organisers/run-tournament.md | 2 +- internal/mobileapp/handlers_lineup.go | 6 +- internal/mobileapp/handlers_match.go | 13 +- internal/mobileapp/self_run_daihyosen_test.go | 24 +++ internal/state/announcement_store.go | 16 +- web-mobile/css/styles.css | 9 +- .../js/__tests__/admin_shiaijo.test.jsx | 12 +- .../__tests__/bout_side_squad_member.test.jsx | 15 +- .../call_to_court_double_tap.render.test.jsx | 61 ++++++- ...m_daihyosen_representative.render.test.jsx | 20 +++ .../viewer_competition_recent_order.test.jsx | 2 +- .../js/__tests__/vsched_winner_cue.test.jsx | 10 +- web-mobile/js/admin_scoring_team.jsx | 162 +++++++++--------- web-mobile/js/admin_shiaijo.jsx | 48 ++++-- web-mobile/js/barred_match_notice.jsx | 6 +- web-mobile/js/bracket.jsx | 3 +- web-mobile/js/lineup_resolver.jsx | 74 +++++--- web-mobile/js/side_cell.jsx | 12 ++ web-mobile/js/viewer_match.jsx | 10 +- 19 files changed, 328 insertions(+), 177 deletions(-) diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 33db2093f..bb71c9b1c 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -79,7 +79,7 @@ Click **Announce** from the dashboard to broadcast a short message to every view 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 when that court starts the match. A match started from another device does not take the call down, so it stays up until its five minutes run out. +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. If that console was closed or reloaded meanwhile, the call stays up until its five minutes run out. ## Registration desk diff --git a/internal/mobileapp/handlers_lineup.go b/internal/mobileapp/handlers_lineup.go index 8c5901b7b..050a931f3 100644 --- a/internal/mobileapp/handlers_lineup.go +++ b/internal/mobileapp/handlers_lineup.go @@ -489,10 +489,14 @@ func teamMemberIDs(squads map[string][]domain.TeamMember, teamID string) map[str return held } +// codeTeamMemberNotInTeam is the refusal code for a member id the named team does +// not hold. The lineup refusal and the representative pick's refusal both answer it. +const codeTeamMemberNotInTeam = "team_member_not_in_team" + func errLineupMemberNotInTeam(pos domain.Position) *selfRunRefusal { return &selfRunRefusal{ status: http.StatusBadRequest, - code: "team_member_not_in_team", + code: codeTeamMemberNotInTeam, message: "The member chosen for " + string(pos) + " is not on this team. Pick again from the list.", } } diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 3facddda5..97e7690e3 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2061,16 +2061,15 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res log.Printf("mobileapp: kept the representative bout a self-run score write to %s/%s left out; the rest of the write was kept", compID, matchID) return nil } - if row >= 0 { - // bc-dhrp: the picks name the side's own team's members (the lineup's rule). - if err := repMembersOutsideTeams(stx, compID, snap.Pairing, subs[row]); err != nil { - return err - } - } if stored == nil || !stored.HanteiDecided() { if row >= 0 && subs[row].HanteiDecided() { return repBoutHanteiRefusal(false) } + if row >= 0 { + // bc-dhrp: the picks name the side's own team's members (the lineup's rule). + // Not judged when a stored verdict stands: it replaces the row below. + return repMembersOutsideTeams(stx, compID, snap.Pairing, subs[row]) + } return nil } switch { @@ -2107,7 +2106,7 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res // refusal (errLineupMemberNotInTeam) says the same thing about a fielded position. var errRepMemberNotInTeam = &selfRunRefusal{ status: http.StatusBadRequest, - code: "team_member_not_in_team", + code: codeTeamMemberNotInTeam, message: "The representative chosen is not on this team. Pick again from the list.", } diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index f60037c6c..5d0e48d0e 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -990,6 +990,30 @@ func TestSelfRun_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "the organiser's pick is not judged on membership: %s", w.Body.String()) } +// A participant's echo of the organiser's recorded verdict is replaced by the +// stored row whatever picks it carries, so a foreign pick on it is not refused +// (the picks are never written). +func TestSelfRun_RepresentativeMembersAreNotJudgedOnADecidedVerdict(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + f.recordHantei(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersB := squads[repBoutTeamBID] + require.NotEmpty(t, membersB, "team B is seeded with members") + + echo := hanteiRow() + echo["sideAMemberId"] = membersB[0].ID // team B's member on team A's side + + w := f.score("", state.MatchStatusRunning, "", f.now+200, echo) + require.Equal(t, http.StatusOK, w.Code, "the verdict's echo is kept whatever its picks: %s", w.Body.String()) + assert.NotContains(t, w.Body.String(), "team_member_not_in_team") + row := f.storedRepBout(t) + assert.Equal(t, []string{domain.HanteiMark}, row.IpponsA, "the verdict stands") + assert.Equal(t, "TeamA", row.Winner) + assert.Empty(t, row.SideAMemberID, "the stored row keeps its own picks, not the echo's") +} + // A password sent but wrong is answered 401 before the body is read, so a // stale organiser is not told about their clock or their body instead. func TestDaihyosen_AWrongPasswordIsRefusedBeforeTheStamp(t *testing.T) { diff --git a/internal/state/announcement_store.go b/internal/state/announcement_store.go index 0d4c0a589..6abe6f870 100644 --- a/internal/state/announcement_store.go +++ b/internal/state/announcement_store.go @@ -1,6 +1,7 @@ package state import ( + "slices" "sync" "time" ) @@ -29,11 +30,8 @@ func (s *AnnouncementStore) Add(msg string, dur time.Duration) (Announcement, [] // Remove the active announcement with the identical message, if any: at // most one exists, since every Add replaces it (bc-cdbl). - for i, a := range s.active { - if a.Message == msg { - s.active = append(s.active[:i], s.active[i+1:]...) - break - } + if i := slices.IndexFunc(s.active, func(a Announcement) bool { return a.Message == msg }); i >= 0 { + s.active = slices.Delete(s.active, i, i+1) } if len(s.active) >= maxActiveAnnouncements { @@ -51,11 +49,9 @@ func (s *AnnouncementStore) Remove(id string) (bool, []Announcement) { defer s.mu.Unlock() s.pruneExpiredLocked(time.Now()) - for i, a := range s.active { - if a.ID == id { - s.active = append(s.active[:i], s.active[i+1:]...) - return true, snapshotLocked(s.active) - } + if i := slices.IndexFunc(s.active, func(a Announcement) bool { return a.ID == id }); i >= 0 { + s.active = slices.Delete(s.active, i, i+1) + return true, snapshotLocked(s.active) } return false, snapshotLocked(s.active) } diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 8d17811b6..28fd8519a 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -3129,7 +3129,8 @@ a.vlist-item { background: var(--red-soft); } -.vsched-item__side--w { +.vsched-item__side--w, +.shiaijo-qrow__side--win .shiaijo-qrow__name { color: var(--accent); font-weight: 700; } @@ -7273,10 +7274,9 @@ button.vsched-item:not([data-clickable]) { .msb-name--labelled .numbered-name--clip { min-width: 0; flex: 0 1 auto; } .msb-name--labelled .msb-member-label, .msb-name--labelled .sb-result-mark, -.msb-name--labelled .barred-chip, -.msb-name--labelled .bc-winner-tick { flex-shrink: 0; } +.msb-name--labelled .barred-chip { flex-shrink: 0; } /* The row's gap spaces the tick; its bracket-card margin would double it. */ -.msb-name--labelled .bc-winner-tick { margin-right: 0; } +.msb-name--labelled .bc-winner-tick { flex-shrink: 0; margin-right: 0; } /* The decided winner's name. .msb-slots--win marks only an ippon-LESS result (hantei, or a default win), so without this an ordinary 2-0 or 1-0 win is emphasised nowhere. Set by IndividualScore from the same id-first winner key @@ -9879,7 +9879,6 @@ abbr.msb-lab { text-decoration: none; cursor: help; } .shiaijo-qrow__side { display: flex; align-items: center; gap: 6px; min-width: 0; padding: 4px 8px; } .shiaijo-qrow__side--shiro { border-radius: 6px 0 0 6px; } .shiaijo-qrow__side--aka { flex-direction: row-reverse; text-align: right; border-radius: 0 6px 6px 0; } -.shiaijo-qrow__side--win .shiaijo-qrow__name { color: var(--accent); font-weight: 700; } .shiaijo-qrow__name { font-weight: 600; font-size: 14px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .shiaijo-qrow__vs { font-size: 11px; color: var(--ink-3); } /* Completed result, centred on its own line below the names (the names keep the diff --git a/web-mobile/js/__tests__/admin_shiaijo.test.jsx b/web-mobile/js/__tests__/admin_shiaijo.test.jsx index 60c689858..acdfa3d7f 100644 --- a/web-mobile/js/__tests__/admin_shiaijo.test.jsx +++ b/web-mobile/js/__tests__/admin_shiaijo.test.jsx @@ -252,11 +252,15 @@ describe('ShiaijoQueueRow; completed result placement', () => { expect(akaSide.props.className).toContain('shiaijo-qrow__side--aka'); expect(akaSide.props.className).toContain('shiaijo-qrow__side--win'); - // Check for the tick span in Aka side + // The tick is the WinnerTick component (side_cell.jsx). This runtime does not + // expand components, so it is found by name and rendered to reach the sr-only + // word a screen reader hears. let hasTick = false; walk(akaSide, (n) => { - if (n && n.props?.className === 'bc-winner-tick' && text(n) === '✓') { - hasTick = true; + if (n && n.type?.name === 'WinnerTick') { + walk(n.type(n.props), (c) => { + if (c && c.props?.className === 'sr-only' && text(c) === 'Winner') hasTick = true; + }); } }); expect(hasTick).toBe(true); @@ -354,7 +358,7 @@ describe('ShiaijoQueueRow; completed result placement', () => { // No tick on either side let tickCount = 0; walk(tree, (n) => { - if (n && n.props?.className === 'bc-winner-tick') { + if (n && n.type?.name === 'WinnerTick') { tickCount++; } }); diff --git a/web-mobile/js/__tests__/bout_side_squad_member.test.jsx b/web-mobile/js/__tests__/bout_side_squad_member.test.jsx index 2b7c93351..73c8a13b7 100644 --- a/web-mobile/js/__tests__/bout_side_squad_member.test.jsx +++ b/web-mobile/js/__tests__/bout_side_squad_member.test.jsx @@ -60,11 +60,22 @@ describe('resolveBoutSideMemberId (mirrors resolveBoutSideName priority)', () => })).toBe('mem-recorded'); }); - it('daihyosen row is lineup-first even in a kachinuki match', () => { + it('daihyosen row reads only its own pick, never a lineup position, even in a kachinuki match', () => { expect(resolveBoutSideMemberId({ isKachinuki: true, isDaihyosen: true, existingMemberId: 'mem-old', lineupMemberId: 'mem-rep', - })).toBe('mem-rep'); + })).toBe('mem-old'); + expect(resolveBoutSideMemberId({ + isKachinuki: false, isDaihyosen: true, + existingMemberId: 'mem-pick', lineupMemberId: 'mem-lineup', + })).toBe('mem-pick'); + }); + + it('daihyosen row with no pick of its own has no id, though a lineup position holds a member', () => { + expect(resolveBoutSideMemberId({ + isKachinuki: false, isDaihyosen: true, + existingMemberId: '', lineupMemberId: 'mem-lineup', + })).toBe(''); }); it('returns "" when neither source has an id', () => { diff --git a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx index 5e4e50653..66648c525 100644 --- a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx @@ -5,7 +5,8 @@ // callToCourt, so the bounce of a tap is never sent. The callingKey disabled // state stays the in-flight guard, and a deliberate tap after the bounce window // still calls again. The call's announcement id is kept per match and deleted -// when the console starts that match (startMatch). +// once the console's list shows that match out of scheduled, however it got +// there (started here, started elsewhere, or closed by a decision). // // Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts // them and these tests could never go red. @@ -108,6 +109,20 @@ function renderConsole(props = {}) { return render(consoleElement(onEditScore)); } +// The console's match list as the next refetch shows it: a rerender with a new +// tournament object re-derives the court's matches from window.tournamentMatches. +function showMatches(rerender, onEditScore, matches) { + window.tournamentMatches = () => matches; + rerender(consoleElement(onEditScore)); +} + +// The two scheduled matches with the first one changed, as a refetch shows it +// once it has been started or closed. +function firstMatchWith(patch) { + const [m1, m2] = twoScheduledMatches(); + return [{ ...m1, ...patch }, m2]; +} + // Every "Call to court" control on the page, in DOM order: the Up next card's // first, then one per queue row. const callButtons = () => screen.getAllByTitle(CALL_TITLE); @@ -152,28 +167,57 @@ describe('Call to court double tap (bc-cdbl)', () => { expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(2); }); - it('starting the called match withdraws its announcement', async () => { + it('starting the called match withdraws its announcement once the list shows it running', async () => { const onEditScore = vi.fn().mockResolvedValue({ applied: true }); - renderConsole({ onEditScore }); + const { rerender } = renderConsole({ onEditScore }); await pointerTap(callButtons()[0]); expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); await act(async () => {}); expect(onEditScore).toHaveBeenCalled(); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + + showMatches(rerender, onEditScore, firstMatchWith({ status: 'running' })); + await act(async () => {}); expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); }); + it('a match started on another device withdraws its call', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + showMatches(rerender, onEditScore, firstMatchWith({ status: 'running' })); + await act(async () => {}); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); + }); + + it('a call stays up while its match is scheduled or missing from the list', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + showMatches(rerender, onEditScore, twoScheduledMatches()); + await act(async () => {}); + // The list is empty while it loads: an absent match is not judged. + showMatches(rerender, onEditScore, []); + await act(async () => {}); + showMatches(rerender, onEditScore, twoScheduledMatches()); + await act(async () => {}); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + }); + it('a failed withdrawal does not fail the start', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); window.API.deleteAnnouncement = vi.fn(async () => { throw new Error('network down'); }); const onEditScore = vi.fn().mockResolvedValue({ applied: true }); - renderConsole({ onEditScore }); + const { rerender } = renderConsole({ onEditScore }); await pointerTap(callButtons()[0]); await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); await act(async () => {}); expect(onEditScore).toHaveBeenCalled(); + showMatches(rerender, onEditScore, firstMatchWith({ status: 'running' })); + await act(async () => {}); expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalled(); warn.mockRestore(); @@ -182,11 +226,14 @@ describe('Call to court double tap (bc-cdbl)', () => { it('a start that lands before the call is answered still withdraws it', async () => { let answer; window.API.sendAnnouncement = vi.fn(() => new Promise((resolve) => { answer = resolve; })); - renderConsole(); + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); await pointerTap(callButtons()[0]); // The call's request is still out when the match starts. await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); await act(async () => {}); + showMatches(rerender, onEditScore, firstMatchWith({ status: 'running' })); + await act(async () => {}); expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); await act(async () => { answer({ id: 'ann-late', message: 'x' }); }); await act(async () => {}); @@ -209,6 +256,10 @@ describe('Call to court double tap (bc-cdbl)', () => { await pointerTap(screen.getByTestId('barred-match-record-fusensho')); await act(async () => {}); expect(window.API.recordDecision).toHaveBeenCalledTimes(1); + // The decision lands and the next refetch shows the match closed. + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + showMatches(rerender, onEditScore, firstMatchWith({ status: 'completed', decision: 'fusensho' })); + await act(async () => {}); expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); }); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index d8081d846..30a0a4d03 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -255,4 +255,24 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dh.sideBMemberId).toBe('m3b'); expect(dh.sideB).toBe('Team B'); }); + + it('R9: a typed name with the team members unreadable goes on and says so in the row, as a lineup name does', async () => { + window.API.fetchSquads.mockRejectedValue(new TypeError('Failed to fetch')); + window.API.addTeamMember.mockResolvedValue({ id: 'm9', name: 'Eve', index: 3 }); + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit } = await mount(match); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); + await waitFor(() => expect(screen.getByTestId('team-editor-lineup-warning').textContent).toContain('may have been added as a new team member')); + expect(screen.getByTestId('team-editor-lineup-warning').getAttribute('data-tone')).toBe('warn'); + expect(screen.getByTestId('team-editor-lineup-warning').textContent).not.toContain('Lineup saved'); + await clickFinishTwice(); + const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + expect(dh.sideBMemberId).toBe('m9'); + expect(dh.sideB).toBe('Team B'); + }); }); diff --git a/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx b/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx index f762618a5..528e429da 100644 --- a/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx +++ b/web-mobile/js/__tests__/viewer_competition_recent_order.test.jsx @@ -355,7 +355,7 @@ describe('ViewerCompetition Recent results includes completed hikiwake (drawn) b tweaks: {}, winnerTick: true, }); - const ticks = findAll(tree, (n) => n.props?.className === 'bc-winner-tick'); + const ticks = findAll(tree, (n) => n.type?.name === 'WinnerTick'); expect(ticks).toHaveLength(0); }); }); diff --git a/web-mobile/js/__tests__/vsched_winner_cue.test.jsx b/web-mobile/js/__tests__/vsched_winner_cue.test.jsx index b5849760e..42b17e83f 100644 --- a/web-mobile/js/__tests__/vsched_winner_cue.test.jsx +++ b/web-mobile/js/__tests__/vsched_winner_cue.test.jsx @@ -179,20 +179,20 @@ describe('T5: VSchedItem winner cue - completed team match', () => { const akaSide = sides.find(n => hasClass(n, 'vsched-item__side--aka')); const shiroSide = sides.find(n => hasClass(n, 'vsched-item__side--shiro')); - // Find bc-winner-tick spans - const ticks = findAll(tree, n => n.props?.className === 'bc-winner-tick'); + // Find WinnerTick components + const ticks = findAll(tree, n => n.type?.name === 'WinnerTick'); // Should have exactly one tick expect(ticks).toHaveLength(1); // The tick should be inside the Aka side (winning side) let akaHasTick = false; - const akaSideTicks = findAll(akaSide, n => n.props?.className === 'bc-winner-tick'); + const akaSideTicks = findAll(akaSide, n => n.type?.name === 'WinnerTick'); akaHasTick = akaSideTicks.length > 0; // The tick should NOT be in the Shiro side let shiroHasTick = false; - const shiroSideTicks = findAll(shiroSide, n => n.props?.className === 'bc-winner-tick'); + const shiroSideTicks = findAll(shiroSide, n => n.type?.name === 'WinnerTick'); shiroHasTick = shiroSideTicks.length > 0; expect(akaHasTick).toBe(true); @@ -215,7 +215,7 @@ describe('T5: VSchedItem winner cue - completed team match', () => { // Mount WITHOUT winnerTick prop (as Up Next/Running do) const tree = runtime.mount(VSchedItem, { m, tweaks: {} }); - const ticks = findAll(tree, n => n.props?.className === 'bc-winner-tick'); + const ticks = findAll(tree, n => n.type?.name === 'WinnerTick'); // Should have no ticks expect(ticks).toHaveLength(0); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index ba473c957..971b81939 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -143,7 +143,7 @@ export function preserveStoredDaihyosenVerdict({ armed, pickedSide, tied, existi // StreamingOverlay). The implementations live in lineup_resolver.jsx; // re-exported here so existing imports from admin_scoring_modal.jsx (which // re-exports them onward) continue to work. -import { resolveMatchLineup, resolveLineupTeamId, resolveBoutSideName, resolveBoutSideMemberId, resolveSquadMember, squadMemberIdForUniqueName, squadRosterEntries, rosterWithoutPlacedElsewhere, resolveBoutSideDisplayName, buildInlineLineupWrite, memberRefusalNote, alreadyPlacedNote, lineupPositionLabel, POS_KEYS_5, POS_LABELS_5 } from './lineup_resolver.jsx'; +import { resolveMatchLineup, resolveLineupTeamId, resolveBoutSideName, resolveBoutSideMemberId, resolveSquadMember, squadMemberIdForUniqueName, squadRosterEntries, rosterWithoutPlacedElsewhere, resolveBoutSideDisplayName, buildInlineLineupWrite, resolveTypedMemberName, memberRefusalNote, alreadyPlacedNote, lineupPositionLabel, POS_KEYS_5, POS_LABELS_5 } from './lineup_resolver.jsx'; // The one owner of what a lineup save carries: the one position a name-box pick changes. import { changedLineupSave } from './lineup_save.jsx'; // The one owner of what a list of team members that arrives does to the members @@ -675,8 +675,9 @@ export async function pickManualBoutName({ sub, idx, sideKey, memberIdKey, squad } } -// The key the member resolver answers a representative under. It is not a lineup -// position: the resolver's bookkeeping only has to stay apart from every position's. +// The daihyosen row's slot key: the key the member resolver answers a representative +// under, and the slot the sheet lists that row as (positions, below). It names the +// representative's slot, not a lineup position, and no lineup holds it. const REPRESENTATIVE_KEY = "daihyosen"; // pickDaihyosenRepresentative: the write behind a representative picked, typed or @@ -977,7 +978,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], @@ -1149,12 +1149,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). @@ -1866,6 +1866,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` @@ -1876,10 +1894,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 @@ -1898,11 +1917,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 @@ -1961,41 +1975,38 @@ 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 (resolveMemberIdsForPositions, - // admin_lineup.jsx: a name the team already has, else the blank member the pick + // 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, and - // the members the resolver answers are shown. No lineup is written: "daihyosen" is no - // lineup key. Only the resulting member id is stored on the row. + // 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 submitRepresentative = async ({ side, noticeKey, idx, value, priorId }) => { - const notify = (tone, text) => { if (mountedRef.current) setLineupNotice({ key: noticeKey, tone, text }); }; - setLineupNotice(null); - const sideChanged = watchLineupSide(side); - const changedNotice = () => { - if (!sideChanged()) return false; - notify("error", SIDE_TEAM_CHANGED_NOTICE); - return true; - }; + const notify = lineupNotifier(noticeKey); + const sideChanged = sideChangedGuard(side, noticeKey); + let membersUnavailable = squadUnavailable; const waiting = membersWait.current[side].pending(); - if (waiting) await waiting; - if (changedNotice()) return; - const resolve = window.AdminLineupHelpers?.resolveMemberIdsForPositions; - if (typeof resolve !== "function") { + if (waiting && !(await waiting)) membersUnavailable = true; + if (sideChanged()) 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) { notify("error", `"${value}" could not be named: the team members are not loaded. Try again in a moment.`); return; } - const teamId = sideSeen.current[side].team; - const resolved = await resolve(m.compId, teamId, { [REPRESENTATIVE_KEY]: value }, membersShown.current[side], password, { [REPRESENTATIVE_KEY]: priorId }); - if (!mountedRef.current || changedNotice()) return; - wroteSideMembers(side, resolved.squad); - const id = resolved.memberIds[REPRESENTATIVE_KEY] || ""; - if (!id) { - const failure = (resolved.failures || [])[0]; + wroteSideMembers(side, typed.squad); + 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 = resolved.squad.find(mem => mem && mem.id === id) || { id }; + 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. Check the team on the Lineups page.`); }; // Shared factory (admin_scoring_shared.jsx): same handler as ScoreEditorModal. @@ -2407,7 +2418,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 @@ -3073,19 +3084,11 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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 @@ -3109,24 +3112,23 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // decides which side that is by comparing the winner to this row's // own sideA/sideB (placeHt, api_serializers.jsx). Blanking them there // dropped the Ht from the wire, which a test caught. A rep bout is - // won by a team, so naming the teams on that row is honest anyway. The - // representatives' member ids ride beside those names (bc-dhrp). - if (isDaihyo) { - // bc-dhrp: the representative bout keeps the TEAM names (above) and adds - // the two representatives' member ids, the operator's picks on this row. - // The winner's id is the id of the side the bout was won for. - const { aMemberId, bMemberId } = playerNamesForBout(idx); - sideA = sideAName; - sideB = sideBName; - winner = teamWinnerName; - sideAMemberId = aMemberId || ""; - sideBMemberId = bMemberId || ""; - if (winner) winnerMemberId = (wKey === "a" ? aMemberId : wKey === "b" ? bMemberId : "") || ""; - } else { - sideA = ""; - sideB = ""; - winner = teamWinnerName; - } + // won by a team, so naming the teams on that row is honest anyway. + sideA = isDaihyo ? sideAName : ""; + 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, @@ -3995,12 +3997,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, @@ -4070,6 +4067,17 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub submitRepresentative({ side, noticeKey, idx, value, priorId }); }; + // 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) }; + // 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, @@ -4113,10 +4121,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // from this same prop. playerName: resolveBoutSideDisplayName({ squad: squadB, memberId: playerBMemberId, storedName: playerBName }), memberId: playerBMemberId, - memberLabel: playerBLabel, roster: isDaihyoRow ? repRosterFor(m.sideB, lineupB, squadB) : rosterB, forceInput: isDaihyoRow ? repPickable : manualPathB, + memberLabel: playerBLabel, ...pickerB, noticeKey: noticeKeyB, lineupSlot: !isDaihyoRow && idx + 1 <= teamSize, - onSelectName: isDaihyoRow ? pickRepresentative("b", noticeKeyB, s.bMemberIdOverride) : (manualPathB ? pickManual("b", noticeKeyB, squadB, teamBId) : pickPlayer("b", noticeKeyB)), }, { key: "a", tapKey: `${idx}:a`, pts: s.aPts, fouls: s.aFouls, @@ -4131,10 +4138,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // bc-dnst: displayed name only, see the SHIRO note above. playerName: resolveBoutSideDisplayName({ squad: squadA, memberId: playerAMemberId, storedName: playerAName }), memberId: playerAMemberId, - memberLabel: playerALabel, roster: isDaihyoRow ? repRosterFor(m.sideA, lineupA, squadA) : rosterA, forceInput: isDaihyoRow ? repPickable : manualPathA, + memberLabel: playerALabel, ...pickerA, noticeKey: noticeKeyA, lineupSlot: !isDaihyoRow && idx + 1 <= teamSize, - onSelectName: isDaihyoRow ? pickRepresentative("a", noticeKeyA, s.aMemberIdOverride) : (manualPathA ? pickManual("a", noticeKeyA, squadA, teamAId) : pickPlayer("a", noticeKeyA)), }, ]; diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 6c13769f0..513d5103e 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -14,7 +14,7 @@ import { createTimerPool } from './timer_pool.jsx'; import { applyPatch, keepNewerCompetitions } from './patch.jsx'; -import { SideCell } from './side_cell.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 @@ -740,7 +740,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const callTapRef = useRefSh(null); // The announcement id (a promise of it, null if the send failed) of each // court call this console made, keyed by matchKey, so the call can be - // withdrawn once its match starts (bc-cdbl). A reload forgets them; the + // 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); @@ -1270,9 +1270,6 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (showToast) showToast(msg, "error"); return false; } - // The call has done its job once the competitors are at the line - // (bc-cdbl): withdraw it now that the match has started. - withdrawCall(matchKey(m)); return true; } catch (e) { if (mountedRef.current) setStartError(refusalFor((e && e.message) || "Could not start the match: check eligibility and try again.")); @@ -1371,14 +1368,14 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, 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 (ev && !acceptTap(callTapRef, ev, matchKey(m))) return; + 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)); try { - const sent = Promise.resolve(window.API.sendAnnouncement(msg, 5, password)); + const sent = window.API.sendAnnouncement(msg, 5, password); // Keep the call's id per match so starting the match can withdraw it // (bc-cdbl). Kept as a promise: a start that lands before the answer // must still withdraw the call once its id arrives. @@ -1395,7 +1392,7 @@ 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 started or been decided (bc-cdbl). Fire + // 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) => { @@ -1410,6 +1407,18 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, .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. @@ -1745,7 +1754,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, courts={courts} onMoveCourt={requestMoveCourt} onMove={moveMatch} onEnterLineup={(m) => setLineupKey(matchKey(m))} onPick={pickMatch} noticeFor={(m) => refusalNotice(matchKey(m))} - onCall={callToCourt} onWithdrawCall={withdrawCall} callingKey={callingKey} calledKey={calledKey} startingKey={startingKey} + onCall={callToCourt} callingKey={callingKey} calledKey={calledKey} startingKey={startingKey} scheduled={filteredScheduled} password={password} /> @@ -2160,14 +2169,14 @@ export function groupQueueMatches(matches) { return order.map((k) => byKey.get(k)); } -function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, noticeFor, onCall, onWithdrawCall, callingKey, calledKey, startingKey, password }) { +function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, noticeFor, onCall, callingKey, calledKey, startingKey, password }) { const renderRow = (m) => ( ); @@ -2193,7 +2202,7 @@ function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMove ); } -export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, onCall, onWithdrawCall, callingKey, calledKey, startingKey, pending, onResolve, slotLabel, password, notice }) { +export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, onCall, callingKey, calledKey, startingKey, pending, onResolve, slotLabel, password, notice }) { const isComplete = m.status === "completed"; // bc-cse: a scheduled match a competitor is barred from. `pending` // placeholder finals are excluded on purpose: their sides are still @@ -2209,8 +2218,9 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE const scoreCell = shiaijoScoreCell(m); // bc-tmwn: for completed rows, determine which side won and get team marks. const winnerSide = isComplete ? window.winnerSideLR?.(m) : null; - const { shiro: teamShiroMark, aka: teamAkaMark } = isComplete && isTeamMatch(m) && window.teamMatchMarks - ? window.teamMatchMarks(m, true) + // teamMatchMarks itself returns no marks for a non-team row or one not completed. + const { shiro: teamShiroMark, aka: teamAkaMark } = window.teamMatchMarks + ? window.teamMatchMarks(m, isTeamMatch(m)) : { shiro: "", aka: "" }; // Derive position in the full scheduled list to know when to disable ↑/↓. // `scheduled` is the court's complete scheduled array (including Up Next); @@ -2268,15 +2278,15 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE side carried by colour alone on the one surface where reading it wrong mis-scores a bout. */} - {winnerSide === "left" && ✓} - + {winnerSide === "left" && } + {teamNameMark("shiro", teamShiroMark, )} vs - {winnerSide === "right" && ✓} - + {winnerSide === "right" && } + {teamNameMark("aka", teamAkaMark, )} @@ -2285,7 +2295,7 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE so the row shows why and the one-tap resolution here instead of a dead Start button. BarredMatchNotice (admin_scoring_shared.jsx) is the one component: same note/action/reinstate on every surface. */} - {barred && onWithdrawCall && onWithdrawCall(matchKey(m))} />} + {barred && } {/* Completed result on its own centred line BELOW the names: the canonical "marks in the centre" position, but stacked so the (often long) names keep the full-width line and never crowd. The diff --git a/web-mobile/js/barred_match_notice.jsx b/web-mobile/js/barred_match_notice.jsx index c4a2f980e..8b0bca079 100644 --- a/web-mobile/js/barred_match_notice.jsx +++ b/web-mobile/js/barred_match_notice.jsx @@ -34,10 +34,7 @@ import { notLandedBanner, notSavedText, writeDidNotLand, queuedNotice } from './ // competitor_status_updated) once it lands. `onDone`, when a caller passes // one, still fires so a host that wants to react sooner (e.g. close a modal) // can. -// onDecisionRecorded (bc-cdbl), when given, fires once a decision (fusensho or -// the both-barred draw) has landed or been queued: the match is closed, so a -// court call for it is withdrawn. A reinstate does not fire it. -export function BarredMatchNotice({ match, password, onDone, onDecisionRecorded }) { +export function BarredMatchNotice({ match, password, onDone }) { const [busy, setBusy] = useState(false); // bc-cse: without this, a successful write's button re-enables the instant // busy clears and stays tappable until the SSE refetch repaints this @@ -84,7 +81,6 @@ export function BarredMatchNotice({ match, password, onDone, onDecisionRecorded setErr(queuedNotice(res)); } setLanded(true); - if (typeof onDecisionRecorded === "function") onDecisionRecorded(res); if (typeof onDone === "function") onDone(res); } catch (e) { if (!mountedRef.current) return; diff --git a/web-mobile/js/bracket.jsx b/web-mobile/js/bracket.jsx index a6480dc12..49cac2b26 100644 --- a/web-mobile/js/bracket.jsx +++ b/web-mobile/js/bracket.jsx @@ -18,6 +18,7 @@ import { BarredChip } from './barred_chip.jsx'; import { realIppons, enchoOn, defaultWinMaru } from './result_slot.jsx'; import { sameCompetitor } from './competitor_identity.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'; @@ -518,7 +519,7 @@ const PlayerLine = React.memo(({ player, isWinner, side, showDojo, score, isTBD,

- {isWinner ? ✓ : null} + {isWinner ? : null} {m2 ? {m2} : null} diff --git a/web-mobile/js/lineup_resolver.jsx b/web-mobile/js/lineup_resolver.jsx index b1736bea0..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); @@ -577,16 +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 rows stay lineup-first). The representative bout never -// reaches this function: its identity is the operator's pick (playerNamesForBout, -// bc-dhrp). 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/side_cell.jsx b/web-mobile/js/side_cell.jsx index d58fa6952..4e811662f 100644 --- a/web-mobile/js/side_cell.jsx +++ b/web-mobile/js/side_cell.jsx @@ -55,6 +55,18 @@ 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. +export function WinnerTick() { + return ( + + + Winner + + ); +} + // A tinted side cell. // // side "shiro" | "aka". The one place the surface states it. diff --git a/web-mobile/js/viewer_match.jsx b/web-mobile/js/viewer_match.jsx index c965336c4..4d402885e 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,10 +195,6 @@ export function MatchDetailCard({ match, onClose, escapeToClose = true, slotLabe // VSchedItem // --------------------------------------------------------------------------- -// The bracket card's winner tick (.bc-winner-tick), shown by VSchedItem on the -// Recent results list only (its `winnerTick` prop). -const winnerTickMark = ✓; - 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 @@ -298,7 +294,7 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig {/* 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 && winnerTickMark}{barredNameMark(m, "shiro", teamNameMark("shiro", teamShiroMark, ))} + {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) → @@ -326,7 +322,7 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig )}
- {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))}{winnerTick && aWin && winnerTickMark} + {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))}{winnerTick && aWin && } {tweaks.showDojo && m.sideA?.dojo ? {m.sideA.dojo} : null}
From 79ed8e171f0c22b569ab270ab8622d780760a0e4 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 19:04:20 +0100 Subject: [PATCH 14/38] fix: address the PR #463 code review - The self-run representative check judges only a member id the write introduces: an id the stored row already holds (a correction seated another team) and a side with no team id are not refused. - A side given another team drops its representative pick, and the latest representative pick on a side wins over a typed name still resolving. - Calling a match again withdraws its earlier call, whose text may differ. - An advance that cannot start because another start is still out says so on Up next instead of leaving the court idle. - The queue row's winner tick takes the row's gap, not its own margin. Co-Authored-By: Claude Opus 5.5 --- internal/mobileapp/handlers_match.go | 36 +++++++--- internal/mobileapp/self_run_daihyosen_test.go | 47 ++++++++++++++ web-mobile/css/styles.css | 4 ++ .../render/admin_shiaijo.render.test.jsx | 65 +++++++++++++++++++ .../call_to_court_double_tap.render.test.jsx | 27 ++++++++ ...m_daihyosen_representative.render.test.jsx | 40 ++++++++++++ web-mobile/js/admin_scoring_team.jsx | 25 +++++-- web-mobile/js/admin_shiaijo.jsx | 19 +++++- 8 files changed, 249 insertions(+), 14 deletions(-) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 97e7690e3..eb97f5606 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2068,7 +2068,7 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res if row >= 0 { // bc-dhrp: the picks name the side's own team's members (the lineup's rule). // Not judged when a stored verdict stands: it replaces the row below. - return repMembersOutsideTeams(stx, compID, snap.Pairing, subs[row]) + return repMembersOutsideTeams(stx, compID, snap.Pairing, subs[row], stored) } return nil } @@ -2115,8 +2115,29 @@ var errRepMemberNotInTeam = &selfRunRefusal{ // squad is the only thing that says whose they are: a pick of another team's // member, or of an id no team holds, is refused rather than written. The // organiser's write is not judged here (only the self-run path calls this). -func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult) error { - if row.SideAMemberID == "" && row.SideBMemberID == "" { +// +// A write answers for what it introduces, not for what it inherited (the write +// guard's rule): a side is judged only when the write names a member for it that +// the stored row does not already hold (stored, nil when the match has no +// representative bout), and only against a stored side that carries a team id. +// A side with no team id has nothing to judge the pick against, so it is not +// refused. +func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult) error { + var storedA, storedB string + if stored != nil { + storedA, storedB = stored.SideAMemberID, stored.SideBMemberID + } + type judged struct{ teamID, memberID string } + var picks []judged + add := func(teamID, memberID, storedID string) { + if memberID == "" || memberID == storedID || teamID == "" { + return + } + picks = append(picks, judged{teamID: teamID, memberID: memberID}) + } + add(pairing.SideAID, row.SideAMemberID, storedA) + add(pairing.SideBID, row.SideBMemberID, storedB) + if len(picks) == 0 { return nil } squads, err := stx.LoadSquads(compID) @@ -2124,11 +2145,10 @@ func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.Win log.Printf("mobileapp: representative member check for %s: %v", compID, err) return err } - if row.SideAMemberID != "" && !teamMemberIDs(squads, pairing.SideAID)[row.SideAMemberID] { - return errRepMemberNotInTeam - } - if row.SideBMemberID != "" && !teamMemberIDs(squads, pairing.SideBID)[row.SideBMemberID] { - return errRepMemberNotInTeam + for _, p := range picks { + if !teamMemberIDs(squads, p.teamID)[p.memberID] { + return errRepMemberNotInTeam + } } return nil } diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index 5d0e48d0e..2c4c4263f 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -990,6 +990,53 @@ func TestSelfRun_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "the organiser's pick is not judged on membership: %s", w.Body.String()) } +// A pick the stored row already holds is inherited, not introduced by the write: +// a correction that seated another team leaves the old team's member on the row, +// and the participant's echo of it is kept rather than refused against the team +// now holding that side. A pick the write changes is still judged. +func TestSelfRun_AStoredRepresentativeIdIsNotJudgedAgainstTheTeamNow(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersB := squads[repBoutTeamBID] + require.GreaterOrEqual(t, len(membersB), 2, "team B is seeded with members") + f.setB1(t, func(bm *state.BracketMatch) { + bm.SubResults[state.DaihyosenSubIndex(bm.SubResults)].SideAMemberID = membersB[0].ID + }) + + echo := repBoutRow([]string{}, []string{}, "") + echo["sideAMemberId"] = membersB[0].ID + w := f.score("", state.MatchStatusRunning, "", f.now+200, echo) + require.Equal(t, http.StatusOK, w.Code, "the echo of the stored pick is kept: %s", w.Body.String()) + assert.Equal(t, membersB[0].ID, f.storedRepBout(t).SideAMemberID) + + changed := repBoutRow([]string{}, []string{}, "") + changed["sideAMemberId"] = membersB[1].ID + w = f.score("", state.MatchStatusRunning, "", f.now+300, changed) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") + assert.Equal(t, membersB[0].ID, f.storedRepBout(t).SideAMemberID, "a refused pick writes nothing") +} + +// A side the stored match carries no participant id for has no team to judge a +// pick against, so the pick is not refused as "not on this team". An empty team +// id names no team, so every pick on such a side used to be refused. +func TestSelfRun_ARepresentativePickOnAnIdlessSideIsNotJudged(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA := squads[repBoutTeamAID] + require.NotEmpty(t, membersA, "team A is seeded with members") + f.setB1(t, func(bm *state.BracketMatch) { bm.SideAID = "" }) + + pick := repBoutRow([]string{}, []string{}, "") + pick["sideAMemberId"] = membersA[0].ID + w := f.score("", state.MatchStatusRunning, "", f.now+200, pick) + require.Equal(t, http.StatusOK, w.Code, "the pick on an id-less side is accepted: %s", w.Body.String()) + assert.Equal(t, membersA[0].ID, f.storedRepBout(t).SideAMemberID) +} + // A participant's echo of the organiser's recorded verdict is replaced by the // stored row whatever picks it carries, so a foreign pick on it is not refused // (the picks are never written). diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 28fd8519a..9b6a2a6fd 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -9879,6 +9879,10 @@ abbr.msb-lab { text-decoration: none; cursor: help; } .shiaijo-qrow__side { display: flex; align-items: center; gap: 6px; min-width: 0; padding: 4px 8px; } .shiaijo-qrow__side--shiro { border-radius: 6px 0 0 6px; } .shiaijo-qrow__side--aka { flex-direction: row-reverse; text-align: right; border-radius: 0 6px 6px 0; } +/* The winner tick is a flex child here, so the side's gap spaces it. Its own + margin-right (for the bracket card's inline use) would double that gap on + Shiro and push Aka's tick in from its outer edge. */ +.shiaijo-qrow__side .bc-winner-tick { margin-right: 0; } .shiaijo-qrow__name { font-weight: 600; font-size: 14px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .shiaijo-qrow__vs { font-size: 11px; color: var(--ink-3); } /* Completed result, centred on its own line below the names (the names keep the diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 1f3320c81..d3783c569 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -1148,6 +1148,71 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { expect(errorEl.textContent).toContain(CLOCK_SKEW_REASON_TEXT); }); + // A Finish + Start Next that asks for Up next while a start of ANOTHER match + // is still out is refused by the one-start guard. That refusal must say so on + // Up next, not vanish (bc-aadv). + it('Finish + Start Next says why Up next was not started while another match is being started', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + const mLater = { + id: 'm-later', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:15', sideA: side('q1', 'Ito'), sideB: side('q2', 'Ishii'), + }; + window.tournamentMatches = () => [mRun, openMatch, mLater]; + window.filterMatchesByCourt = (m) => m; + const prevRevert = window.API.revertMatchToQueue; + window.API.revertMatchToQueue = vi.fn().mockResolvedValue({}); + // The queued row's start hangs; the finish of the running bout lands. + const onEditScore = vi.fn((_compId, matchId) => (matchId === 'm-later' ? new Promise(() => {}) : Promise.resolve({ status: 'ok' }))); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m-run'); + await act(async () => { utils.container.querySelector('.shiaijo-row__pick').click(); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-later']); + expect(probe.props.match?.id).toBe('m-run'); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed', winner: side('p1', 'Yamada') }); }); + // The finish landed and no second start went out: Up next was refused. + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-later', 'm-run']); + const note = utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); + expect(note).toBeTruthy(); + expect(note.textContent).toBe('Not started: another match is still being started on this court. Start it once that one has.'); + } finally { + window.API.revertMatchToQueue = prevRevert; + } + }); + + // The one-start guard stays silent for a repeat advance to the match already + // being started: that match is simply on its way. + it('a repeat Finish + Start Next to the match already being started stays silent', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + window.tournamentMatches = () => [mRun, openMatch]; + window.filterMatchesByCourt = (m) => m; + const prevRevert = window.API.revertMatchToQueue; + window.API.revertMatchToQueue = vi.fn().mockResolvedValue({}); + // Up next's own start hangs; the finish of the running bout lands. + const onEditScore = vi.fn((_compId, matchId) => (matchId === 'm-open' ? new Promise(() => {}) : Promise.resolve({ status: 'ok' }))); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + const upNextStart = [...utils.container.querySelector('.shiaijo-upnext__card').querySelectorAll('button')].find((b) => /start match/i.test(b.textContent)); + await act(async () => { upNextStart.click(); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-open']); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed', winner: side('p1', 'Yamada') }); }); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-open', 'm-run']); + expect(utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error')).toBeNull(); + } finally { + window.API.revertMatchToQueue = prevRevert; + } + }); + it('offers Reinstate for a reinstateable (kiken-injury) withdrawal', async () => { window.tournamentMatches = () => [barredMatch({ ineligibleSides: { b: 'kiken-injury' } }), openMatch]; window.filterMatchesByCourt = (m) => m; diff --git a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx index 66648c525..91a2a4bb6 100644 --- a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx @@ -263,6 +263,33 @@ describe('Call to court double tap (bc-cdbl)', () => { expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); }); + it('a second deliberate call withdraws the first banner, and starting the match withdraws the second', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + await wait(TAP_BOUNCE_MS + 50); + // A side's name resolved between the two calls, so the second announcement's + // text differs. The server replaces only an announcement with identical text, + // so the first banner must come down here, not wait out its expiry. + const [m1, m2] = twoScheduledMatches(); + showMatches(rerender, onEditScore, [{ ...m1, sideA: { id: 'p1', name: 'Yamada Taro' } }, m2]); + await act(async () => {}); + await pointerTap(callButtons()[0]); + await act(async () => {}); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(2); + expect(window.API.sendAnnouncement.mock.calls[1][0]).toContain('Yamada Taro'); + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); + expect(window.API.deleteAnnouncement).toHaveBeenLastCalledWith('ann-1', ''); + + await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); + await act(async () => {}); + showMatches(rerender, onEditScore, firstMatchWith({ status: 'running', sideA: { id: 'p1', name: 'Yamada Taro' } })); + await act(async () => {}); + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(2); + expect(window.API.deleteAnnouncement).toHaveBeenLastCalledWith('ann-2', ''); + }); + it('starting a match that was never called withdraws nothing', async () => { renderConsole(); await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 30a0a4d03..5b7feee8b 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -275,4 +275,44 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dh.sideBMemberId).toBe('m9'); expect(dh.sideB).toBe('Team B'); }); + + it('R10: a side given another team drops the representative it picked, so the write stops naming the old team\'s member', async () => { + const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); + const { onSubmit, rerenderWith } = await mount(match); + expect(dhInput('AKA').value).toBe('Alice'); + // A correction elsewhere seats team t3 on side A; the stored row still names Alice. + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(dhInput('AKA').value).toBe(''); + await clickFinishTwice(); + const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + expect('sideAMemberId' in dh, 'the old team\'s member is not sent').toBe(false); + expect(dh.sideA, 'the row names the team now on the side').toBe('Team C'); + }); + + it('R11: two typed names for one side: the newer pick wins even when the older name resolves last', async () => { + let resolveEve; + const eveAnswer = new Promise((resolve) => { resolveEve = resolve; }); + window.API.addTeamMember.mockImplementation((_c, _t, name) => (name === 'Eve' + ? eveAnswer + : Promise.resolve({ id: 'm-frank', name, index: 3 }))); + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit } = await mount(match); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const addEve = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(addEve.closest('button')); }); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Frank' } }); }); + const addFrank = await screen.findByText(/Add “Frank”/); + await act(async () => { fireEvent.click(addFrank.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Frank', '')); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + // The older name answers last. + await act(async () => { resolveEve({ id: 'm-eve', name: 'Eve', index: 4 }); }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(dhInput('SHIRO').value).toBe('Frank'); + await clickFinishTwice(); + const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + expect(dh.sideBMemberId, 'the later pick, not the older name that resolved last').toBe('m-frank'); + }); }); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 971b81939..14ef81dcc 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -1280,6 +1280,11 @@ 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]; const showMembers = (side, list) => { membersShown.current[side] = list; (side === "a" ? setSquadA : setSquadB)(list); @@ -1598,6 +1603,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const giveSideAnotherTeam = (side) => { dropSideMembers(side); dropLineup(side); + // The representative the side picked is a member of the team it no longer has, so the + // pick is cleared and the sheet stops sending that member's id. + if (daihyosenIdx >= 0) { + newRepPick(side); + pickDaihyosenRepresentative({ idx: daihyosenIdx, side, updateSub }, null); + } }; useEffectA(() => { const was = sideSeen.current; @@ -1982,21 +1993,26 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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 submitRepresentative = async ({ side, noticeKey, idx, value, priorId }) => { + const submitRepresentative = 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; - if (sideChanged()) return; + if (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) { - notify("error", `"${value}" could not be named: the team members are not loaded. Try again in a moment.`); + 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.")}`); @@ -4062,9 +4078,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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 }); + submitRepresentative({ side, noticeKey, idx, value, priorId, seq }); }; // One picker per side: the representative row's (bc-dhrp: the team's whole squad, diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 513d5103e..b1b5680cf 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1248,10 +1248,20 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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; + const refusalFor = (msg) => ({ key: matchKey(m), compId: m.compId, msg }); + if (startingKey) { + // 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 on Up next rather than doing + // nothing (bc-aadv). A repeat for the match already being started is + // silent: it is on its way. + if (startingKey !== matchKey(m) && mountedRef.current) { + setStartError(refusalFor("Not started: another match is still being started on this court. Start it once that one has.")); + } + return false; + } setStartError(null); setStartingKey(matchKey(m)); - const refusalFor = (msg) => ({ key: matchKey(m), compId: m.compId, msg }); try { // Starting makes the match running; the scoring panel shows // running[0], so it picks the match up on the next refetch. @@ -1374,6 +1384,11 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const b = (m.sideB && m.sideB.name) || "Shiro"; const msg = `Now calling ${b} and ${a} to Shiaijo ${court}.`.slice(0, 200); setCallingKey(matchKey(m)); + // A call already up for this match comes down before the new one goes + // out. 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). + withdrawCall(matchKey(m)); try { const sent = window.API.sendAnnouncement(msg, 5, password); // Keep the call's id per match so starting the match can withdraw it From fce5866cfeca8eddf4a99eee528c7399deb7a8d0 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 19:23:14 +0100 Subject: [PATCH 15/38] fix: refuse a queue Start while another start is out, before the defer A queue row's Start tapped while another match's start was still in flight sent the running bout back to the queue and then started nothing. The tap is now refused before the defer, as an open correction already refuses one (bc-crpn), with a notice on the row naming the match being started (startWhileStartingMessage). The advance's Up next note uses the same words. Co-Authored-By: Claude Opus 5.5 --- .../render/admin_shiaijo.render.test.jsx | 71 ++++++++++++++++++- ...ite_result_court_console_refusals.test.jsx | 11 ++- web-mobile/js/admin_shiaijo.jsx | 48 +++++++++---- web-mobile/js/write_result.jsx | 5 ++ 4 files changed, 116 insertions(+), 19 deletions(-) diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index d3783c569..68112a421 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, act, fireEvent, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; -import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, startWhileCorrectingMessage, correctWhileRunningMessage, CLOCK_SKEW_REASON_TEXT } from '../../write_result.jsx'; +import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, startWhileCorrectingMessage, startWhileStartingMessage, correctWhileRunningMessage, CLOCK_SKEW_REASON_TEXT } from '../../write_result.jsx'; import { scoreRowMatchName } from '../../pool_ids.jsx'; // Window globals required by admin_shiaijo.jsx. // MODULE-EVAL-TIME entries (e.g. `const AdminTopbar = window.AdminTopbar;`) @@ -1179,7 +1179,7 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-later', 'm-run']); const note = utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); expect(note).toBeTruthy(); - expect(note.textContent).toBe('Not started: another match is still being started on this court. Start it once that one has.'); + expect(note.textContent).toBe(startWhileStartingMessage({ label: scoreRowMatchName(mLater) })); } finally { window.API.revertMatchToQueue = prevRevert; } @@ -1858,3 +1858,70 @@ describe('a running match and an open correction never coexist on the console (b } finally { c.restore(); } }); }); + +// One start at a time (bc-aadv): while a Start is still out, a Start of another +// match is refused on its own row BEFORE the defer, so the running bout is not +// sent back to the queue for a start that never happens (the bc-crpn rule, +// applied to starts). A repeat for the match already being started is silent. +describe('a Start tapped while another start is still out is refused before anything is sent back (bc-aadv)', () => { + const rowOf = (c, text) => [...c.utils.container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes(text)); + const startButton = (el) => [...el.querySelectorAll('button')].find((b) => /^start/i.test(b.textContent.trim())); + // m1 runs on the court; m2 (Up next) and m3 wait. Up next's start is held in + // flight until the test calls land(). + const setup = async () => { + let land; + const onEditScore = vi.fn((_compId, matchId) => (matchId === 'm2' + ? new Promise((r) => { land = r; }) + : Promise.resolve({ applied: true }))); + const c = await mountCourt([courtMatch('m1', 'running'), courtMatch('m2', 'scheduled'), courtMatch('m3', 'scheduled')], { onEditScore }); + await act(async () => { startButton(c.utils.container.querySelector('.shiaijo-upnext__card')).click(); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((x) => x[1])).toEqual(['m2']); + // m1 was sent back for m2's start; the assertions below are about what a + // later tap does, so the count starts again from here. + window.API.revertMatchToQueue.mockClear(); + return { c, onEditScore, land: (res) => land(res) }; + }; + + it('a) a Start on another row is refused on that row: the running bout is not sent back and no second start goes out', async () => { + const { c, onEditScore } = await setup(); + try { + await act(async () => { startButton(rowOf(c, 'Aka m3')).click(); }); + await act(async () => {}); + const text = startWhileStartingMessage({ label: scoreRowMatchName(courtMatch('m2', 'scheduled')) }); + expect(window.API.revertMatchToQueue).not.toHaveBeenCalled(); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(c.editorMatch()).toBe('m1'); + expect(rowOf(c, 'Aka m3').querySelector('[role="alert"]').textContent).toBe(text); + expect(c.showToast).toHaveBeenCalledWith(text, 'error'); + } finally { c.restore(); } + }); + + it('b) the refusal goes once the held start lands', async () => { + const { c, land } = await setup(); + try { + await act(async () => { startButton(rowOf(c, 'Aka m3')).click(); }); + await act(async () => {}); + expect(rowOf(c, 'Aka m3').querySelector('[role="alert"]')).toBeTruthy(); + await act(async () => { land({ applied: true }); }); + await act(async () => {}); + expect(rowOf(c, 'Aka m3').querySelector('[role="alert"]')).toBeNull(); + } finally { c.restore(); } + }); + + it('c) a repeat Start on the match already being started does nothing: no revert, no notice', async () => { + const { c, onEditScore } = await setup(); + try { + // While its start is out, Up next's own button reads "Starting…" and is disabled. + const card = c.utils.container.querySelector('.shiaijo-upnext__card'); + const again = startButton(card); + expect(again.disabled).toBe(true); + await act(async () => { again.click(); }); + await act(async () => {}); + expect(window.API.revertMatchToQueue).not.toHaveBeenCalled(); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(card.querySelector('[role="alert"]')).toBeNull(); + expect(c.showToast).not.toHaveBeenCalled(); + } finally { c.restore(); } + }); +}); diff --git a/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx b/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx index 9aef82e11..eef8746b4 100644 --- a/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx +++ b/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx @@ -3,7 +3,7 @@ // 2026-09-27). The words are the operator's, so they are pinned here once. import { describe, it, expect } from 'vitest'; -import { startWhileCorrectingMessage, correctWhileRunningMessage, courtBusyMessage } from '../write_result.jsx'; +import { startWhileCorrectingMessage, startWhileStartingMessage, correctWhileRunningMessage, courtBusyMessage } from '../write_result.jsx'; describe('startWhileCorrectingMessage', () => { it('names the correction and the remedy', () => { @@ -12,6 +12,13 @@ describe('startWhileCorrectingMessage', () => { }); }); +describe('startWhileStartingMessage', () => { + it('names the start still on its way and when this match can start', () => { + expect(startWhileStartingMessage({ label: 'Pool A · Match 2 · Kato vs Sato' })) + .toBe('Pool A · Match 2 · Kato vs Sato is still being started on this court. Start this match once it has.'); + }); +}); + describe('correctWhileRunningMessage', () => { it('names the court and the running bout, with the court-busy remedy', () => { const msg = correctWhileRunningMessage({ court: 'A', label: 'Match 3 · Ito vs Abe' }); @@ -23,7 +30,7 @@ describe('correctWhileRunningMessage', () => { describe('the refusal copy follows the house words', () => { it('carries no em-dash and never says mat', () => { - const all = startWhileCorrectingMessage({ label: 'x' }) + correctWhileRunningMessage({ court: 'A', label: 'x' }); + const all = startWhileCorrectingMessage({ label: 'x' }) + startWhileStartingMessage({ label: 'x' }) + correctWhileRunningMessage({ court: 'A', label: 'x' }); expect(all).not.toMatch(/—/); expect(all).not.toMatch(/\bmats?\b/i); }); diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index b1b5680cf..5a5af0903 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -27,7 +27,7 @@ import { useDialogFocus } from './dialog_focus.jsx'; import { writeDidNotLand, writeKeepsEditorOpen, writeWasSuperseded, writeWasRefusedForClock, CLOCK_SKEW_REASON_TEXT, attemptScoreWrite, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, OVERRIDE_HELD_NOTICE, - startWhileCorrectingMessage, correctWhileRunningMessage, + startWhileCorrectingMessage, startWhileStartingMessage, correctWhileRunningMessage, } from './write_result.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. @@ -809,8 +809,9 @@ 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, why: "running" | "correcting" | "starting" }. // Only the tap is stored; the notice text is derived at render while the // condition still holds (refusalNotice below), so it cannot go stale. const [refusedTap, setRefusedTap] = useStateSh(null); @@ -1011,6 +1012,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. ONE lookup for liveBout below and the one-start refusal. + 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 @@ -1018,24 +1022,28 @@ 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. + const refusalBlocker = (why) => { + if (why === "running") return liveBout; + if (why === "starting") return startingBlocker; + 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, @@ -1254,9 +1262,10 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // after a decision) that asks for another match while a start is out // is refused, and the refusal says so on Up next rather than doing // nothing (bc-aadv). A repeat for the match already being started is - // silent: it is on its way. - if (startingKey !== matchKey(m) && mountedRef.current) { - setStartError(refusalFor("Not started: another match is still being started on this court. Start it once that one has.")); + // silent: it is on its way. A start that cannot be found on the court + // gets no sentence: the refusal is still returned, never acted on. + if (startingKey !== matchKey(m) && startingBlocker && mountedRef.current) { + setStartError(refusalFor(startWhileStartingMessage({ label: scoreRowMatchName(startingBlocker) }))); } return false; } @@ -1303,12 +1312,21 @@ 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; } + if (startingKey && m.status === "scheduled") { + if (startingKey !== matchKey(m) && startingBlocker) refuseTap(m, "starting"); + return; + } setRefusedTap(null); const cur = running[0] || null; const isSame = cur && matchKey(cur) === matchKey(m); diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index 9f53d6951..427434144 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -839,6 +839,11 @@ 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.`; +} + export function correctWhileRunningMessage({ court, label }) { return `${courtBusyMessage({ court, label })} Then correct this match.`; } From b30edc2ec9f33ebe209b23c31a44aff57d4745f8 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 20:00:33 +0100 Subject: [PATCH 16/38] fix: address code review round 2 on PR #463 - The one-start guard reads the start in flight from a ref, so an advance that awaited its finish write judges the court as it is now; its refusal is the derived row/Up next notice with a toast, and clears itself. - Calling a match again takes the earlier call down only once the new one is answered, so a failed send leaves the earlier banner up. - Finish + Start Next and the decision advance hand the write's answer back to the editor. - A typed representative holds Finish, Save and Close until it is named. - A participant's representative winner id must name one of the two representatives. - A side given another team clears its representative only when one is picked, so the sheet is not dirtied for nothing. - A read-only unpicked representative side shows its team name, as the scoreboards do. Co-Authored-By: Claude Opus 5.5 --- internal/mobileapp/handlers_match.go | 23 ++- internal/mobileapp/self_run_daihyosen_test.go | 31 ++++ .../render/admin_shiaijo.render.test.jsx | 160 +++++++++++++++++- .../call_to_court_double_tap.render.test.jsx | 34 +++- ...m_daihyosen_representative.render.test.jsx | 42 +++++ web-mobile/js/admin_scoring_team.jsx | 55 ++++-- web-mobile/js/admin_shiaijo.jsx | 104 ++++++++---- 7 files changed, 395 insertions(+), 54 deletions(-) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index eb97f5606..243fd927e 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2110,6 +2110,17 @@ var errRepMemberNotInTeam = &selfRunRefusal{ message: "The representative chosen is not on this team. Pick again from the list.", } +// errRepWinnerNotARepresentative refuses a participant's representative bout whose +// winner id names neither of the two representatives the same row names. The +// client attributes the winner by those ids, so such a winner would read as no one +// while the server credits a team by name. Same code as errRepMemberNotInTeam, with +// its own sentence (the winner is not a pick that is off the team). +var errRepWinnerNotARepresentative = &selfRunRefusal{ + status: http.StatusBadRequest, + code: codeTeamMemberNotInTeam, + message: "The winner chosen is neither side's representative. Pick the winner from the two representatives.", +} + // repMembersOutsideTeams judges the member ids a representative bout row names // against the team that holds each side. Ids are bare UUIDs, so the team's own // squad is the only thing that says whose they are: a pick of another team's @@ -2121,11 +2132,17 @@ var errRepMemberNotInTeam = &selfRunRefusal{ // the stored row does not already hold (stored, nil when the match has no // representative bout), and only against a stored side that carries a team id. // A side with no team id has nothing to judge the pick against, so it is not -// refused. +// refused. The winner id is judged the same way: a winner the write introduces +// must name one of the row's two representatives (errRepWinnerNotARepresentative). func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult) error { - var storedA, storedB string + var storedA, storedB, storedWinner string if stored != nil { - storedA, storedB = stored.SideAMemberID, stored.SideBMemberID + storedA, storedB, storedWinner = stored.SideAMemberID, stored.SideBMemberID, stored.WinnerMemberID + } + // WinnerIDNamesASide accepts an empty winner id, so only a non-empty one naming + // neither representative is refused. + if row.WinnerMemberID != storedWinner && !domain.WinnerIDNamesASide(row.WinnerMemberID, row.SideAMemberID, row.SideBMemberID) { + return errRepWinnerNotARepresentative } type judged struct{ teamID, memberID string } var picks []judged diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index 2c4c4263f..7f16ed987 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -990,6 +990,37 @@ func TestSelfRun_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "the organiser's pick is not judged on membership: %s", w.Body.String()) } +// The winner a participant names on the representative bout must be one of the +// two representatives the same row names: the client attributes the winner by +// those ids, and the server credits it by name, so a winner id matching neither +// side's pick would read as no winner on one side and a team's win on the other. +// A winner equal to a side's representative is accepted. +func TestSelfRun_TheRepresentativeWinnerMustBeOneOfTheRepresentatives(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA, membersB := squads[repBoutTeamAID], squads[repBoutTeamBID] + require.NotEmpty(t, membersA, "team A is seeded with members") + require.NotEmpty(t, membersB, "team B is seeded with members") + + // Team B's representative is picked on side B and scores the point; the winner + // id names team A's member, who is neither side's representative. + row := repBoutRow([]string{}, []string{"M"}, "TeamB") + row["sideBMemberId"] = membersB[0].ID + row["winnerMemberId"] = membersA[0].ID + w := f.score("", state.MatchStatusRunning, "", f.now+100, row) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") + assert.Empty(t, f.storedRepBout(t).WinnerMemberID, "a refused winner writes nothing") + + row = repBoutRow([]string{}, []string{"M"}, "TeamB") + row["sideBMemberId"] = membersB[0].ID + row["winnerMemberId"] = membersB[0].ID + w = f.score("", state.MatchStatusRunning, "", f.now+200, row) + require.Equal(t, http.StatusOK, w.Code, "a winner equal to a side's representative is accepted: %s", w.Body.String()) + assert.Equal(t, membersB[0].ID, f.storedRepBout(t).WinnerMemberID) +} + // A pick the stored row already holds is inherited, not introduced by the write: // a correction that seated another team leaves the old team's member on the row, // and the participant's echo of it is kept rather than refused against the team diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 68112a421..416997c65 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -98,7 +98,7 @@ function renderPage(tournament, court = 'A', props = {}) { onLogout={vi.fn()} onViewerMode={vi.fn()} password="" - showToast={vi.fn()} + showToast={props.showToast || vi.fn()} tweaks={{}} onSwitchCourt={vi.fn()} /> @@ -1081,6 +1081,42 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { expect(onEditScore.mock.calls[1][1]).toBe('m-open'); }); + // Every host hands the editor back what its write came back with: the finish + // write's answer, a partial apply's held groups included, is the return value. + it('Finish + Start Next hands the finish write\'s answer back to the editor', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + window.tournamentMatches = () => [mRun, openMatch]; + window.filterMatchesByCourt = (m) => m; + const answer = { applied: true, heldGroups: ['points'] }; + const onEditScore = vi.fn() + .mockResolvedValueOnce(answer) + .mockResolvedValueOnce({ status: 'ok' }); + await act(async () => { renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + let ret; + await act(async () => { ret = await probe.props.onSubmitAndNext({ status: 'completed', winner: side('p1', 'Yamada') }); }); + // The start-next write still went out after the finish. + expect(onEditScore).toHaveBeenCalledTimes(2); + expect(ret).toEqual(answer); + }); + + it('onAfterDecision hands the decision\'s answer back to the editor', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + window.tournamentMatches = () => [mRun, openMatch]; + window.filterMatchesByCourt = (m) => m; + const onEditScore = vi.fn().mockResolvedValue({ status: 'ok' }); + await act(async () => { renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + const decided = { winner: side('p1', 'Yamada') }; + let ret; + await act(async () => { ret = await probe.props.onAfterDecision(decided); }); + expect(ret).toBe(decided); + }); + // bc-aadv: Finish + Start Next keeps a refused start on the Up next card, // instead of swallowing the error silently. it('Finish + Start Next keeps a refused start on the Up next card', async () => { @@ -1166,9 +1202,10 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { window.API.revertMatchToQueue = vi.fn().mockResolvedValue({}); // The queued row's start hangs; the finish of the running bout lands. const onEditScore = vi.fn((_compId, matchId) => (matchId === 'm-later' ? new Promise(() => {}) : Promise.resolve({ status: 'ok' }))); + const showToast = vi.fn(); try { let utils; - await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore, showToast }); }); expect(probe.props.match?.id).toBe('m-run'); await act(async () => { utils.container.querySelector('.shiaijo-row__pick').click(); }); await act(async () => {}); @@ -1180,6 +1217,42 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { const note = utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); expect(note).toBeTruthy(); expect(note.textContent).toBe(startWhileStartingMessage({ label: scoreRowMatchName(mLater) })); + // The refusal is toasted as well as noted, the same as a refused tap. + expect(showToast).toHaveBeenCalledWith(startWhileStartingMessage({ label: scoreRowMatchName(mLater) }), 'error'); + } finally { + window.API.revertMatchToQueue = prevRevert; + } + }); + + // The refusal is derived from the start in flight, so it goes once that start + // lands, not only when Up next moves (the stored message used to outlive it). + it('the Up next note for a refused advance clears once the blocking start lands', async () => { + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + const mLater = { + id: 'm-later', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:15', sideA: side('q1', 'Ito'), sideB: side('q2', 'Ishii'), + }; + window.tournamentMatches = () => [mRun, openMatch, mLater]; + window.filterMatchesByCourt = (m) => m; + const prevRevert = window.API.revertMatchToQueue; + window.API.revertMatchToQueue = vi.fn().mockResolvedValue({}); + let landQueued; + const queuedStart = new Promise((resolve) => { landQueued = resolve; }); + const onEditScore = vi.fn((_compId, matchId) => (matchId === 'm-later' ? queuedStart : Promise.resolve({ status: 'ok' }))); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + await act(async () => { utils.container.querySelector('.shiaijo-row__pick').click(); }); + await act(async () => {}); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed', winner: side('p1', 'Yamada') }); }); + const note = () => utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); + expect(note()).toBeTruthy(); + await act(async () => { landQueued({ status: 'ok' }); }); + await act(async () => {}); + expect(note()).toBeNull(); } finally { window.API.revertMatchToQueue = prevRevert; } @@ -1925,3 +1998,86 @@ describe('a Start tapped while another start is still out is refused before anyt } finally { c.restore(); } }); }); + +// The one-start guard is judged from the start in flight NOW. An advance awaits +// its finish write and then starts the next match from the render that began the +// await, so a guard that reads that render's state sees a start begun meanwhile +// as absent, and one that has since landed as still out. +describe('the one-start guard reads the start in flight now, not the render that began an advance', () => { + const deferred = () => { + let resolve; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; + }; + const side = (id, name) => ({ id, name }); + const runM = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + const openM = { + id: 'm-open', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:10', sideA: side('s', 'Sato'), sideB: side('k', 'Kato'), + }; + const laterM = { + id: 'm-later', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:15', sideA: side('q1', 'Ito'), sideB: side('q2', 'Ishii'), + }; + const winner = side('p1', 'Yamada'); + // The queue row's Start button for the match whose side carries `name`. + const queueStart = (utils, name) => { + const row = [...utils.container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes(name)); + return [...row.querySelectorAll('button')].find((b) => /^start match$/i.test(b.textContent.trim())); + }; + let prevRevert; + beforeEach(() => { + prevRevert = window.API.revertMatchToQueue; + window.API.revertMatchToQueue = vi.fn().mockResolvedValue({}); + window.tournamentMatches = () => [runM, openM, laterM]; + window.filterMatchesByCourt = (m) => m; + }); + afterEach(() => { + window.API.revertMatchToQueue = prevRevert; + }); + + it('a) a finish that answers after a queue start began sends no second start, and says why Up next was not started', async () => { + const finish = deferred(); + // The queue start hangs; the finish of the running bout waits on `finish`. + const onEditScore = vi.fn((_compId, matchId) => { + if (matchId === 'm-run') return finish.promise; + if (matchId === 'm-later') return new Promise(() => {}); + return Promise.resolve({ applied: true }); + }); + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m-run'); + // The editor holds this closure while its finish is out. + const advance = probe.props.onSubmitAndNext; + let pending; + await act(async () => { pending = advance({ status: 'completed', winner }); }); + await act(async () => { queueStart(utils, 'Ito').click(); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-run', 'm-later']); + await act(async () => { finish.resolve({ applied: true }); await pending; }); + // No second start went out: the advance was refused, and the refusal shows. + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-run', 'm-later']); + expect(utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error').textContent) + .toBe(startWhileStartingMessage({ label: scoreRowMatchName(laterM) })); + }); + + it('b) a finish that answers after a start that has since landed is not refused as still starting', async () => { + const laterStart = deferred(); + const onEditScore = vi.fn((_compId, matchId) => (matchId === 'm-later' ? laterStart.promise : Promise.resolve({ applied: true }))); + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + await act(async () => { queueStart(utils, 'Ito').click(); }); + await act(async () => {}); + // The editor holds this closure while the queue start is still out. + const advance = probe.props.onSubmitAndNext; + await act(async () => { laterStart.resolve({ applied: true }); }); + await act(async () => {}); + await act(async () => { await advance({ status: 'completed', winner }); }); + // The next match was started: the start that had landed no longer blocks it. + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-later', 'm-run', 'm-open']); + expect(utils.container.querySelector('.shiaijo-upnext__error')).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx index 91a2a4bb6..5c83a9a51 100644 --- a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx @@ -263,7 +263,7 @@ describe('Call to court double tap (bc-cdbl)', () => { expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); }); - it('a second deliberate call withdraws the first banner, and starting the match withdraws the second', async () => { + it('a second deliberate call withdraws the first banner once the second is answered, and starting the match withdraws the second', async () => { const onEditScore = vi.fn().mockResolvedValue({ applied: true }); const { rerender } = renderConsole({ onEditScore }); await pointerTap(callButtons()[0]); @@ -271,7 +271,11 @@ describe('Call to court double tap (bc-cdbl)', () => { await wait(TAP_BOUNCE_MS + 50); // A side's name resolved between the two calls, so the second announcement's // text differs. The server replaces only an announcement with identical text, - // so the first banner must come down here, not wait out its expiry. + // so the first banner must come down here, not wait out its expiry. It comes + // down only once the second call is answered, so the banner is never taken + // down for a call that did not go out. + let answerSecond; + window.API.sendAnnouncement.mockImplementationOnce(() => new Promise((resolve) => { answerSecond = resolve; })); const [m1, m2] = twoScheduledMatches(); showMatches(rerender, onEditScore, [{ ...m1, sideA: { id: 'p1', name: 'Yamada Taro' } }, m2]); await act(async () => {}); @@ -279,6 +283,9 @@ describe('Call to court double tap (bc-cdbl)', () => { await act(async () => {}); expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(2); expect(window.API.sendAnnouncement.mock.calls[1][0]).toContain('Yamada Taro'); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + await act(async () => { answerSecond({ id: 'ann-2', message: 'x' }); }); + await act(async () => {}); expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); expect(window.API.deleteAnnouncement).toHaveBeenLastCalledWith('ann-1', ''); @@ -290,6 +297,29 @@ describe('Call to court double tap (bc-cdbl)', () => { expect(window.API.deleteAnnouncement).toHaveBeenLastCalledWith('ann-2', ''); }); + it('a failed re-send leaves the first call in place: nothing is withdrawn, and starting the match still takes it down', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + await wait(TAP_BOUNCE_MS + 50); + window.API.sendAnnouncement.mockImplementationOnce(async () => { throw new Error('network down'); }); + const [m1, m2] = twoScheduledMatches(); + showMatches(rerender, onEditScore, [{ ...m1, sideA: { id: 'p1', name: 'Yamada Taro' } }, m2]); + await act(async () => {}); + await pointerTap(callButtons()[0]); + await act(async () => {}); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(2); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + // The first banner is still up, so starting its match takes it down. + await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); + await act(async () => {}); + showMatches(rerender, onEditScore, firstMatchWith({ status: 'running', sideA: { id: 'p1', name: 'Yamada Taro' } })); + await act(async () => {}); + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); + }); + it('starting a match that was never called withdraws nothing', async () => { renderConsole(); await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 5b7feee8b..b17e78d26 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -315,4 +315,46 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const dh = dhEntryOf(onSubmit.mock.calls[0][0]); expect(dh.sideBMemberId, 'the later pick, not the older name that resolved last').toBe('m-frank'); }); + + it('R12: Finish and Close wait while a typed representative is still being named, and the finishing write carries the pick', async () => { + let answerEve; + window.API.addTeamMember.mockImplementation(() => new Promise((resolve) => { answerEve = resolve; })); + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit } = await mount(match); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); + // The member POST is still out. Finish or Close now would send or drop the pick + // while the member may already exist on the server, so both wait for it. + expect(screen.getByText('Finish').closest('button').disabled).toBe(true); + expect(screen.getByText('✕ Close').closest('button').disabled).toBe(true); + await act(async () => { answerEve({ id: 'm9', name: 'Eve', index: 3 }); }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(screen.getByText('Finish').closest('button').disabled).toBe(false); + await clickFinishTwice(); + expect(onSubmit).toHaveBeenCalledTimes(1); + const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + expect(dh.sideBMemberId).toBe('m9'); + expect(dh.sideB).toBe('Team B'); + }); + + it('R13: a side given another team with no representative picked leaves the sheet clean: nothing is autosaved', async () => { + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit, rerenderWith } = await mount(match); + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [DH_EMPTY] })); + // Past the autosave debounce: a sheet the side change left dirty would be written here. + await act(async () => { await new Promise((r) => setTimeout(r, 500)); }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('R14: on a read-only representative row an unpicked side shows its team name, and a picked side shows the representative', async () => { + // selfReport on a completed match: the row offers no picker, so it is read-only. + const match = makeMatch({ status: 'completed', subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b' }] }); + await mount(match, { selfReport: true }); + expect(screen.getByText('Carol', { selector: '.tsm-name__static' })).toBeTruthy(); + expect(screen.getByText('Team A', { selector: '.tsm-name__static' })).toBeTruthy(); + expect(screen.queryByText('-', { selector: '.tsm-name__static' })).toBeNull(); + }); }); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 14ef81dcc..2b84b05fa 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -1285,6 +1285,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // nothing (submitRepresentative), whichever answers first: the newer pick wins. const repPickSeq = useRefA({ a: 0, b: 0 }); const newRepPick = (side) => ++repPickSeq.current[side]; + // 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); @@ -1603,10 +1609,12 @@ 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 cleared and the sheet stops sending that member's id. - if (daihyosenIdx >= 0) { - newRepPick(side); + // pick is cleared and the sheet stops sending that member's id. Only a pick the row + // holds is cleared: an update marks the sheet dirty and autosaves, which a side change + // with nothing picked would then do for no edit the operator made. + if (daihyosenIdx >= 0 && subs[daihyosenIdx]?.[`${side}MemberIdOverride`]) { pickDaihyosenRepresentative({ idx: daihyosenIdx, side, updateSub }, null); } }; @@ -1993,7 +2001,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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 submitRepresentative = async ({ side, noticeKey, idx, value, priorId, seq }) => { + 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 @@ -2024,6 +2032,16 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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. 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 @@ -3157,9 +3175,12 @@ 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 + // 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: that row's group is named as changed + // (match_groups.jsx) and the server copies the row whole, so the omitted + // key clears the stored id. 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; @@ -3605,7 +3626,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 @@ -3729,7 +3750,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub {isComplete ? "CORRECTION" : "PRE-MATCH"}
)} - {canClose && } + {canClose && } @@ -4095,6 +4116,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub ? { 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, @@ -4136,7 +4163,7 @@ 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, ...pickerB, noticeKey: noticeKeyB, @@ -4153,7 +4180,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub color: "aka", label: "AKA", // 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, ...pickerA, noticeKey: noticeKeyA, @@ -4888,7 +4915,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub {reopenCtl.busy ? "Reopening…" : reopenCtl.landed ? "Reopened" : "Reopen match"} )} - {canClose && } + {canClose && } {kachinukiBoutMode ? ( // Kachinuki bout mode (mp-gmcg): TWO always-visible actions. // [Record bout]: a RUNNING write flagged kachinukiBoutFinal; @@ -5031,7 +5058,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub if (isComplete && !correctionReason) { setReasonPromptKind("correction"); return; } if (!isComplete && !confirmFinish(ev)) return; doSubmit(() => (isComplete ? onSubmit : onSubmitAndNext)(buildPatch("completed"))); - }} disabled={submitting || koTieBlocked} + }} disabled={submitting || koTieBlocked || repTyping} title={koTieBlocked ? koTieTitle : undefined}> {submitting ? "Saving…" : koTieBlocked ? "Needs a winner" : isComplete ? "Save correction" : finishArmed ? "Tap again to finish →" : "Finish + Start Next →"} @@ -5041,7 +5068,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub if (isComplete && !correctionReason) { setReasonPromptKind("correction"); return; } if (!isComplete && !confirmFinish(ev)) return; doSubmit(() => onSubmit(buildPatch("completed"))); - }} disabled={submitting || koTieBlocked} + }} disabled={submitting || koTieBlocked || repTyping} title={koTieBlocked ? koTieTitle : undefined}> {submitting ? "Saving…" : koTieBlocked ? "Needs a winner" : isComplete ? "Save correction" : finishArmed ? "Tap again to finish" : "Finish"} diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 5a5af0903..2eb4997a8 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -737,6 +737,12 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, 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); const callTapRef = useRefSh(null); // The announcement id (a promise of it, null if the send failed) of each // court call this console made, keyed by matchKey, so the call can be @@ -1013,7 +1019,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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. ONE lookup for liveBout below and the one-start refusal. + // 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 @@ -1033,9 +1039,12 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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 ref, 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). At render time the ref and the state agree. const refusalBlocker = (why) => { if (why === "running") return liveBout; - if (why === "starting") return startingBlocker; + if (why === "starting") return startingRef.current ? startingRef.current.match : null; return openCorrection; }; const refusalText = (why) => { @@ -1154,14 +1163,16 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, : 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. + // switched competition, or the court moved on), the stored refusal (the + // server's or a thrown start's message in startError) 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 for a start still out (refuseTap, "starting") is derived instead + // and clears itself once that start lands. A refusal that still applies comes + // straight back on the next tap. Keyed on the key VALUE: a refetch that keeps + // the same Up next leaves a refusal for it where it is. const upNextKey = upNext ? matchKey(upNext) : null; useEffectSh(() => { setStartError(null); @@ -1256,21 +1267,22 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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) => { - const refusalFor = (msg) => ({ key: matchKey(m), compId: m.compId, msg }); - if (startingKey) { + const key = matchKey(m); + const refusalFor = (msg) => ({ key, compId: m.compId, msg }); + const inFlight = startingRef.current; + 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 on Up next rather than doing - // nothing (bc-aadv). A repeat for the match already being started is - // silent: it is on its way. A start that cannot be found on the court - // gets no sentence: the refusal is still returned, never acted on. - if (startingKey !== matchKey(m) && startingBlocker && mountedRef.current) { - setStartError(refusalFor(startWhileStartingMessage({ label: scoreRowMatchName(startingBlocker) }))); - } + // 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)); + startingRef.current = { key, match: m }; + setStartingKey(key); try { // Starting makes the match running; the scoring panel shows // running[0], so it picks the match up on the next refetch. @@ -1295,7 +1307,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (showToast) showToast((e && e.message) || "Could not start the match", "error"); return false; } finally { - if (mountedRef.current) setStartingKey(null); + // Only the start that holds the ref clears it; the state follows the ref. + if (startingRef.current && startingRef.current.key === key) startingRef.current = null; + if (mountedRef.current) setStartingKey(startingRef.current ? startingRef.current.key : null); } }; @@ -1323,8 +1337,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, refuseTap(m, "correcting"); return; } - if (startingKey && m.status === "scheduled") { - if (startingKey !== matchKey(m) && startingBlocker) refuseTap(m, "starting"); + const inFlight = startingRef.current; + if (inFlight && m.status === "scheduled") { + if (inFlight.key !== matchKey(m)) refuseTap(m, "starting"); return; } setRefusedTap(null); @@ -1402,17 +1417,26 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const b = (m.sideB && m.sideB.name) || "Shiro"; const msg = `Now calling ${b} and ${a} to Shiaijo ${court}.`.slice(0, 200); setCallingKey(matchKey(m)); - // A call already up for this match comes down before the new one goes - // out. 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). - withdrawCall(matchKey(m)); + // The call already up for this match, if any. 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 { const sent = window.API.sendAnnouncement(msg, 5, password); - // Keep the call's id per match so starting the match can withdraw it - // (bc-cdbl). Kept as a promise: a start that lands before the answer - // must still withdraw the call once its id arrives. - callIdsRef.current.set(matchKey(m), sent.then((ann) => (ann && ann.id) || null, () => null)); + // Keep the newest call's id per match so starting the match can + // withdraw it (bc-cdbl). 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 call that was up stays up, so the entry becomes its id. + const id = sent.then((ann) => (ann && ann.id) || null, () => earlier); + callIdsRef.current.set(matchKey(m), id); + // The earlier call comes down once this one is answered, unless it is + // the same announcement (a failed send, or the server returning the + // id it already had). + if (earlier) { + takeDown(Promise.all([earlier, id]).then(([oldId, newId]) => (oldId !== newId ? oldId : null))); + } await sent; if (!mountedRef.current) return; setCalledKey(matchKey(m)); @@ -1432,6 +1456,12 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const pending = callIdsRef.current.get(key); if (!pending) return; callIdsRef.current.delete(key); + takeDown(pending); + }; + // 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; @@ -1963,8 +1993,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); @@ -1980,6 +2011,10 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, 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 @@ -1998,6 +2033,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (next && next.status === "scheduled") { await startMatch(next); } + // The decision's own answer, handed back as the editor gave it + // over: the editor's write is the decision, not the start. + return result; }} password={password} /> From e8703507c0fab172f0b82e8f08648a852ee679a1 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 20:32:36 +0100 Subject: [PATCH 17/38] fix: address code review round 3 on PR #463 - A side given another team clears its representative the server-following way (no edit stamp, no dirty mark, no autosave, an armed Finish stays armed), so it cannot overwrite a pick made on another device. - A participant's representative winner id is judged whenever the write changes any of the row's ids, so a changed pick cannot leave a winner id that names neither side. - A typed representative still being named holds the representative bout's Add and Remove, and a change of that bout supersedes it. - A participant's unread-members warning names the organiser, not the Lineups page. - The winner tick's screen-reader text is "Winner: ", separated from the name. - A court call re-sent with the same text no longer deletes the earlier id, which the server already replaced. - openapi: PUT /score documents the self-run 400 team_member_not_in_team. - Dropped a return value no caller reads; corrected a stale comment. Co-Authored-By: Claude Opus 5.5 --- internal/mobileapp/handlers_match.go | 12 +++- internal/mobileapp/self_run_daihyosen_test.go | 50 ++++++++++++++++ specs/openapi.yaml | 28 ++++++++- .../js/__tests__/admin_shiaijo.test.jsx | 4 +- .../render/admin_shiaijo.render.test.jsx | 15 ----- .../call_to_court_double_tap.render.test.jsx | 48 +++++++++++++++ ...m_daihyosen_representative.render.test.jsx | 52 ++++++++++++++++ web-mobile/js/admin_scoring_team.jsx | 24 +++++--- web-mobile/js/admin_shiaijo.jsx | 59 +++++++++++-------- web-mobile/js/side_cell.jsx | 2 +- 10 files changed, 239 insertions(+), 55 deletions(-) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 243fd927e..6e011ab0f 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2132,16 +2132,22 @@ var errRepWinnerNotARepresentative = &selfRunRefusal{ // the stored row does not already hold (stored, nil when the match has no // representative bout), and only against a stored side that carries a team id. // A side with no team id has nothing to judge the pick against, so it is not -// refused. The winner id is judged the same way: a winner the write introduces -// must name one of the row's two representatives (errRepWinnerNotARepresentative). +// refused. The winner id is judged whenever the write introduces any of the +// row's three ids (see the check below): a non-empty winner must then name one of +// the row's two representatives (errRepWinnerNotARepresentative). func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult) error { var storedA, storedB, storedWinner string if stored != nil { storedA, storedB, storedWinner = stored.SideAMemberID, stored.SideBMemberID, stored.WinnerMemberID } + // The winner is judged whenever the write introduces any of its three ids (no + // stored row, or the winner or a side id differs from the stored row's), so a + // winner kept from the stored row cannot be left naming neither representative + // once a side changes. A row wholly matching the stored one is not judged. // WinnerIDNamesASide accepts an empty winner id, so only a non-empty one naming // neither representative is refused. - if row.WinnerMemberID != storedWinner && !domain.WinnerIDNamesASide(row.WinnerMemberID, row.SideAMemberID, row.SideBMemberID) { + introduces := stored == nil || row.WinnerMemberID != storedWinner || row.SideAMemberID != storedA || row.SideBMemberID != storedB + if introduces && !domain.WinnerIDNamesASide(row.WinnerMemberID, row.SideAMemberID, row.SideBMemberID) { return errRepWinnerNotARepresentative } type judged struct{ teamID, memberID string } diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index 7f16ed987..3a90d05c9 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -1021,6 +1021,56 @@ func TestSelfRun_TheRepresentativeWinnerMustBeOneOfTheRepresentatives(t *testing assert.Equal(t, membersB[0].ID, f.storedRepBout(t).WinnerMemberID) } +// A winner the write inherits from the stored row is still judged once a side's +// representative changes: a write that keeps the stored winner id but seats a new +// member on side A would otherwise leave the winner naming neither representative, +// and attribution would credit nobody. A row whose ids and winner all match the +// stored row is not judged. +func TestSelfRun_AChangedRepresentativeJudgesAnInheritedWinner(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA, membersB := squads[repBoutTeamAID], squads[repBoutTeamBID] + require.GreaterOrEqual(t, len(membersA), 3, "team A is seeded with members") + require.NotEmpty(t, membersB, "team B is seeded with members") + x, z, y := membersA[0].ID, membersA[1].ID, membersB[0].ID + f.setB1(t, func(bm *state.BracketMatch) { + row := &bm.SubResults[state.DaihyosenSubIndex(bm.SubResults)] + row.IpponsA = []string{"M"} + row.Winner = "TeamA" + row.SideAMemberID, row.SideBMemberID, row.WinnerMemberID = x, y, x + }) + pick := func(sideA, sideB, winner string) map[string]any { + row := repBoutRow([]string{"M"}, []string{}, "TeamA") + row["sideAMemberId"] = sideA + row["sideBMemberId"] = sideB + row["winnerMemberId"] = winner + return row + } + + // Side A's pick moves to team A's other member, and the winner keeps the stored + // id x, which is no longer either representative. + w := f.score("", state.MatchStatusRunning, "", f.now+100, pick(z, y, x)) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") + stored := f.storedRepBout(t) + assert.Equal(t, x, stored.SideAMemberID, "a refused write leaves the stored pick") + assert.Equal(t, x, stored.WinnerMemberID, "a refused write leaves the stored winner") + + // The same change names the new representative as the winner, and is accepted. + w = f.score("", state.MatchStatusRunning, "", f.now+200, pick(z, y, z)) + require.Equal(t, http.StatusOK, w.Code, "a winner that is the new representative is accepted: %s", w.Body.String()) + assert.Equal(t, z, f.storedRepBout(t).WinnerMemberID) + + // A row the stored row wholly matches is an echo, not a change: its winner is + // not judged even though it names neither representative. + f.setB1(t, func(bm *state.BracketMatch) { + bm.SubResults[state.DaihyosenSubIndex(bm.SubResults)].WinnerMemberID = membersA[2].ID + }) + w = f.score("", state.MatchStatusRunning, "", f.now+300, pick(z, y, membersA[2].ID)) + require.Equal(t, http.StatusOK, w.Code, "a wholly inherited row is not judged: %s", w.Body.String()) +} + // A pick the stored row already holds is inherited, not introduced by the write: // a correction that seated another team leaves the old team's member on the row, // and the participant's echo of it is kept rather than refused against the team diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 07dce2a42..e912b2d3a 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -51,7 +51,9 @@ info: not have (a sheet a moment behind a remove made elsewhere) is dropped from the write and the rest is written as usual, unless the write finishes the match, which may take its winner from that row and is refused with 409 `no_daihyosen`; a write listing - two is refused with 400 `duplicate_daihyosen`. Nor does it remove one: a write listing + two is refused with 400 `duplicate_daihyosen`, and a representative pick that names a member the + side's team does not hold, or a winner that names neither representative, is refused with 400 + `team_member_not_in_team`. Nor does it remove one: a write listing the bouts without the representative bout the match has (a sheet a moment behind an add made elsewhere) keeps the stored row, scored or not, unless it finishes the match, which is refused with 409 `daihyosen_added`. It carries only what the competitors saw: a match @@ -2477,6 +2479,17 @@ paths: participant: "A team match has one representative bout, and this score lists more than one. Check the scores and try again." + The same request with an empty password is refused with `error: + team_member_not_in_team`, with a `message` to show, when its + representative bout (position `-1`) names a member the side's team + does not hold (a pick the stored row does not already hold), or when + its winner id names neither of the row's two representatives (judged + whenever the write introduces any of the row's three ids). The + `message` is "The representative chosen is not on this team. Pick + again from the list.", or "The winner chosen is neither side's + representative. Pick the winner from the two representatives." + Nothing is written. The organiser's picks are not checked. + A `completed` write whose `decision` is empty or `hikiwake` on a match whose stored result is a completed withdrawal (kiken or fusenpai) is a score sheet's correction, which cannot state a @@ -2505,7 +2518,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Error' + type: object + required: [error] + properties: + error: + type: string + description: > + The reason the payload was refused, or for the self-run refusals + the machine-readable code (`duplicate_daihyosen`, + `team_member_not_in_team`). + message: + type: string + description: The sentence the public page shows as it is, for the self-run refusals. '401': description: > `invalid tournament password`: the password is missing or wrong in an diff --git a/web-mobile/js/__tests__/admin_shiaijo.test.jsx b/web-mobile/js/__tests__/admin_shiaijo.test.jsx index acdfa3d7f..4301b5e68 100644 --- a/web-mobile/js/__tests__/admin_shiaijo.test.jsx +++ b/web-mobile/js/__tests__/admin_shiaijo.test.jsx @@ -259,7 +259,9 @@ describe('ShiaijoQueueRow; completed result placement', () => { walk(akaSide, (n) => { if (n && n.type?.name === 'WinnerTick') { walk(n.type(n.props), (c) => { - if (c && c.props?.className === 'sr-only' && text(c) === 'Winner') hasTick = true; + // The separator is part of the announced text: without it a screen + // reader reads the tick run into the name ("WinnerK1 Alice"). + if (c && c.props?.className === 'sr-only' && text(c) === 'Winner: ') hasTick = true; }); } }); diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 416997c65..12321d6ea 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -1102,21 +1102,6 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { expect(ret).toEqual(answer); }); - it('onAfterDecision hands the decision\'s answer back to the editor', async () => { - const mRun = { - id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', - court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), - }; - window.tournamentMatches = () => [mRun, openMatch]; - window.filterMatchesByCourt = (m) => m; - const onEditScore = vi.fn().mockResolvedValue({ status: 'ok' }); - await act(async () => { renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); - const decided = { winner: side('p1', 'Yamada') }; - let ret; - await act(async () => { ret = await probe.props.onAfterDecision(decided); }); - expect(ret).toBe(decided); - }); - // bc-aadv: Finish + Start Next keeps a refused start on the Up next card, // instead of swallowing the error silently. it('Finish + Start Next keeps a refused start on the Up next card', async () => { diff --git a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx index 5c83a9a51..dcfbc9f34 100644 --- a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx @@ -320,6 +320,54 @@ describe('Call to court double tap (bc-cdbl)', () => { expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); }); + it('a re-call with unchanged text deletes nothing: the server already replaced the first banner, and starting the match withdraws the new one', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + await wait(TAP_BOUNCE_MS + 50); + // The same text again. The server removes the active announcement with + // identical text and answers with a new id, so the earlier id is already gone + // and a DELETE of it would 404 ("Could not withdraw the court call"). + await pointerTap(callButtons()[0]); + await act(async () => {}); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(2); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + + await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); + await act(async () => {}); + showMatches(rerender, onEditScore, firstMatchWith({ status: 'running' })); + await act(async () => {}); + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-2', ''); + }); + + it('after an unchanged re-call, a changed re-call takes down the banner that is up, once it is answered', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { rerender } = renderConsole({ onEditScore }); + await pointerTap(callButtons()[0]); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(callButtons()[0]); + await act(async () => {}); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + + await wait(TAP_BOUNCE_MS + 50); + let answerThird; + window.API.sendAnnouncement.mockImplementationOnce(() => new Promise((resolve) => { answerThird = resolve; })); + const [m1, m2] = twoScheduledMatches(); + showMatches(rerender, onEditScore, [{ ...m1, sideA: { id: 'p1', name: 'Yamada Taro' } }, m2]); + await act(async () => {}); + await pointerTap(callButtons()[0]); + await act(async () => {}); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(3); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + await act(async () => { answerThird({ id: 'ann-3', message: 'x' }); }); + await act(async () => {}); + // ann-2 is the banner up for this match; ann-1 was replaced by the server. + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-2', ''); + }); + it('starting a match that was never called withdraws nothing', async () => { renderConsole(); await pointerTap(screen.getAllByRole('button', { name: 'Start match' })[0]); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index b17e78d26..f0c0410e1 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -357,4 +357,56 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(screen.getByText('Team A', { selector: '.tsm-name__static' })).toBeTruthy(); expect(screen.queryByText('-', { selector: '.tsm-name__static' })).toBeNull(); }); + + it('R15: a side given another team clears a stored pick and sends nothing: the clear is the server following, not an operator edit', async () => { + const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); + const { onSubmit, rerenderWith } = await mount(match); + expect(dhInput('AKA').value).toBe('Alice'); + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + expect(dhInput('AKA').value).toBe(''); + // Past the autosave debounce: a clear that marked the sheet dirty would be written here. + await act(async () => { await new Promise((r) => setTimeout(r, 500)); }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('R16: a side given another team leaves an armed Finish armed', async () => { + const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); + const { rerenderWith } = await mount(match); + await act(async () => { fireEvent.click(screen.getByText('Finish')); }); + expect(screen.getByText('Tap again to finish')).toBeTruthy(); + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + expect(screen.getByText('Tap again to finish')).toBeTruthy(); + }); + + it('R17: under selfReport the members-unavailable warning tells a participant to ask the organizer, not to open the Lineups page', async () => { + window.API.fetchSquads.mockRejectedValue(new TypeError('Failed to fetch')); + window.API.addTeamMember.mockResolvedValue({ id: 'm9', name: 'Eve', index: 3 }); + const match = makeMatch({ subResults: [DH_EMPTY] }); + await mount(match, { selfReport: true }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(screen.getByTestId('team-editor-lineup-warning').textContent).toContain('may have been added as a new team member')); + const text = screen.getByTestId('team-editor-lineup-warning').textContent; + expect(text).toContain('Ask the tournament organizer to check the team.'); + expect(text).not.toContain('Lineups page'); + }); + + it('R18: Remove daihyosen is disabled while a typed representative is still being named, and offered again once it is', async () => { + let answerEve; + window.API.addTeamMember.mockImplementation(() => new Promise((resolve) => { answerEve = resolve; })); + const match = makeMatch({ subResults: [DH_EMPTY] }); + await mount(match); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); + expect(screen.getByTestId('team-daihyosen-remove').disabled).toBe(true); + await act(async () => { answerEve({ id: 'm9', name: 'Eve', index: 3 }); }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(screen.getByTestId('team-daihyosen-remove').disabled).toBe(false); + }); }); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 2b84b05fa..cf562d97b 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -1612,10 +1612,13 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub newRepPick(side); // The representative the side picked is a member of the team it no longer has, so the // pick is cleared and the sheet stops sending that member's id. Only a pick the row - // holds is cleared: an update marks the sheet dirty and autosaves, which a side change - // with nothing picked would then do for no edit the operator made. + // holds is cleared, and it is cleared 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. The server still holds the old id + // until the operator's next write, which carries the cleared row. if (daihyosenIdx >= 0 && subs[daihyosenIdx]?.[`${side}MemberIdOverride`]) { - pickDaihyosenRepresentative({ idx: daihyosenIdx, side, updateSub }, null); + const followServerSub = (idx, fn) => setSubs(prev => reconcileRowsToPositions(prev, serverSubs).map((s, i) => i === idx ? fn(s) : s)); + pickDaihyosenRepresentative({ idx: daihyosenIdx, side, updateSub: followServerSub }, null); } }; useEffectA(() => { @@ -2030,7 +2033,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub 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. Check the team on the Lineups page.`); + 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). @@ -3467,6 +3470,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 { @@ -4325,7 +4334,8 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub 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 "-", never the team name. It never gets + 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 @@ -4538,7 +4548,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 @@ -4628,7 +4638,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub admin_scoring_shared.jsx). The term is taught in the title/hint above instead. */} {!removingWithdrawal &&
-
} diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 2eb4997a8..005812b63 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -744,10 +744,11 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // landed still was). The state above only drives the rendering. const startingRef = useRefSh(null); const callTapRef = useRefSh(null); - // The announcement id (a promise of it, null if the send failed) of each - // court call this console made, keyed by matchKey, so the call can be - // withdrawn once its match leaves scheduled (bc-cdbl). A reload forgets them; the - // 5-minute expiry is the backstop. + // 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; @@ -1417,25 +1418,34 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, 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. 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). + // 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 { const sent = window.API.sendAnnouncement(msg, 5, password); - // Keep the newest call's id per match so starting the match can - // withdraw it (bc-cdbl). 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 call that was up stays up, so the entry becomes its id. - const id = sent.then((ann) => (ann && ann.id) || null, () => earlier); - callIdsRef.current.set(matchKey(m), id); - // The earlier call comes down once this one is answered, unless it is - // the same announcement (a failed send, or the server returning the - // id it already had). - if (earlier) { - takeDown(Promise.all([earlier, id]).then(([oldId, newId]) => (oldId !== newId ? oldId : null))); + 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; @@ -1453,10 +1463,10 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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 pending = callIdsRef.current.get(key); - if (!pending) return; + const entry = callIdsRef.current.get(key); + if (!entry) return; callIdsRef.current.delete(key); - takeDown(pending); + 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 @@ -2033,9 +2043,6 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (next && next.status === "scheduled") { await startMatch(next); } - // The decision's own answer, handed back as the editor gave it - // over: the editor's write is the decision, not the start. - return result; }} password={password} /> diff --git a/web-mobile/js/side_cell.jsx b/web-mobile/js/side_cell.jsx index 4e811662f..042695103 100644 --- a/web-mobile/js/side_cell.jsx +++ b/web-mobile/js/side_cell.jsx @@ -62,7 +62,7 @@ export function WinnerTick() { return ( - Winner + Winner: ); } From 5b297dc9936637457718fc151805e87f5f5ff3af Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 21:11:30 +0100 Subject: [PATCH 18/38] fix: address code review round 4 on PR #463 - A queue pick counts as a start in flight from the moment it begins, so a second Start tapped while the running bout is being sent back is refused rather than sending it back twice. - A refused advance start stays on Up next when that match becomes Up next; a refused pick still clears there, as before. A refused start shows on its own queue row too. - A side-change clear of a representative pick no longer reads as an unsaved change (Close, Prev/Next, and the silence check of an untouched row). - Recent results read Aka's tick after the name as ", winner". - Dropped the representative judge's unreachable nil case. - Tests: the representative render tests read the finishing write by its status, not by call order, so an autosave under load cannot be mistaken for it. Co-Authored-By: Claude Opus 5.5 --- internal/mobileapp/handlers_match.go | 35 ++--- .../render/admin_shiaijo.render.test.jsx | 131 +++++++++++++++++- ...m_daihyosen_representative.render.test.jsx | 94 +++++++++++-- ...winner_tick_reads_in_order.render.test.jsx | 69 +++++++++ web-mobile/js/admin_scoring_team.jsx | 48 ++++++- web-mobile/js/admin_shiaijo.jsx | 98 +++++++++---- web-mobile/js/side_cell.jsx | 9 +- web-mobile/js/viewer_match.jsx | 2 +- 8 files changed, 415 insertions(+), 71 deletions(-) create mode 100644 web-mobile/js/__tests__/render/winner_tick_reads_in_order.render.test.jsx diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 6e011ab0f..da1ca9196 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2129,24 +2129,25 @@ var errRepWinnerNotARepresentative = &selfRunRefusal{ // // A write answers for what it introduces, not for what it inherited (the write // guard's rule): a side is judged only when the write names a member for it that -// the stored row does not already hold (stored, nil when the match has no -// representative bout), and only against a stored side that carries a team id. -// A side with no team id has nothing to judge the pick against, so it is not -// refused. The winner id is judged whenever the write introduces any of the -// row's three ids (see the check below): a non-empty winner must then name one of -// the row's two representatives (errRepWinnerNotARepresentative). +// the stored row does not already hold, and only against a stored side that +// carries a team id. A side with no team id has nothing to judge the pick +// against, so it is not refused. The winner id is judged whenever the write +// introduces any of the row's three ids (see the check below): a non-empty +// winner must then name one of the row's two representatives +// (errRepWinnerNotARepresentative). +// +// stored is the match's representative bout and is never nil: the caller answers +// a row the match does not have before it gets here (dropped or refused), so this +// only judges a row the match already has. func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult) error { - var storedA, storedB, storedWinner string - if stored != nil { - storedA, storedB, storedWinner = stored.SideAMemberID, stored.SideBMemberID, stored.WinnerMemberID - } - // The winner is judged whenever the write introduces any of its three ids (no - // stored row, or the winner or a side id differs from the stored row's), so a - // winner kept from the stored row cannot be left naming neither representative - // once a side changes. A row wholly matching the stored one is not judged. - // WinnerIDNamesASide accepts an empty winner id, so only a non-empty one naming - // neither representative is refused. - introduces := stored == nil || row.WinnerMemberID != storedWinner || row.SideAMemberID != storedA || row.SideBMemberID != storedB + storedA, storedB, storedWinner := stored.SideAMemberID, stored.SideBMemberID, stored.WinnerMemberID + // The winner is judged whenever the write introduces any of its three ids (the + // winner or a side id differs from the stored row's), so a winner kept from the + // stored row cannot be left naming neither representative once a side changes. + // A row wholly matching the stored one is not judged. WinnerIDNamesASide accepts + // an empty winner id, so only a non-empty one naming neither representative is + // refused. + introduces := row.WinnerMemberID != storedWinner || row.SideAMemberID != storedA || row.SideBMemberID != storedB if introduces && !domain.WinnerIDNamesASide(row.WinnerMemberID, row.SideAMemberID, row.SideBMemberID) { return errRepWinnerNotARepresentative } diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 12321d6ea..d7ac85928 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -882,7 +882,7 @@ describe('AdminShiaijoPage render-smoke', () => { try { let utils; await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); - const refusal = () => utils.container.querySelector('.shiaijo-upnext__error'); + const refusal = () => utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); // The Up next card's own Start (an Upcoming row carries one too). const start = async () => { const card = utils.container.querySelector('.shiaijo-upnext__card'); @@ -958,7 +958,7 @@ describe('AdminShiaijoPage render-smoke', () => { try { let utils; await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); - const refusal = () => utils.container.querySelector('.shiaijo-upnext__error'); + const refusal = () => utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); const refresh = async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }; // Picked from the queue below Up next and refused: not shown under Up @@ -980,6 +980,96 @@ describe('AdminShiaijoPage render-smoke', () => { } }); + // The advance's own start (here the one after a decision) refused for the + // next match: the refusal stays on the card once a refetch makes that match + // Up next. The refetch is the very event the upNextKey effect used to clear + // it on. + it('a refused advance start for the next match stays on Up next once that match becomes Up next', async () => { + const side = (id, name) => ({ id, name }); + const mRun = { + id: 'm-run', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + court: 'A', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + // Up next before the decision. It involves the withdrawing Yamada, so the + // advance passes over it to m3. + const m2 = { + id: 'm2', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', sideA: side('p1', 'Yamada'), sideB: side('p5', 'Endo'), + }; + const m3 = { + id: 'm3', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:10', sideA: side('p4', 'Doi'), sideB: side('p6', 'Fujii'), + }; + let current = [mRun, m2, m3]; + window.tournamentMatches = () => current; + window.filterMatchesByCourt = (matches) => matches; + const prevFetch = window.API.fetchCourtMatches; + const prevSub = window.API.subscribeToEvents; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + const onEditScore = vi.fn().mockRejectedValue(new Error('Doi is fighting on shiaijo B')); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m-run'); + await act(async () => { + await probe.props.onAfterDecision({ + id: 'm-run', sideA: 'Yamada', sideB: 'Tanaka', sideAId: 'p1', sideBId: 'p2', winner: 'Tanaka', winnerId: 'p2', + status: 'completed', decision: 'kiken-voluntary', decisionBy: 'aka', + }); + }); + // The advance's start of m3 is refused. Up next is still m2, so the + // refusal is not on the card yet. + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(onEditScore.mock.calls[0][1]).toBe('m3'); + const upNextCard = () => utils.container.querySelector('.shiaijo-upnext__card'); + expect(upNextCard().querySelector('.shiaijo-upnext__error')).toBeNull(); + + // The refetch: m2 carries the barred stamp the server writes, so Up next + // moves onto m3, the match the refused start was for. + current = [{ ...mRun, status: 'completed' }, { ...m2, ineligibleSides: { a: 'kiken-voluntary' } }, m3]; + await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); + expect(upNextCard().textContent).toContain('Doi'); + expect(upNextCard().querySelector('.shiaijo-upnext__error')?.textContent).toBe('Doi is fighting on shiaijo B'); + } finally { + window.API.fetchCourtMatches = prevFetch; + window.API.subscribeToEvents = prevSub; + } + }); + + // A refused Start for a queue row's match (not Up next) shows on that row, + // so the operator sees why it did not start where they tapped it. + it('a refused Start shows on the queue row of the match it refused, not on Up next', async () => { + const side = (id, name) => ({ id, name }); + const m1 = { + id: 'm1', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:00', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), + }; + const m2 = { + id: 'm2', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:05', sideA: side('p3', 'Sato'), sideB: side('p4', 'Kato'), + }; + window.tournamentMatches = () => [m1, m2]; + window.filterMatchesByCourt = (matches) => matches; + const prevFetch = window.API.fetchCourtMatches; + const prevSub = window.API.subscribeToEvents; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + const onEditScore = vi.fn().mockRejectedValue(new Error('Kato is fighting on shiaijo B')); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + const rowOf = (name) => [...utils.container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes(name)); + await act(async () => { rowOf('Sato').querySelector('.shiaijo-row__pick').click(); }); + expect(onEditScore.mock.calls[0][1]).toBe('m2'); + expect(rowOf('Sato').querySelector('.shiaijo-upnext__error')?.textContent).toBe('Kato is fighting on shiaijo B'); + expect(utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error')).toBeNull(); + } finally { + window.API.fetchCourtMatches = prevFetch; + window.API.subscribeToEvents = prevSub; + } + }); + // Guard: verify that a missing window scope reference causes a render failure. // This is the exact class of bug that PR #271 introduced undetected: // requestMoveCourt was used in ShiaijoQueueGroup but not passed as a prop, @@ -1121,7 +1211,7 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { // The finishing write succeeds; the start-next write fails. expect(onEditScore).toHaveBeenCalledTimes(2); // The error is recorded on the Up next card. - const errorEl = utils.container.querySelector('.shiaijo-upnext__error'); + const errorEl = utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); expect(errorEl).toBeTruthy(); expect(errorEl.textContent).toContain('Sato is fighting on Shiaijo B.'); }); @@ -1142,7 +1232,7 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { // The start-next write fails. expect(onEditScore).toHaveBeenCalledTimes(1); // The error is recorded on the Up next card. - const errorEl = utils.container.querySelector('.shiaijo-upnext__error'); + const errorEl = utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); expect(errorEl).toBeTruthy(); expect(errorEl.textContent).toContain('Could not start the match'); }); @@ -1164,7 +1254,7 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { // The finishing write succeeds; the start-next write returns clock_skew. expect(onEditScore).toHaveBeenCalledTimes(2); // The clock-skew error is recorded on the Up next card. - const errorEl = utils.container.querySelector('.shiaijo-upnext__error'); + const errorEl = utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error'); expect(errorEl).toBeTruthy(); expect(errorEl.textContent).toContain(CLOCK_SKEW_REASON_TEXT); }); @@ -1982,6 +2072,35 @@ describe('a Start tapped while another start is still out is refused before anyt expect(c.showToast).not.toHaveBeenCalled(); } finally { c.restore(); } }); + + // A pick that defers the running bout is a start in flight from the moment + // it is made, not from the moment the deferred bout's revert answers: a second + // Start on another row during that wait must not send the running bout back + // a second time. + it('d) a second Start tapped while a pick is still deferring the running bout is refused, and the bout is not reverted twice', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const c = await mountCourt([courtMatch('m1', 'running'), courtMatch('m2', 'scheduled'), courtMatch('m3', 'scheduled'), courtMatch('m4', 'scheduled')], { onEditScore }); + let landRevert; + window.API.revertMatchToQueue = vi.fn(() => new Promise((r) => { landRevert = r; })); + try { + // The first pick: a queue row's Start, which reverts m1 and waits on it. + await act(async () => { startButton(rowOf(c, 'Aka m3')).click(); }); + await act(async () => {}); + expect(window.API.revertMatchToQueue).toHaveBeenCalledTimes(1); + + // A second Start on another row while that wait is out. + await act(async () => { startButton(rowOf(c, 'Aka m4')).click(); }); + await act(async () => {}); + expect(window.API.revertMatchToQueue).toHaveBeenCalledTimes(1); + expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]').textContent) + .toBe(startWhileStartingMessage({ label: scoreRowMatchName(courtMatch('m3', 'scheduled')) })); + + // Once the revert answers, the first pick starts and only it. + await act(async () => { landRevert(true); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((x) => x[1])).toEqual(['m3']); + } finally { c.restore(); } + }); }); // The one-start guard is judged from the start in flight NOW. An advance awaits @@ -2063,6 +2182,6 @@ describe('the one-start guard reads the start in flight now, not the render that await act(async () => { await advance({ status: 'completed', winner }); }); // The next match was started: the start that had landed no longer blocks it. expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-later', 'm-run', 'm-open']); - expect(utils.container.querySelector('.shiaijo-upnext__error')).toBeNull(); + expect(utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error')).toBeNull(); }); }); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index f0c0410e1..f5235e29e 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -92,14 +92,14 @@ function dhEntryOf(patch) { return (patch.subResults || []).find((s) => s.position === -1); } -async function mount(match, { selfReport = false } = {}) { +async function mount(match, { selfReport = false, onClose = vi.fn() } = {}) { const onSubmit = vi.fn(); const view = (m) => ( - + ); let utils; await act(async () => { utils = render(view(match)); }); - return { ...utils, onSubmit, rerenderWith: async (m) => { await act(async () => { utils.rerender(view(m)); }); } }; + return { ...utils, onSubmit, onClose, rerenderWith: async (m) => { await act(async () => { utils.rerender(view(m)); }); } }; } // The daihyosen row's two name boxes, by their aria-label ("Daihyosen SHIRO player"). @@ -129,6 +129,16 @@ async function clickFinishTwice() { await act(async () => { fireEvent.click(screen.getByText('Tap again to finish')); }); } +// The FINISH write: the one onSubmit call whose patch completes the match. A pick +// arms the editor's autosave, a RUNNING write sent through the same onSubmit, and +// its debounce is real time: under load it can land between the pick and Finish. +// So a test that means the finish cannot read calls[0] or count every call. +function finishPatchOf(onSubmit) { + const finishes = onSubmit.mock.calls.map(([patch]) => patch).filter((patch) => patch.status === 'completed'); + expect(finishes).toHaveLength(1); + return finishes[0]; +} + describe('team daihyosen representative picker (bc-dhrp)', () => { it('R1: the daihyosen row renders a name box on each side, and never the team name in it', async () => { await mount(makeMatch({ subResults: [DH_EMPTY] })); @@ -143,8 +153,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await pickFromDh('SHIRO', 'Carol'); expect(dhInput('SHIRO').value).toBe('Carol'); await clickFinishTwice(); - expect(onSubmit).toHaveBeenCalledTimes(1); - const patch = onSubmit.mock.calls[0][0]; + const patch = finishPatchOf(onSubmit); const dh = dhEntryOf(patch); expect(dh.sideBMemberId).toBe('m1b'); expect(dh.sideA).toBe('Team A'); @@ -175,7 +184,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { }); await rerenderWith(scored); await clickFinishTwice(); - const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + const dh = dhEntryOf(finishPatchOf(onSubmit)); expect(dh.ipponsB).toEqual(['M']); expect(dh.sideBMemberId).toBe('m1b'); expect(dh.sideB).toBe('Team B'); @@ -190,7 +199,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const clear = within(wrap).getByRole('button', { name: 'Clear player' }); await act(async () => { fireEvent.click(clear); }); await clickFinishTwice(); - const patch = onSubmit.mock.calls[0][0]; + const patch = finishPatchOf(onSubmit); const dh = dhEntryOf(patch); expect('sideBMemberId' in dh).toBe(false); expect(dh.sideB).toBe('Team B'); @@ -224,7 +233,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await act(async () => { fireEvent.click(add.closest('button')); }); await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); await clickFinishTwice(); - const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + const dh = dhEntryOf(finishPatchOf(onSubmit)); expect(dh.sideBMemberId).toBe('m9'); expect(dh.sideB).toBe('Team B'); expect(window.API.putMatchLineup).not.toHaveBeenCalled(); @@ -251,7 +260,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await waitFor(() => expect(window.API.renameTeamMember).toHaveBeenCalledWith('comp1', 't2', 'm3b', 'Frank', '')); expect(window.API.addTeamMember).not.toHaveBeenCalled(); await clickFinishTwice(); - const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + const dh = dhEntryOf(finishPatchOf(onSubmit)); expect(dh.sideBMemberId).toBe('m3b'); expect(dh.sideB).toBe('Team B'); }); @@ -271,7 +280,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(screen.getByTestId('team-editor-lineup-warning').getAttribute('data-tone')).toBe('warn'); expect(screen.getByTestId('team-editor-lineup-warning').textContent).not.toContain('Lineup saved'); await clickFinishTwice(); - const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + const dh = dhEntryOf(finishPatchOf(onSubmit)); expect(dh.sideBMemberId).toBe('m9'); expect(dh.sideB).toBe('Team B'); }); @@ -285,7 +294,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(dhInput('AKA').value).toBe(''); await clickFinishTwice(); - const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + const dh = dhEntryOf(finishPatchOf(onSubmit)); expect('sideAMemberId' in dh, 'the old team\'s member is not sent').toBe(false); expect(dh.sideA, 'the row names the team now on the side').toBe('Team C'); }); @@ -312,7 +321,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(dhInput('SHIRO').value).toBe('Frank'); await clickFinishTwice(); - const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + const dh = dhEntryOf(finishPatchOf(onSubmit)); expect(dh.sideBMemberId, 'the later pick, not the older name that resolved last').toBe('m-frank'); }); @@ -334,8 +343,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(screen.getByText('Finish').closest('button').disabled).toBe(false); await clickFinishTwice(); - expect(onSubmit).toHaveBeenCalledTimes(1); - const dh = dhEntryOf(onSubmit.mock.calls[0][0]); + const dh = dhEntryOf(finishPatchOf(onSubmit)); expect(dh.sideBMemberId).toBe('m9'); expect(dh.sideB).toBe('Team B'); }); @@ -409,4 +417,62 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(screen.getByTestId('team-daihyosen-remove').disabled).toBe(false); }); + + it('R19: a completed match whose side was given another team clears the stored pick, and Close closes at once with no discard prompt', async () => { + window.confirmDialog.mockClear(); + const match = makeMatch({ status: 'completed', subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); + const { rerenderWith, onClose } = await mount(match); + expect(dhInput('AKA').value).toBe('Alice'); + // A correction elsewhere seats team t3 on side A. The clear is the server following, not an edit. + await rerenderWith(makeMatch({ status: 'completed', sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(dhInput('AKA').value).toBe(''); + await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('R20: after a side is given another team, an operator pick on that side is an unsaved change, and Close asks first', async () => { + window.confirmDialog.mockClear(); + window.confirmDialog.mockResolvedValueOnce(false); + window.API.fetchSquads.mockResolvedValue({ ...SQUADS, t3: [{ id: 'm1c', name: 'Erin', index: 1 }] }); + const match = makeMatch({ status: 'completed', subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); + const { rerenderWith, onClose } = await mount(match); + await rerenderWith(makeMatch({ status: 'completed', sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await pickFromDh('AKA', 'Erin'); + expect(dhInput('AKA').value).toBe('Erin'); + await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); + expect(window.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ message: 'Discard unsaved scoring changes?' })); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('R21: a side given another team clears the stored pick without making the representative row a touched one: the next Finish leaves its scoreline unstated', async () => { + const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); + const { onSubmit, rerenderWith } = await mount(match); + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await clickFinishTwice(); + const dh = dhEntryOf(finishPatchOf(onSubmit)); + expect('sideAMemberId' in dh, 'the cleared pick is not sent').toBe(false); + expect(dh.ipponsA, 'an untouched row states no scoreline, so a stored verdict is not overwritten').toBeUndefined(); + expect(dh.ipponsB).toBeUndefined(); + }); + + it('R22: after a side is given another team, a pick the operator puts on that side and then clears is their own edit: Close asks first', async () => { + window.confirmDialog.mockClear(); + window.confirmDialog.mockResolvedValueOnce(false); + window.API.fetchSquads.mockResolvedValue({ ...SQUADS, t3: [{ id: 'm1c', name: 'Erin', index: 1 }] }); + const match = makeMatch({ status: 'completed', subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); + const { rerenderWith, onClose } = await mount(match); + await rerenderWith(makeMatch({ status: 'completed', sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await pickFromDh('AKA', 'Erin'); + const wrap = dhInput('AKA').closest('.lineup-name'); + await act(async () => { fireEvent.click(within(wrap).getByRole('button', { name: 'Clear player' })); }); + expect(dhInput('AKA').value).toBe(''); + await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); + expect(window.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ message: 'Discard unsaved scoring changes?' })); + expect(onClose).not.toHaveBeenCalled(); + }); }); diff --git a/web-mobile/js/__tests__/render/winner_tick_reads_in_order.render.test.jsx b/web-mobile/js/__tests__/render/winner_tick_reads_in_order.render.test.jsx new file mode 100644 index 000000000..649218c64 --- /dev/null +++ b/web-mobile/js/__tests__/render/winner_tick_reads_in_order.render.test.jsx @@ -0,0 +1,69 @@ +// The winner tick's screen-reader words follow the tick's place in the row. +// Before the name (Shiro on Recent results, the bracket card, the queue row) +// the words are "Winner: "; after the name (Aka on Recent results, where the +// tick sits at the outer edge) they are ", winner", so the row is spoken as +// "Aka: Tanaka, winner" and not "Aka: Tanaka Winner:". +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; + +let WinnerTick, VSchedItem, normalizeMatch; + +beforeAll(async () => { + window.matchScoreStr = vi.fn(() => ''); + ({ WinnerTick } = await import('../../side_cell.jsx')); + ({ normalizeMatch } = await import('../../api_serializers.jsx')); + ({ VSchedItem } = await import('../../viewer_match.jsx')); +}); + +afterAll(() => { + delete window.matchScoreStr; +}); + +// What a screen reader would say for an element: its text with the +// aria-hidden glyphs left out. +function spoken(el) { + const copy = el.cloneNode(true); + copy.querySelectorAll('[aria-hidden="true"]').forEach((n) => n.remove()); + return copy.textContent; +} + +function completedBout(winner) { + return normalizeMatch({ + id: 'm-recent-1', + status: 'completed', + court: 'A', + phase: 'bracket', + round: 'Final', + sideA: 'Tanaka', + sideB: 'Sato', + winner, + }, {}); +} + +describe('WinnerTick sr-only words follow the tick position', () => { + it('a tick after the name reads ", winner"', () => { + const { container } = render(Tanaka); + expect(container.querySelector('.sr-only').textContent).toBe(', winner'); + expect(spoken(container.firstChild)).toBe('Tanaka, winner'); + }); + + it('a tick before the name keeps "Winner: "', () => { + const { container } = render(Tanaka); + expect(container.querySelector('.sr-only').textContent).toBe('Winner: '); + expect(spoken(container.firstChild)).toBe('Winner: Tanaka'); + }); + + it('Recent results: Aka (tick after the name) reads ", winner"; Shiro keeps "Winner: "', () => { + const akaWins = render(); + const aka = akaWins.container.querySelector('.vsched-item__side--aka'); + expect(aka.querySelector('.bc-winner-tick .sr-only').textContent).toBe(', winner'); + expect(spoken(aka)).toBe('Aka: Tanaka, winner'); + akaWins.unmount(); + + const shiroWins = render(); + const shiro = shiroWins.container.querySelector('.vsched-item__side--shiro'); + expect(shiro.querySelector('.bc-winner-tick .sr-only').textContent).toBe('Winner: '); + expect(spoken(shiro)).toBe('Shiro: Winner: Sato'); + }); +}); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index cf562d97b..786bd85ce 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -692,6 +692,28 @@ function pickDaihyosenRepresentative({ idx, side, updateSub }, member) { updateSub(idx, prev => ({ ...prev, [memberIdKey]: (member && member.id) || "" })); } +// repBaselineAfterFollowedClear: serverSubs as the sheet's dirty baseline, with a +// representative pick that a side-change clear emptied (giveSideAnotherTeam) read as +// empty too. `followed` maps a side to the member id that clear superseded +// (followedRepClearRef). A pick is cleared in the baseline only where the sheet shows +// it empty AND the server still holds that same id, so a write that lands, or another +// device's change, moves the server off it and the clear stops counting. A pick the +// sheet shows is never hidden here. +function repBaselineAfterFollowedClear(serverSubs, dIdx, subs, followed) { + const served = serverSubs[dIdx]; + const shown = subs[dIdx]; + if (dIdx < 0 || !served || !shown) return serverSubs; + let row = null; + for (const side of ["a", "b"]) { + const key = `${side}MemberIdOverride`; + if (!followed[side] || served[key] !== followed[side] || shown[key]) continue; + row = row || { ...served }; + row[key] = ""; + } + if (!row) return serverSubs; + return serverSubs.map((s, i) => (i === dIdx ? row : s)); +} + // 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 @@ -1285,6 +1307,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // nothing (submitRepresentative), whichever answers first: the newer pick wins. const repPickSeq = useRefA({ a: 0, b: 0 }); const newRepPick = (side) => ++repPickSeq.current[side]; + // The representative picks giveSideAnotherTeam cleared while the server still held them: + // side -> the member id the clear superseded. repBaselineAfterFollowedClear reads it, and + // the operator's next edit to the representative row drops it (updateSub). + const followedRepClearRef = useRefA({}); // 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 @@ -1617,6 +1643,13 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // stays armed, since the operator changed nothing. The server still holds the old id // until the operator's next write, which carries the cleared row. if (daihyosenIdx >= 0 && subs[daihyosenIdx]?.[`${side}MemberIdOverride`]) { + const memberKey = `${side}MemberIdOverride`; + const shownPick = subs[daihyosenIdx][memberKey]; + // Recorded for the dirty baseline only where the server holds the pick the sheet shows. + // A pick the operator made and has not saved is their own edit, and clearing it still counts. + if (serverSubs[daihyosenIdx]?.[memberKey] === shownPick) { + followedRepClearRef.current = { ...followedRepClearRef.current, [side]: shownPick }; + } const followServerSub = (idx, fn) => setSubs(prev => reconcileRowsToPositions(prev, serverSubs).map((s, i) => i === idx ? fn(s) : s)); pickDaihyosenRepresentative({ idx: daihyosenIdx, side, updateSub: followServerSub }, null); } @@ -2141,6 +2174,9 @@ 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); + // What the sheet's dirty checks (isDirty, daihyosenTouched) compare against: the server board, + // with a representative pick that a side-change clear emptied read as cleared too. + const dirtyBaseline = repBaselineAfterFollowedClear(serverSubs, daihyosenIdx, subs, followedRepClearRef.current); // 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 @@ -2171,6 +2207,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // untapped. The server re-seed never comes through here (it uses setSubs). const updateSub = (idx, fn) => { lastRowEditRef.current.set(subs[idx]._pos, serverNowMs()); + // An operator edit to the representative row ends the side-change clear's baseline there: + // from here a pick the operator puts back or clears is their own edit. + if (idx === daihyosenIdx) followedRepClearRef.current = {}; setSubsByOperator(prev => { const rows = reconcileRowsToPositions(prev, serverSubs); return rows.map((s, i) => i === idx ? fn(s) : s); @@ -3073,7 +3112,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]) || + JSON.stringify(subs[daihyosenIdx]) !== JSON.stringify(dirtyBaseline[daihyosenIdx]) || daihyosenVerdictDirty ); const daihyosenKnownLocally = hasDaihyosen && ( @@ -3531,9 +3570,12 @@ 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 against + // dirtyBaseline, which reads a side-change clear of a representative pick as no + // operator change; it is the server board itself (and so this signature) unless + // such a clear is pending. const serverSubsSig = JSON.stringify(serverSubs); - const isDirty = JSON.stringify(subs) !== serverSubsSig || daihyosenVerdictDirty + const isDirty = JSON.stringify(subs) !== (dirtyBaseline === serverSubs ? serverSubsSig : JSON.stringify(dirtyBaseline)) || daihyosenVerdictDirty // A removed withdrawal is unsaved until Save correction sends it. || removingWithdrawal; // bc-dscn: an edit the running patch would NOT carry. Under kachinuki diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 005812b63..e93dd6f28 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -743,6 +743,12 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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) @@ -1040,12 +1046,16 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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 ref, so a refusal made from an advance's async + // "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). At render time the ref and the state agree. + // 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") return startingRef.current ? startingRef.current.match : null; + if (why === "starting") { + const s = startingRef.current || pickingRef.current; + return s ? s.match : null; + } return openCorrection; }; const refusalText = (why) => { @@ -1068,6 +1078,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, setRefusedTap({ key: matchKey(m), why, against: matchKey(refusalBlocker(why)) }); if (showToast) showToast(refusalText(why), "error"); }; + // 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 ? startError.msg : null); // For pool daihyosen/tiebreaker bouts, enrich the selected match with // rep-player roster data so ScoreEditorModal renders the rep-picker dropdowns @@ -1150,11 +1163,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 @@ -1162,21 +1172,24 @@ 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), the stored refusal (the - // server's or a thrown start's message in startError) 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 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), the stored refusal (the server's or a + // thrown start's message in startError) 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). The one + // exception is a refused ADVANCE start (startMatch's advance flag, Finish + + // Start Next or the one after a decision): the refetch that follows moves Up + // next onto exactly that match, so its refusal stays while it is Up next. // A refusal for a start still out (refuseTap, "starting") is derived instead // and clears itself once that start lands. A refusal that still applies comes // straight back on the next tap. Keyed on the key VALUE: a refetch that keeps // the same Up next leaves a refusal for it where it is. const upNextKey = upNext ? matchKey(upNext) : null; useEffectSh(() => { - setStartError(null); + setStartError((prev) => (prev && prev.advance && prev.key === upNextKey ? prev : null)); }, [upNextKey]); // "Which pool is next" for the context panel: the first upcoming pool on @@ -1267,10 +1280,22 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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) => { + // 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); + }; + // `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. `advance` + // marks a start an advance (Finish + Start Next, or the one after a decision) + // asked for: its refusal survives the Up next change the refetch makes (see + // the upNextKey effect). + const startMatch = async (m, { pick = null, advance = false } = {}) => { const key = matchKey(m); - const refusalFor = (msg) => ({ key, compId: m.compId, msg }); - const inFlight = startingRef.current; + const refusalFor = (msg) => ({ key, compId: m.compId, msg, advance }); + 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 @@ -1308,9 +1333,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (showToast) showToast((e && e.message) || "Could not start the match", "error"); return false; } finally { - // Only the start that holds the ref clears it; the state follows the ref. + // Only the start that holds the ref clears it; the state follows the refs. if (startingRef.current && startingRef.current.key === key) startingRef.current = null; - if (mountedRef.current) setStartingKey(startingRef.current ? startingRef.current.key : null); + syncStartingKey(); } }; @@ -1338,12 +1363,31 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, refuseTap(m, "correcting"); return; } - const inFlight = startingRef.current; + 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) { @@ -1364,7 +1408,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. @@ -1826,7 +1870,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} @@ -2018,7 +2062,7 @@ 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") { - await startMatch(next); + await startMatch(next, { advance: true }); } } catch (_e) { /* keep panel */ } // The finish write's answer, as the editor needs it (a partial @@ -2041,7 +2085,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") { - await startMatch(next); + await startMatch(next, { advance: true }); } }} password={password} diff --git a/web-mobile/js/side_cell.jsx b/web-mobile/js/side_cell.jsx index 042695103..fc243574c 100644 --- a/web-mobile/js/side_cell.jsx +++ b/web-mobile/js/side_cell.jsx @@ -57,12 +57,15 @@ export function SideLabel({ 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. -export function WinnerTick() { +// 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 ( - Winner: + {trailing ? ", winner" : "Winner: "} ); } diff --git a/web-mobile/js/viewer_match.jsx b/web-mobile/js/viewer_match.jsx index 4d402885e..59f7421ed 100644 --- a/web-mobile/js/viewer_match.jsx +++ b/web-mobile/js/viewer_match.jsx @@ -322,7 +322,7 @@ export const VSchedItem = React.memo(({ m, tweaks, showCompetition, onClick, hig )}
- {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))}{winnerTick && aWin && } + {barredNameMark(m, "aka", teamNameMark("aka", teamAkaMark, ))}{winnerTick && aWin && } {tweaks.showDojo && m.sideA?.dojo ? {m.sideA.dojo} : null}
From 7bf0c9ef9f1b8f152c7f04975fda00000d6eef4f Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 22:00:24 +0100 Subject: [PATCH 19/38] fix: address code review round 5 on PR #463 - Representative picks are their own dated change (repPicks), ordered apart from the representative bout's row (bout:-1): a point struck on another device a moment behind no longer erases a pick, and a pick no longer erases a point. The bout's winner member id is re-derived when the two disagree; the daihyosen remove dates the picks too; the history view names a held pick; the self-run judge checks each change only when the write changes it, against that change's own stamp. - The self-run judge leaves a stale pick the merge will hold to the merge, and the winner refusal has its own code (representative_winner_invalid). - Moving a called match to another court from this console takes its call down, only once the move has landed (the host now says whether it did). - A "still being started" notice no longer comes back when the same match starts again later. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/architecture/data-model.md | 10 +- internal/engine/match_history.go | 4 + internal/engine/match_merge.go | 30 +++ internal/engine/match_merge_rep_picks_test.go | 200 ++++++++++++++++++ internal/engine/scoring.go | 3 + internal/mobileapp/handlers_daihyosen.go | 12 +- internal/mobileapp/handlers_match.go | 86 ++++++-- internal/mobileapp/self_run_daihyosen_test.go | 115 +++++++++- internal/state/match_groups.go | 81 ++++++- internal/state/match_groups_table_test.go | 83 ++++++-- internal/state/models.go | 17 ++ internal/state/testdata/match_groups.json | 4 +- specs/openapi.yaml | 39 ++-- .../js/__tests__/match_groups_table.test.jsx | 40 +++- .../render/admin_shiaijo.render.test.jsx | 33 +++ .../call_to_court_double_tap.render.test.jsx | 49 ++++- .../render/match_history_view.render.test.jsx | 2 + ...m_daihyosen_representative.render.test.jsx | 11 +- web-mobile/js/admin.jsx | 5 +- web-mobile/js/admin_shiaijo.jsx | 12 +- web-mobile/js/match_groups.jsx | 37 +++- web-mobile/js/match_history_view.jsx | 5 +- 23 files changed, 792 insertions(+), 88 deletions(-) create mode 100644 internal/engine/match_merge_rep_picks_test.go diff --git a/CLAUDE.md b/CLAUDE.md index d9a481365..082155dfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,7 +284,7 @@ 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), `repPicks` (the two representatives picked for a team match's representative bout, SideAMemberID/SideBMemberID of the -1 row: dated apart from `bout:-1`, so a point on that bout never erases a pick made on another device 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 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. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index c9c838034..f2b3b4c3d 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -385,7 +385,11 @@ a bout between two people who happen to share a display name could not be attrib all, because the name identifies neither of them, and competitors are allowed to share 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. +side. Those two ids are a change of their own, ordered apart from the bout's score and +result: a point struck on the representative bout never alters who was picked, and a pick +never alters the score, so two boards doing one each both land. 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. 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 @@ -577,8 +581,8 @@ 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, the two fighters picked for a +representative bout, 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/internal/engine/match_history.go b/internal/engine/match_history.go index a7ae6a9d9..e4d1e6742 100644 --- a/internal/engine/match_history.go +++ b/internal/engine/match_history.go @@ -200,6 +200,10 @@ func noteServerBoutChanges(result *state.MatchResult, before, stored []state.Sub result.Changed = append(result.Changed, g) } } + // The representatives are a change of their own, not part of the bout row. + if !slices.Contains(result.Changed, state.GroupRepPicks) && state.GroupDiffers(was, result, state.GroupRepPicks) { + result.Changed = append(result.Changed, state.GroupRepPicks) + } } // 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..766849360 100644 --- a/internal/engine/match_merge.go +++ b/internal/engine/match_merge.go @@ -125,6 +125,11 @@ 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 them. + if !slices.Contains(out, state.BoutGroup(state.DaihyosenSubPosition)) { + out = slices.DeleteFunc(out, func(g string) bool { return g == state.GroupRepPicks }) + } return out } @@ -299,6 +304,9 @@ 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() rep := &state.MergeReport{Stamp: stamp, Changed: changed, HoldReason: holdReason} storedStamps := state.MaterializedGroupStamps(stored.GroupStamps, stored.ModifiedAt, state.SubPositions(stored.SubResults)) stamps := state.CloneGroupStamps(storedStamps) @@ -315,6 +323,22 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic } state.CopyGroup(incoming, stored, g) } + // A pick has no bout to land on once the representative bout is gone (a + // removal's tombstone, or a row the write carried that its stamp could not + // bring back): the picks are held, in the history, not applied onto nothing. + if i := slices.Index(rep.Applied, state.GroupRepPicks); i >= 0 && (payloadPickA != "" || payloadPickB != "") && + state.DaihyosenSubIndex(incoming.SubResults) < 0 { + rep.Applied = slices.Delete(rep.Applied, i, i+1) + ghost := &state.MatchResult{SubResults: []state.SubMatchResult{{ + Position: state.DaihyosenSubPosition, SideAMemberID: payloadPickA, SideBMemberID: payloadPickB, + }}} + reportHeld(rep, state.GroupRepPicks, stored, ghost, nil) + if s, ok := storedStamps[state.GroupRepPicks]; ok { + stamps[state.GroupRepPicks] = s + } else { + delete(stamps, state.GroupRepPicks) + } + } resultApplied := inChanged[state.GroupResult] && !hold[state.GroupResult] rep.ResultChanged = resultApplied mh := mergeHold{rep: rep, stored: stored, incoming: incoming, stamps: stamps, storedStamps: storedStamps} @@ -476,6 +500,12 @@ 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, an id that names neither stored pick is + // derived again from the winning side (or left empty). + if i := state.DaihyosenSubIndex(incoming.SubResults); i >= 0 { + incoming.SubResults[i].ReconcileWinnerMemberID() + } if len(stamps) == 0 { stamps = nil } 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..129d69b36 --- /dev/null +++ b/internal/engine/match_merge_rep_picks_test.go @@ -0,0 +1,200 @@ +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 their own dated change, state.GroupRepPicks, ordered apart +// from the bout's score and result: a point never alters the picks, a pick +// never alters the point, and an older change of either 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" + repPicksName = state.GroupRepPicks +) + +// 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, repPicksName))) + got := h.load(t) + require.Len(t, got.SubResults, 1) + require.Equal(t, "carol", got.SubResults[0].SideAMemberID, "setup: the pick is stored") +} + +// 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), repPicksName))) + + 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, repPicksName))) + + err := h.write(repWrite(h, mmT1, repRow("dana", "", nil, nil), repPicksName)) + require.ErrorIs(t, err, ErrMatchSuperseded, "its one change is outranked") + assert.Equal(t, []string{repPicksName}, 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[repPicksName]) + assert.JSONEq(t, `{"sideAMemberId":"dana","sideBMemberId":""}`, string(held.Held[repPicksName]), + "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), repPicksName))) + 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, repPicksName))) + + 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, repPicksName))) + + require.NoError(t, h.write(repWrite(h, mmT2, repRow("dana", "", nil, nil), repPicksName))) + + 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), repPicksName))) + 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 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, repPicksName) + 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(repPicksName), "the removal dates the picks too") + + late := repWrite(h, mmT3, repRow("dana", "", nil, nil), repPicksName) + err := h.write(late) + require.ErrorIs(t, err, ErrMatchSuperseded) + assert.Equal(t, []string{repPicksName}, HeldGroupsOf(err)) + assert.Empty(t, h.load(t).SubResults, "nothing is resurrected") +} + +// 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", "", 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)) + + err = h.write(repWrite(h, mmT1, repRow("dana", "", nil, nil), repPicksName)) + require.ErrorIs(t, err, ErrMatchSuperseded, "older than the row it sits on") + assert.Equal(t, "carol", h.load(t).SubResults[0].SideAMemberID) + + require.NoError(t, h.write(repWrite(h, mmT3, repRow("dana", "", nil, nil), repPicksName))) + assert.Equal(t, "dana", h.load(t).SubResults[0].SideAMemberID) +} diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index a28186367..8fc9c0dc4 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -541,6 +541,9 @@ 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 + // The winner's member id is the bout's, the representatives their own + // change: keep the pair consistent (SubMatchResult.ReconcileWinnerMemberID). + in.ReconcileWinnerMemberID() } // preserveDaihyosenOutcome is the call every forward SubResults replacement diff --git a/internal/mobileapp/handlers_daihyosen.go b/internal/mobileapp/handlers_daihyosen.go index fa7d79553..94ea32e5e 100644 --- a/internal/mobileapp/handlers_daihyosen.go +++ b/internal/mobileapp/handlers_daihyosen.go @@ -267,9 +267,11 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da u.Encho = nil // What the remove changes (bc-mrgc): the representative bout (its // stamp stays as a tombstone, so an older write still carrying the - // row cannot bring it back), the verdict, the overtime, and the - // scoreline when clearing the verdict took a hantei mark out of it. - u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult, state.GroupEncho) + // row cannot bring it back), its representatives (they go with the + // row, and their stamp dates the removal so a pick made before it + // is held, not applied onto nothing), the verdict, the overtime, and + // the scoreline when clearing the verdict took a hantei mark out of it. + u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult, state.GroupEncho, state.GroupRepPicks) u.WriteDoor = engine.DoorDaihyosenDel if _, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, mid, &u); err != nil { if errors.Is(err, engine.ErrMatchSuperseded) { @@ -471,7 +473,9 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da u.Status = state.MatchStatusRunning // daihyosen bout in progress // What the add changes (bc-mrgc): the new representative bout and // the verdict, and the scoreline when clearing the verdict took a - // hantei mark out of it. + // hantei mark out of it. The new row is built with no + // representatives, so it names none: the picks are the pickers' to + // date. u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult) u.WriteDoor = engine.DoorDaihyosenAdd if _, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, mid, &u); err != nil { diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index da1ca9196..a46c2bc28 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2068,7 +2068,7 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res if row >= 0 { // bc-dhrp: the picks name the side's own team's members (the lineup's rule). // Not judged when a stored verdict stands: it replaces the row below. - return repMembersOutsideTeams(stx, compID, snap.Pairing, subs[row], stored) + return repRowRefusal(stx, compID, snap, result, subs[row], stored) } return nil } @@ -2113,14 +2113,61 @@ var errRepMemberNotInTeam = &selfRunRefusal{ // errRepWinnerNotARepresentative refuses a participant's representative bout whose // winner id names neither of the two representatives the same row names. The // client attributes the winner by those ids, so such a winner would read as no one -// while the server credits a team by name. Same code as errRepMemberNotInTeam, with -// its own sentence (the winner is not a pick that is off the team). +// while the server credits a team by name. Its own code, not +// team_member_not_in_team: the picks themselves are on their teams, it is the winner +// that names neither of them. var errRepWinnerNotARepresentative = &selfRunRefusal{ status: http.StatusBadRequest, - code: codeTeamMemberNotInTeam, + code: "representative_winner_invalid", message: "The winner chosen is neither side's representative. Pick the winner from the two representatives.", } +// repRowRefusal judges what a participant's representative bout row would +// change, change by change, as the merge will order them (bc-mrgc): the two +// representatives (state.GroupRepPicks) and the bout's own winner id (the bout +// group, state.BoutGroup(DaihyosenSubPosition)). A change the write does not +// name, or one the merge holds because its stamp is strictly older than the +// stored change's (domain.ApplyByTimestamp: an equal stamp applies), is never +// written, so it is not judged: the write is answered superseded and kept in +// the match's history, as any stale write is. Judging it would refuse a stale +// queued pick the merge was going to hold anyway. An unnamed write (an older +// client) changes every group its row carries. +// +// The winner id is judged against the representatives that will STAND after +// the merge: the row's own when its picks apply, else the stored ones. The +// merge also re-derives a winner id that names neither stored pick +// (SubMatchResult.ReconcileWinnerMemberID), which backstops the case judged +// here only for a participant's own payload. +func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result *state.MatchResult, row state.SubMatchResult, stored *state.SubMatchResult) error { + names := func(group string) bool { + return result.Changed == nil || slices.Contains(result.Changed, group) + } + applies := func(group string, storedStamp int64) bool { + return names(group) && domain.ApplyByTimestamp(result.ModifiedAt, storedStamp) + } + picksApply := applies(state.GroupRepPicks, snap.RepPicksStamp) + boutApplies := applies(state.BoutGroup(state.DaihyosenSubPosition), snap.RepStamp) + standingA, standingB := stored.SideAMemberID, stored.SideBMemberID + if picksApply { + standingA, standingB = row.SideAMemberID, row.SideBMemberID + } + // The winner is judged whenever the write introduces any of its three ids + // (the winner's, in the bout, or a pick that applies), so a winner kept from + // the stored row cannot be left naming neither representative once a side + // changes. A row wholly matching the stored one is not judged. + // WinnerIDNamesASide accepts an empty winner id, so only a non-empty one + // naming neither representative is refused. + picksChange := picksApply && (row.SideAMemberID != stored.SideAMemberID || row.SideBMemberID != stored.SideBMemberID) + if (boutApplies || picksChange) && (row.WinnerMemberID != stored.WinnerMemberID || picksChange) && + !domain.WinnerIDNamesASide(row.WinnerMemberID, standingA, standingB) { + return errRepWinnerNotARepresentative + } + if !picksApply { + return nil + } + return repMembersOutsideTeams(stx, compID, snap.Pairing, row, stored) +} + // repMembersOutsideTeams judges the member ids a representative bout row names // against the team that holds each side. Ids are bare UUIDs, so the team's own // squad is the only thing that says whose they are: a pick of another team's @@ -2131,26 +2178,14 @@ var errRepWinnerNotARepresentative = &selfRunRefusal{ // guard's rule): a side is judged only when the write names a member for it that // the stored row does not already hold, and only against a stored side that // carries a team id. A side with no team id has nothing to judge the pick -// against, so it is not refused. The winner id is judged whenever the write -// introduces any of the row's three ids (see the check below): a non-empty -// winner must then name one of the row's two representatives -// (errRepWinnerNotARepresentative). +// against, so it is not refused. The winner id is the bout's, not the picks': +// repRowRefusal judges it. // // stored is the match's representative bout and is never nil: the caller answers // a row the match does not have before it gets here (dropped or refused), so this // only judges a row the match already has. func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult) error { - storedA, storedB, storedWinner := stored.SideAMemberID, stored.SideBMemberID, stored.WinnerMemberID - // The winner is judged whenever the write introduces any of its three ids (the - // winner or a side id differs from the stored row's), so a winner kept from the - // stored row cannot be left naming neither representative once a side changes. - // A row wholly matching the stored one is not judged. WinnerIDNamesASide accepts - // an empty winner id, so only a non-empty one naming neither representative is - // refused. - introduces := row.WinnerMemberID != storedWinner || row.SideAMemberID != storedA || row.SideBMemberID != storedB - if introduces && !domain.WinnerIDNamesASide(row.WinnerMemberID, row.SideAMemberID, row.SideBMemberID) { - return errRepWinnerNotARepresentative - } + storedA, storedB := stored.SideAMemberID, stored.SideBMemberID type judged struct{ teamID, memberID string } var picks []judged add := func(teamID, memberID, storedID string) { @@ -2237,6 +2272,15 @@ type matchSnapshot struct { // verdict was last changed), which the correction-reason check reads to // tell a stale replay of a finish from a correction (bc-mrgc review). ResultStamp int64 + // RepStamp is the stored stamp of the representative bout's row (its bout:-1 + // group, read through GroupStamp, so a legacy match reads its ModifiedAt). The + // self-run judge orders a participant's bout change against it: the merge + // holds a change stamped before it, so such a change is not judged. + RepStamp int64 + // RepPicksStamp is the same for the representatives (state.GroupRepPicks), + // a change of their own: the judge orders a pick against it, not against the + // bout row's. + RepPicksStamp int64 } // repBoutOf returns a copy of the first representative-bout row in subs, nil @@ -2289,6 +2333,8 @@ func lookupMatchSnapshot(s matchStores, compID, matchID string) (matchSnapshot, RepBout: repBoutOf(poolMatches[i].SubResults), Pairing: poolMatches[i].Attribution(), ResultStamp: poolMatches[i].GroupStamp(state.GroupResult), + RepStamp: poolMatches[i].GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), + RepPicksStamp: poolMatches[i].GroupStamp(state.GroupRepPicks), }, true, loadErr } } @@ -2323,6 +2369,8 @@ func bracketMatchSnapshot(bm *state.BracketMatch) matchSnapshot { RepBout: repBoutOf(bm.SubResults), Pairing: bm.Attribution(), ResultStamp: bm.GroupStamp(state.GroupResult), + RepStamp: bm.GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), + RepPicksStamp: bm.GroupStamp(state.GroupRepPicks), } } diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index 3a90d05c9..95b5c5800 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -990,6 +990,117 @@ func TestSelfRun_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "the organiser's pick is not judged on membership: %s", w.Body.String()) } +// The merge holds a pick whose stamp is strictly older than the stored +// representatives' own stamp (state.GroupRepPicks, a change dated apart from the +// bout row), so such a pick is never written. Judging it would refuse a stale +// queued write for a pick the merge was going to hold anyway (and keep in the +// history). An EQUAL stamp applies under the merge (domain.ApplyByTimestamp), so +// an equal-stamped pick is still judged. +func TestSelfRun_AStalePickTheMergeHoldsIsNotRefused(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA, membersB := squads[repBoutTeamAID], squads[repBoutTeamBID] + require.NotEmpty(t, membersA, "team A is seeded with members") + require.NotEmpty(t, membersB, "team B is seeded with members") + + // Team A's own member is picked on side A at f.now: the picks' stamp. + valid := repBoutRow([]string{}, []string{}, "") + valid["sideAMemberId"] = membersA[0].ID + w := f.score("", state.MatchStatusRunning, "", f.now, valid) + require.Equal(t, http.StatusOK, w.Code, "a valid pick: %s", w.Body.String()) + before := *f.storedRepBout(t) + require.Equal(t, membersA[0].ID, before.SideAMemberID) + + // Team B's member on side A: another team's, which the row would be refused for. + pick := repBoutRow([]string{}, []string{}, "") + pick["sideAMemberId"] = membersB[0].ID + + w = f.score("", state.MatchStatusRunning, "", f.now-100, pick) + require.Equal(t, http.StatusOK, w.Code, "a write stamped before the stored pick is held, not refused: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), `"superseded"`, "the merge answers the held write as superseded") + assert.Equal(t, before, *f.storedRepBout(t), "the held pick is not written") + + w = f.score("", state.MatchStatusRunning, "", f.now, pick) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") + + w = f.score("", state.MatchStatusRunning, "", f.now+100, pick) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") + assert.Equal(t, before, *f.storedRepBout(t), "a refused pick writes nothing") +} + +// A pick is judged against the representatives' OWN stamp, not the bout row's +// and not the match's ModifiedAt (the newest stamp of any group). A point struck +// on the representative bout moves the bout's stamp past the picks' while the +// picks keep theirs, and a pick stamped between the two applies to the picks +// under the merge, so it is judged. The write that struck the point names only +// the bout and so is never judged for picks. +func TestSelfRun_APickIsJudgedAgainstThePicksOwnStamp(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA, membersB := squads[repBoutTeamAID], squads[repBoutTeamBID] + require.NotEmpty(t, membersA, "team A is seeded with members") + require.NotEmpty(t, membersB, "team B is seeded with members") + + valid := repBoutRow([]string{}, []string{}, "") + valid["sideAMemberId"] = membersA[0].ID + w := f.score("", state.MatchStatusRunning, "", f.now, valid) + require.Equal(t, http.StatusOK, w.Code, "a valid pick: %s", w.Body.String()) + + // A point on the representative bout from a board that never saw the pick: + // it names the bout only, so its row (with no ids, and so no pick of its own) + // is not judged for picks, and it leaves the stored pick alone. + point := scoreSheet(state.MatchStatusRunning, "", f.now+100, repBoutRow([]string{"M"}, []string{}, "")) + point["changed"] = []string{state.BoutGroup(state.DaihyosenSubPosition)} + w = f.send(http.MethodPut, repBoutMatchPath+"/score", "", point) + require.Equal(t, http.StatusOK, w.Code, "the point: %s", w.Body.String()) + stored := storedB1(t, f.store, "c1") + require.Equal(t, f.now+100, stored.GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), "the bout moved on") + require.Equal(t, f.now, stored.GroupStamp(state.GroupRepPicks), "the picks kept their stamp") + assert.Equal(t, membersA[0].ID, f.storedRepBout(t).SideAMemberID, "the point did not erase the pick") + + bad := repBoutRow([]string{}, []string{}, "") + bad["sideAMemberId"] = membersB[0].ID + w = f.score("", state.MatchStatusRunning, "", f.now+50, bad) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") +} + +// Removing the representative bout dates the representatives with the removal +// (it names state.GroupRepPicks beside the bout), so a pick a board made before +// it, on a sheet that still showed the row, is held and kept in the history +// rather than applied onto nothing. +func TestDaihyosenRemove_DatesTheRepresentativesToo(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA := squads[repBoutTeamAID] + require.NotEmpty(t, membersA) + + pick := repBoutRow([]string{}, []string{}, "") + pick["sideAMemberId"] = membersA[0].ID + w := f.score("", state.MatchStatusRunning, "", f.now+10, pick) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + w = f.send(http.MethodDelete, repBoutMatchPath+"/daihyosen", "main-pw", map[string]any{"modifiedAt": f.now + 50}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + stored := storedB1(t, f.store, "c1") + require.False(t, carriesDaihyosenRow(stored.SubResults), "the bout is gone") + assert.Equal(t, f.now+50, stored.GroupStamp(state.GroupRepPicks), "the removal dates the representatives") + + // A pick made after the removal on a sheet that still showed the row names + // the picks alone: it is newer, but the bout it sits on is gone. + sheet := scoreSheet(state.MatchStatusRunning, "", f.now+60, pick) + sheet["changed"] = []string{state.GroupRepPicks} + late := f.send(http.MethodPut, repBoutMatchPath+"/score", "main-pw", sheet) + require.Equal(t, http.StatusOK, late.Code, late.Body.String()) + assert.Contains(t, late.Body.String(), `"superseded"`, "a pick has no bout to land on: it is held") + assert.False(t, carriesDaihyosenRow(storedB1(t, f.store, "c1").SubResults), "nothing is resurrected") +} + // The winner a participant names on the representative bout must be one of the // two representatives the same row names: the client attributes the winner by // those ids, and the server credits it by name, so a winner id matching neither @@ -1010,7 +1121,7 @@ func TestSelfRun_TheRepresentativeWinnerMustBeOneOfTheRepresentatives(t *testing row["sideBMemberId"] = membersB[0].ID row["winnerMemberId"] = membersA[0].ID w := f.score("", state.MatchStatusRunning, "", f.now+100, row) - requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") + requireRefusal(t, w, http.StatusBadRequest, "representative_winner_invalid", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") assert.Empty(t, f.storedRepBout(t).WinnerMemberID, "a refused winner writes nothing") row = repBoutRow([]string{}, []string{"M"}, "TeamB") @@ -1052,7 +1163,7 @@ func TestSelfRun_AChangedRepresentativeJudgesAnInheritedWinner(t *testing.T) { // Side A's pick moves to team A's other member, and the winner keeps the stored // id x, which is no longer either representative. w := f.score("", state.MatchStatusRunning, "", f.now+100, pick(z, y, x)) - requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") + requireRefusal(t, w, http.StatusBadRequest, "representative_winner_invalid", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") stored := f.storedRepBout(t) assert.Equal(t, x, stored.SideAMemberID, "a refused write leaves the stored pick") assert.Equal(t, x, stored.WinnerMemberID, "a refused write leaves the stored winner") diff --git a/internal/state/match_groups.go b/internal/state/match_groups.go index 2cd8ee0d3..fc35f834d 100644 --- a/internal/state/match_groups.go +++ b/internal/state/match_groups.go @@ -43,12 +43,23 @@ const ( GroupFlags = "flags" // GroupRep holds the pool daihyosen/tiebreaker representative players. GroupRep = "rep" + // GroupRepPicks holds the two representatives a team match's + // representative bout is fought by: SideAMemberID and SideBMemberID of the + // row at DaihyosenSubPosition. They are a change of their own, dated and + // ordered apart from the bout row they sit on (bout:-1), which keeps the + // bout's score and result: a point struck on the bout never alters who was + // picked, and a pick never alters the score. The row's WinnerMemberID + // stays with the bout (it is part of the result), and the merge + // re-derives it whenever the two disagree (SubMatchResult. + // ReconcileWinnerMemberID). Not to be confused with GroupRep, the pool + // daihyosen/tiebreaker players. + GroupRepPicks = "repPicks" boutGroupPrefix = "bout:" ) // ScalarGroups lists the scalar groups in their fixed order. -var ScalarGroups = []string{GroupPoints, GroupResult, GroupEncho, GroupFlags, GroupRep} +var ScalarGroups = []string{GroupPoints, GroupResult, GroupEncho, GroupFlags, GroupRep, GroupRepPicks} // BoutGroup names the group of the bout row at position (1..n, or // DaihyosenSubPosition for the representative bout). @@ -115,6 +126,31 @@ func subAt(subs []SubMatchResult, position int) *SubMatchResult { return nil } +// RepPicks returns the two representatives the row at DaihyosenSubPosition +// names, empty when the match has no such row. +func (m *MatchResult) RepPicks() (sideA, sideB string) { + if row := subAt(m.SubResults, DaihyosenSubPosition); row != nil { + return row.SideAMemberID, row.SideBMemberID + } + return "", "" +} + +// SetRepPicks puts the two representatives on the row at DaihyosenSubPosition. +// It does nothing when the match has no such row: a pick has no bout to land +// on. The list is replaced, not edited in place, so a row another copy of the +// match shares is never written through. +func (m *MatchResult) SetRepPicks(sideA, sideB string) { + for i := range m.SubResults { + if m.SubResults[i].Position != DaihyosenSubPosition { + continue + } + subs := append([]SubMatchResult(nil), m.SubResults...) + subs[i].SideAMemberID, subs[i].SideBMemberID = sideA, sideB + m.SubResults = subs + return + } +} + // boutOrderKey sorts bout rows the way every writer appends them: numbered // bouts ascending, the representative bout (and any other negative) after. func boutOrderKey(position int) int { @@ -176,9 +212,20 @@ func CopyGroup(dst, src *MatchResult, group string) { dst.FlagsA, dst.FlagsB = src.FlagsA, src.FlagsB case GroupRep: dst.RepPlayerA, dst.RepPlayerB = src.RepPlayerA, src.RepPlayerB + case GroupRepPicks: + dst.SetRepPicks(src.RepPicks()) default: if pos, ok := ParseBoutGroup(group); ok { - dst.SubResults = setSubAt(dst.SubResults, pos, subAt(src.SubResults, pos)) + row := subAt(src.SubResults, pos) + // The representatives are not the bout's: a copy of the bout keeps the + // picks the destination row already holds (GroupRepPicks moves them), + // so the two copies give the same result in either order. + if dstRow := subAt(dst.SubResults, pos); pos == DaihyosenSubPosition && row != nil && dstRow != nil { + kept := *row + kept.SideAMemberID, kept.SideBMemberID = dstRow.SideAMemberID, dstRow.SideBMemberID + row = &kept + } + dst.SubResults = setSubAt(dst.SubResults, pos, row) } } } @@ -222,6 +269,11 @@ type flagsGroup struct { FlagsB int `json:"flagsB"` } +type repPicksGroup struct { + SideAMemberID string `json:"sideAMemberId"` + SideBMemberID string `json:"sideBMemberId"` +} + type repGroup struct { RepPlayerA string `json:"repPlayerA"` RepPlayerB string `json:"repPlayerB"` @@ -248,6 +300,9 @@ func groupProjection(m *MatchResult, group string) any { return flagsGroup{FlagsA: m.FlagsA, FlagsB: m.FlagsB} case GroupRep: return repGroup{RepPlayerA: m.RepPlayerA, RepPlayerB: m.RepPlayerB} + case GroupRepPicks: + a, b := m.RepPicks() + return repPicksGroup{SideAMemberID: a, SideBMemberID: b} } if pos, ok := ParseBoutGroup(group); ok { row := subAt(m.SubResults, pos) @@ -256,6 +311,10 @@ func groupProjection(m *MatchResult, group string) any { } c := CloneSubResults([]SubMatchResult{*row})[0] c.IpponsA, c.IpponsB = nonNilStrings(c.IpponsA), nonNilStrings(c.IpponsB) + if pos == DaihyosenSubPosition { + // The representatives are GroupRepPicks', not the bout's. + c.SideAMemberID, c.SideBMemberID = "", "" + } return &c } return nil @@ -295,7 +354,13 @@ func groupStampOf(stamps map[string]int64, modifiedAt int64, group string) int64 if stamps == nil { return modifiedAt } - return stamps[group] + if v, ok := stamps[group]; ok || group != GroupRepPicks { + return v + } + // A map written before the representatives had a group of their own dated + // them with the bout row they sit on: until they are stamped themselves, + // that is their date. + return stamps[BoutGroup(DaihyosenSubPosition)] } // GroupStamp is the stored stamp of group on m (see groupStampOf). @@ -314,7 +379,15 @@ func (bm *BracketMatch) GroupStamp(group string) int64 { // stamped, or every other group would start reading the new ModifiedAt. func MaterializedGroupStamps(stamps map[string]int64, modifiedAt int64, positions []int) map[string]int64 { if stamps != nil { - return CloneGroupStamps(stamps) + out := CloneGroupStamps(stamps) + // Fix the fallback groupStampOf reads, or the picks' date would follow + // the bout row's every later stamp. + if _, ok := out[GroupRepPicks]; !ok { + if v, ok := out[BoutGroup(DaihyosenSubPosition)]; ok { + out[GroupRepPicks] = v + } + } + return out } out := map[string]int64{} if modifiedAt <= 0 { diff --git a/internal/state/match_groups_table_test.go b/internal/state/match_groups_table_test.go index 9847dca01..219d01e98 100644 --- a/internal/state/match_groups_table_test.go +++ b/internal/state/match_groups_table_test.go @@ -149,16 +149,75 @@ func TestMergeReport_HeldDecisionRequiresTheDefaultWinStandsReason(t *testing.T) assert.Empty(t, (*MergeReport)(nil).HeldDecision(), "a nil report names no decision") } -// bc-dhrp: a representative pick is a change of the bout row it sits on. The row -// is copied whole when its group applies, so a pick added or cleared is a -// difference in the group, and the copy takes the row exactly as sent. -func TestBoutGroup_RepresentativeMemberIDIsAChange(t *testing.T) { - group := BoutGroup(DaihyosenSubPosition) - picked := &MatchResult{SubResults: []SubMatchResult{{Position: DaihyosenSubPosition, SideA: "TeamA", SideB: "TeamB", Decision: "daihyosen", SideBMemberID: "rep-b"}}} - cleared := &MatchResult{SubResults: []SubMatchResult{{Position: DaihyosenSubPosition, SideA: "TeamA", SideB: "TeamB", Decision: "daihyosen"}}} - assert.True(t, GroupDiffers(picked, cleared, group), "a pick cleared is a change of the row") - - dst := &MatchResult{SubResults: CloneSubResults(picked.SubResults)} - CopyGroup(dst, cleared, group) - assert.Empty(t, dst.SubResults[0].SideBMemberID, "the copy takes the row as sent, so a cleared id clears") +// The two representatives of the representative bout are a change of their own +// (GroupRepPicks), apart from the bout row they sit on (bout:-1): a pick added +// or cleared differs in the picks and not in the bout, a point differs in the +// bout and not in the picks, and each copy moves only its own fields, in +// either order. +func TestRepPicks_AreAChangeApartFromTheBout(t *testing.T) { + bout := BoutGroup(DaihyosenSubPosition) + row := func(a, b string, ippons ...string) SubMatchResult { + return SubMatchResult{Position: DaihyosenSubPosition, SideA: "TeamA", SideB: "TeamB", Decision: "daihyosen", + SideAMemberID: a, SideBMemberID: b, IpponsA: ippons} + } + match := func(r SubMatchResult) *MatchResult { return &MatchResult{SubResults: []SubMatchResult{r}} } + + picked, cleared, scored := match(row("", "rep-b")), match(row("", "")), match(row("", "", "M")) + assert.True(t, GroupDiffers(picked, cleared, GroupRepPicks), "a pick cleared is a change of the picks") + assert.False(t, GroupDiffers(picked, cleared, bout), "and not of the bout") + assert.True(t, GroupDiffers(cleared, scored, bout), "a point is a change of the bout") + assert.False(t, GroupDiffers(cleared, scored, GroupRepPicks), "and not of the picks") + assert.JSONEq(t, `{"sideAMemberId":"","sideBMemberId":"rep-b"}`, string(GroupValue(picked, GroupRepPicks))) + assert.JSONEq(t, `null`, string(GroupValue(&MatchResult{}, bout)), "a removed bout has no value") + assert.JSONEq(t, `{"sideAMemberId":"","sideBMemberId":""}`, string(GroupValue(&MatchResult{}, GroupRepPicks)), "no bout, no picks") + + // Picks from one match, the bout from another: each copy takes only its own + // fields, so the result is the same in either order. + for _, picksFirst := range []bool{true, false} { + dst, stored := match(row("carol", "", "K")), match(row("dana", "", "M")) + if picksFirst { + CopyGroup(dst, stored, GroupRepPicks) + CopyGroup(dst, stored, bout) + } else { + CopyGroup(dst, stored, bout) + CopyGroup(dst, stored, GroupRepPicks) + } + assert.Equal(t, []string{"M"}, dst.SubResults[0].IpponsA, "picksFirst=%v", picksFirst) + assert.Equal(t, "dana", dst.SubResults[0].SideAMemberID, "picksFirst=%v", picksFirst) + } + // And a bout copy alone leaves the picks where they are. + dst := match(row("carol", "", "K")) + CopyGroup(dst, match(row("dana", "", "M")), bout) + assert.Equal(t, "carol", dst.SubResults[0].SideAMemberID, "the bout's copy leaves the picks alone") + + // A copy of the bout onto a match with no such row brings the row whole, + // picks included; a copy of the picks onto no row lands nowhere. + empty := &MatchResult{} + CopyGroup(empty, picked, GroupRepPicks) + assert.Empty(t, empty.SubResults, "a pick has no bout to land on") + CopyGroup(empty, picked, bout) + require.Len(t, empty.SubResults, 1) + assert.Equal(t, "rep-b", empty.SubResults[0].SideBMemberID) + // A removal removes the row, picks and all. + CopyGroup(empty, &MatchResult{}, bout) + assert.Empty(t, empty.SubResults) +} + +// A match written before the picks had a stamp of their own dates them with +// the bout row they sit on, and that date is fixed the first time the map is +// materialized, so a later stamp of the bout does not move it. +func TestRepPicks_LegacyStampFollowsTheBoutOnceThenStandsAlone(t *testing.T) { + bout := BoutGroup(DaihyosenSubPosition) + m := &MatchResult{ModifiedAt: 50, GroupStamps: map[string]int64{bout: 40, GroupPoints: 50}} + assert.Equal(t, int64(40), m.GroupStamp(GroupRepPicks), "no entry yet: the bout row's date") + assert.Equal(t, int64(0), (&MatchResult{GroupStamps: map[string]int64{}}).GroupStamp(GroupRepPicks), "never written") + + m.StampGroups(90, bout) + assert.Equal(t, int64(90), m.GroupStamp(bout)) + assert.Equal(t, int64(40), m.GroupStamp(GroupRepPicks), "the point did not date the picks") + + legacy := &MatchResult{ModifiedAt: 30, SubResults: []SubMatchResult{{Position: DaihyosenSubPosition}}} + assert.Equal(t, int64(30), legacy.GroupStamp(GroupRepPicks), "a match with no map reads ModifiedAt") + legacy.StampGroups(60, bout) + assert.Equal(t, int64(30), legacy.GroupStamp(GroupRepPicks), "materialized with every other group") } diff --git a/internal/state/models.go b/internal/state/models.go index 7806b9568..e5fdec3b6 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1431,6 +1431,23 @@ func (s *SubMatchResult) ResolveMemberWinnerID() bool { return false } +// ReconcileWinnerMemberID keeps a representative-bout row's WinnerMemberID +// consistent with the two representatives it names: WinnerMemberID belongs to +// the bout's result and the representatives to their own change +// (GroupRepPicks), so a merge can leave a winner id naming a member who is no +// longer picked. A stored id that names neither side's representative is +// cleared and derived again from the winner's side and the side's pick +// (ResolveMemberWinnerID), so it names one of them or is empty. It reports +// whether the id changed. +func (s *SubMatchResult) ReconcileWinnerMemberID() bool { + if s.WinnerMemberID == "" || domain.WinnerIDNamesASide(s.WinnerMemberID, s.SideAMemberID, s.SideBMemberID) { + return false + } + s.WinnerMemberID = "" + s.ResolveMemberWinnerID() + return true +} + // IneligibleSidesAnnotation names which side(s) of a SCHEDULED match are // currently barred by a withdrawal recorded on a DIFFERENT match (bc-cse). // Both fields are optional and independent: either, both, or neither may be diff --git a/internal/state/testdata/match_groups.json b/internal/state/testdata/match_groups.json index efbbe7e84..8d83e309b 100644 --- a/internal/state/testdata/match_groups.json +++ b/internal/state/testdata/match_groups.json @@ -1,6 +1,6 @@ { - "_comment": "Shared Go/JS table of the groups a match write is merged by (bc-mrgc). Go half: TestMatchGroups_SharedTable (internal/state/match_groups_table_test.go) against state.ScalarGroups, BoutGroup, ParseBoutGroup and ValidGroup. JS half: web-mobile/js/__tests__/match_groups_table.test.jsx against match_groups.jsx's SCALAR_GROUPS, boutGroup, parseBoutGroup and isValidGroup. A client write names its groups by these exact strings in `changed`, and the server refuses a name it does not know, so the two lists must never drift.", - "scalarGroups": ["points", "result", "encho", "flags", "rep"], + "_comment": "Shared Go/JS table of the groups a match write is merged by (bc-mrgc). Go half: TestMatchGroups_SharedTable (internal/state/match_groups_table_test.go) against state.ScalarGroups (repPicks: the two representatives of the representative bout, a change of their own, dated apart from bout:-1), BoutGroup, ParseBoutGroup and ValidGroup. JS half: web-mobile/js/__tests__/match_groups_table.test.jsx against match_groups.jsx's SCALAR_GROUPS, boutGroup, parseBoutGroup and isValidGroup. A client write names its groups by these exact strings in `changed`, and the server refuses a name it does not know, so the two lists must never drift.", + "scalarGroups": ["points", "result", "encho", "flags", "rep", "repPicks"], "bouts": [ { "position": 1, "group": "bout:1" }, { "position": 2, "group": "bout:2" }, diff --git a/specs/openapi.yaml b/specs/openapi.yaml index e912b2d3a..4a827f428 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -51,9 +51,11 @@ info: not have (a sheet a moment behind a remove made elsewhere) is dropped from the write and the rest is written as usual, unless the write finishes the match, which may take its winner from that row and is refused with 409 `no_daihyosen`; a write listing - two is refused with 400 `duplicate_daihyosen`, and a representative pick that names a member the - side's team does not hold, or a winner that names neither representative, is refused with 400 - `team_member_not_in_team`. Nor does it remove one: a write listing + two is refused with 400 `duplicate_daihyosen`, a representative pick that names a member the + side's team does not hold is refused with 400 `team_member_not_in_team`, and a winner that names + neither representative is refused with 400 `representative_winner_invalid`. A write stamped before + the stored representative bout is not judged for its picks: the merge holds that row, and the write + is answered as superseded. Nor does it remove one: a write listing the bouts without the representative bout the match has (a sheet a moment behind an add made elsewhere) keeps the stored row, scored or not, unless it finishes the match, which is refused with 409 `daihyosen_added`. It carries only what the competitors saw: a match @@ -2482,13 +2484,17 @@ paths: The same request with an empty password is refused with `error: team_member_not_in_team`, with a `message` to show, when its representative bout (position `-1`) names a member the side's team - does not hold (a pick the stored row does not already hold), or when - its winner id names neither of the row's two representatives (judged - whenever the write introduces any of the row's three ids). The - `message` is "The representative chosen is not on this team. Pick - again from the list.", or "The winner chosen is neither side's - representative. Pick the winner from the two representatives." - Nothing is written. The organiser's picks are not checked. + does not hold (a pick the stored row does not already hold). It is + refused with `error: representative_winner_invalid`, with a `message` + to show, when its winner id names neither of the row's two + representatives (judged whenever the write introduces any of the row's + three ids). The `message` is "The representative chosen is not on this + team. Pick again from the list.", or "The winner chosen is neither + side's representative. Pick the winner from the two representatives." + A representative bout row stamped strictly before the stored one is not + judged: the merge holds that row, and the write is answered as + superseded (an equal stamp applies, so it is judged). Nothing is + written. The organiser's picks are not checked. A `completed` write whose `decision` is empty or `hikiwake` on a match whose stored result is a completed withdrawal (kiken or @@ -2526,7 +2532,7 @@ paths: description: > The reason the payload was refused, or for the self-run refusals the machine-readable code (`duplicate_daihyosen`, - `team_member_not_in_team`). + `team_member_not_in_team`, `representative_winner_invalid`). message: type: string description: The sentence the public page shows as it is, for the self-run refusals. @@ -5392,11 +5398,16 @@ components: decision, decisionBy, decisionReason, and the correctionReason and resultSource that describe it); `encho` the match-level overtime; `flags` the engi flag counts; `rep` the pool representative players; - and `bout:` one bout row (1..n, or -1 for the representative - bout). The sides, court, schedule and round are not groups. A write + `repPicks` the two representatives of a team match's representative + bout (the member ids `sideAMemberId` and `sideBMemberId` on its `-1` + row), a change of their own that a point struck on the bout never + alters and that never alters the bout's score; and `bout:` + one bout row (1..n, or -1 for the representative bout, without the + two representatives). The sides, court, schedule and round are not + groups. A write names the groups it changes in `changed`; the match records the stamp of the last applied change to each in `groupStamps`. - pattern: '^(points|result|encho|flags|rep|bout:-?[0-9]+)$' + pattern: '^(points|result|encho|flags|rep|repPicks|bout:-?[0-9]+)$' DaihyosenWriteStamp: type: object diff --git a/web-mobile/js/__tests__/match_groups_table.test.jsx b/web-mobile/js/__tests__/match_groups_table.test.jsx index b2ce06201..ef2dea43b 100644 --- a/web-mobile/js/__tests__/match_groups_table.test.jsx +++ b/web-mobile/js/__tests__/match_groups_table.test.jsx @@ -114,6 +114,7 @@ describe('groupLabel', () => { ['encho', 'overtime'], ['flags', 'flags'], ['rep', 'the representative players'], + ['repPicks', 'the fighters picked for the representative bout'], ['bout:2', 'bout 2'], ['bout:-1', 'the representative bout'], ])('%s reads "%s"', (g, words) => { @@ -153,26 +154,51 @@ describe('the serializer', () => { }); }); -// bc-dhrp: the representative bout's member ids are its pick. The server copies a -// named bout row WHOLE, so a pick cleared (the key left out) must name the group, -// or the stored id survives. Numbered rows keep the rule they always had. -describe('the member ids of a bout row (bc-dhrp)', () => { +// The two representatives of the representative bout (the member ids on the -1 row) +// are a change of their own, `repPicks`, dated apart from the bout row they sit on. +// A pick never alters the score and a point never alters the pick, so a write names +// the one it changed and not the other. Numbered rows keep the rule they always had. +describe('the representatives of the representative bout (repPicks)', () => { const bout = (position, extra = {}) => ({ position, sideA: 'Team A', sideB: 'Team B', ipponsA: [], ipponsB: [], winner: '', decision: 'daihyosen', ...extra, }); - it('a representative pick cleared (key omitted) names bout:-1', () => { + it('a representative pick cleared (key omitted) names repPicks and not bout:-1', () => { const stored = { subResults: [bout(-1, { sideBMemberId: 'm1b' })] }; const next = { subResults: [bout(-1)] }; - expect(changedGroups(next, stored)).toEqual(['bout:-1']); + expect(changedGroups(next, stored)).toEqual(['repPicks']); }); - it('a representative pick added names bout:-1', () => { + it('a representative pick added names repPicks and not bout:-1', () => { const stored = { subResults: [bout(-1)] }; const next = { subResults: [bout(-1, { sideBMemberId: 'm1b' })] }; + expect(changedGroups(next, stored)).toEqual(['repPicks']); + }); + + it('a point struck on the representative bout names bout:-1 and not repPicks, whatever ids the row carries', () => { + // A board that never saw the pick holds the row without it, and sends it so. + expect(changedGroups({ subResults: [bout(-1, { ipponsA: ['M'] })] }, { subResults: [bout(-1)] })).toEqual(['bout:-1']); + // A board that did see it holds it and sends it back. + const seen = { subResults: [bout(-1, { sideAMemberId: 'm1a' })] }; + expect(changedGroups({ subResults: [bout(-1, { ipponsA: ['M'], sideAMemberId: 'm1a' })] }, seen)).toEqual(['bout:-1']); + }); + + it('a pick and a point together name both', () => { + const stored = { subResults: [bout(-1)] }; + const next = { subResults: [bout(-1, { ipponsA: ['M'], sideBMemberId: 'm1b' })] }; + expect(changedGroups(next, stored)).toEqual(['repPicks', 'bout:-1']); + }); + + it('the winner\'s member id belongs to the bout', () => { + const stored = { subResults: [bout(-1, { sideAMemberId: 'm1a' })] }; + const next = { subResults: [bout(-1, { winner: 'Team A', winnerMemberId: 'm1a', sideAMemberId: 'm1a' })] }; expect(changedGroups(next, stored)).toEqual(['bout:-1']); }); + it('a write with no representative row names no picks', () => { + expect(changedGroups({ subResults: [bout(1, { decision: '' })] }, { subResults: [bout(1, { decision: '' })] })).toEqual([]); + }); + it('a numbered row whose id is left out is not a change, as before', () => { const stored = { subResults: [bout(1, { sideBMemberId: 'm1b', decision: '' })] }; const next = { subResults: [bout(1, { decision: '' })] }; diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index d7ac85928..c5bd0efb4 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -2184,4 +2184,37 @@ describe('the one-start guard reads the start in flight now, not the render that expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-later', 'm-run', 'm-open']); expect(utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error')).toBeNull(); }); + + // A Start refused while another start is out names that start. Once the start + // has landed, the refusal must not come back when the same match is started + // again by an advance (which, unlike a pick, does not clear the refusal). + it('c) a refusal made while a start was out does not come back when that match is started again by an advance', async () => { + const firstStart = deferred(); + const secondStart = deferred(); + const openStarts = [firstStart, secondStart]; + const onEditScore = vi.fn((_compId, matchId) => (matchId === 'm-open' + ? openStarts.shift().promise + : Promise.resolve({ applied: true }))); + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + // Finishing the running bout advances to m-open, and that start is held. + let firstAdvance; + await act(async () => { firstAdvance = probe.props.onSubmitAndNext({ status: 'completed', winner }); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-run', 'm-open']); + // A Start of the queue row below is refused while m-open's start is out. + const itoRow = () => [...utils.container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes('Ito')); + await act(async () => { queueStart(utils, 'Ito').click(); }); + await act(async () => {}); + expect(itoRow().querySelector('[role="alert"]')?.textContent).toBe(startWhileStartingMessage({ label: scoreRowMatchName(openM) })); + // The start lands: the refusal goes with it. + await act(async () => { firstStart.resolve({ applied: true }); await firstAdvance; }); + await act(async () => {}); + expect(itoRow().querySelector('[role="alert"]')).toBeNull(); + // m-open is started again by an advance, and that start is still out. + await act(async () => { probe.props.onSubmitAndNext({ status: 'completed', winner }); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-run', 'm-open', 'm-run', 'm-open']); + expect(itoRow().querySelector('[role="alert"]'), 'the old refusal must not name the second start').toBeNull(); + }); }); diff --git a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx index dcfbc9f34..b5d5fac97 100644 --- a/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/call_to_court_double_tap.render.test.jsx @@ -12,7 +12,7 @@ // them and these tests could never go red. import React from 'react'; -import { render, act, screen, cleanup } from '@testing-library/react'; +import { render, act, screen, cleanup, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; @@ -24,7 +24,15 @@ const STUBBED_GLOBALS = { AdminTopbar: ({ children }) =>
{children}
, Breadcrumbs: () => null, ScoreEditorModal: (props) =>
, - CourtPicker: () => , + // One button per OTHER court, calling onChange as the real picker does, so a + // test can reach the move confirm from a queue row. + CourtPicker: ({ value, courts, onChange }) => ( + + {(courts || []).filter((cc) => cc !== value).map((cc) => ( + + ))} + + ), BracketTree: () => null, Icon: ({ name }) => {name}, filterMatchesByCourt: (matches) => matches, @@ -84,14 +92,14 @@ function twoScheduledMatches() { // A new tournament object on every call, so rerender() re-derives the court's // matches from window.tournamentMatches (the console memoises on it). -function consoleElement(onEditScore) { +function consoleElement(onEditScore, onMoveCourt = vi.fn()) { return ( twoScheduledMatches(); window.filterMatchesByCourt = (matches) => matches; const onEditScore = props.onEditScore || vi.fn().mockResolvedValue({ applied: true }); - return render(consoleElement(onEditScore)); + return render(consoleElement(onEditScore, props.onMoveCourt)); } // The console's match list as the next refetch shows it: a rerender with a new @@ -374,4 +382,35 @@ describe('Call to court double tap (bc-cdbl)', () => { await act(async () => {}); expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); }); + + // Calls m2 from its queue row, then moves m2 to shiaijo B from the same row. + // The move confirm is the console's own dialog (requestMoveCourt, then + // confirmMoveCourt), so it is driven through the row's court picker. + const callThenMoveSuzuki = async (onMoveCourt) => { + const { container } = renderConsole({ onMoveCourt }); + await pointerTap(callButtons()[1]); + expect(window.API.sendAnnouncement).toHaveBeenCalledTimes(1); + const row = [...container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes('Suzuki')); + await pointerTap(within(row).getByTestId('move-to-B')); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(screen.getByRole('button', { name: 'Move to Shiaijo B' })); + await act(async () => {}); + }; + + it('moving the called match to another court from this console withdraws its call', async () => { + // The host answers true once the move has landed (admin.jsx moveMatchCourt). + const onMoveCourt = vi.fn(async () => true); + await callThenMoveSuzuki(onMoveCourt); + expect(onMoveCourt).toHaveBeenCalledWith('c1', 'm2', 'B'); + expect(window.API.deleteAnnouncement).toHaveBeenCalledTimes(1); + expect(window.API.deleteAnnouncement).toHaveBeenCalledWith('ann-1', ''); + }); + + it('a move that fails leaves the call up', async () => { + // The real host catches the failed move, toasts it and resolves false. + const onMoveCourt = vi.fn(async () => false); + await callThenMoveSuzuki(onMoveCourt); + expect(onMoveCourt).toHaveBeenCalledWith('c1', 'm2', 'B'); + expect(window.API.deleteAnnouncement).not.toHaveBeenCalled(); + }); }); diff --git a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx index 8c11895d5..547388903 100644 --- a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx +++ b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx @@ -155,6 +155,8 @@ describe('heldValueText', () => { ['result', { status: 'completed', decision: 'fusensho', decisionBy: 'shiro' }, 'Fusensho against Shiro'], ['result', { status: 'completed', winner: 'Tanaka' }, 'Tanaka won'], ['bout:-1', null, 'no bout'], + ['repPicks', { sideAMemberId: 'm1a', sideBMemberId: '' }, 'Shiro not picked, Aka picked'], + ['repPicks', null, 'not picked'], ])('%s %j reads "%s"', (group, value, words) => { expect(view.heldValueText(group, value)).toBe(words); }); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index f5235e29e..716a542e4 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -147,7 +147,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dhInput('AKA').value).toBe(''); }); - it('R2: picking a member records its id on the -1 row, keeps the team names, and names bout:-1', async () => { + it('R2: picking a member records its id on the -1 row, keeps the team names, and names repPicks', async () => { const match = makeMatch({ subResults: [DH_EMPTY] }); const { onSubmit } = await mount(match); await pickFromDh('SHIRO', 'Carol'); @@ -163,7 +163,8 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const wireDh = wire.subResults.find((s) => s.position === -1); expect(wireDh.sideBMemberId).toBe('m1b'); expect(wireDh.sideB).toBe('Team B'); - expect(wire.changed).toContain('bout:-1'); + expect(wire.changed).toContain('repPicks'); + expect(wire.changed, 'a pick is not a change of the bout').not.toContain('bout:-1'); // A pick is never a lineup write: "daihyosen" is not a lineup key. expect(window.API.putMatchLineup).not.toHaveBeenCalled(); }); @@ -190,7 +191,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dh.sideB).toBe('Team B'); }); - it('R4: clearing a stored pick omits the id and names bout:-1 on the next write', async () => { + it('R4: clearing a stored pick omits the id and names repPicks on the next write', async () => { const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b' }] }); const { onSubmit } = await mount(match); // The stored pick shows as the member's own name, never the team's. @@ -203,7 +204,9 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const dh = dhEntryOf(patch); expect('sideBMemberId' in dh).toBe(false); expect(dh.sideB).toBe('Team B'); - expect(toBackendMatchResult(patch, match).changed).toContain('bout:-1'); + const cleared = toBackendMatchResult(patch, match).changed; + expect(cleared).toContain('repPicks'); + expect(cleared).not.toContain('bout:-1'); }); it('R5: the representative roster includes a member already placed at a numbered position', async () => { diff --git a/web-mobile/js/admin.jsx b/web-mobile/js/admin.jsx index ef96ce31f..a982b3aea 100644 --- a/web-mobile/js/admin.jsx +++ b/web-mobile/js/admin.jsx @@ -298,14 +298,17 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan // real outcome. // 2. Post-mutation refresh: best-effort via refreshCompsBestEffort. // A refresh failure cannot make the mutation "look failed." + // Resolves true when the move landed and false when it failed (the failure is + // toasted here), so a caller can tell the two apart. const moveMatchCourt = async (compId, matchId, newCourt) => { try { await window.API.moveMatchCourt(compId, matchId, newCourt, password); } catch (e) { showToast(e.message, "error"); - return; + return false; } await refreshCompsBestEffort("Move"); + return true; }; // bc-kcdg: THE single chokepoint every score-editor host (the bracket diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index e93dd6f28..95ea22d97 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1286,6 +1286,10 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const syncStartingKey = () => { const s = startingRef.current || pickingRef.current; if (mountedRef.current) setStartingKey(s ? s.key : null); + // With no start out, a "starting" refusal has no start left to name, so it + // goes: otherwise the next start of that match would bring it back. Other + // refusals are not about a start in flight and stay. + if (!s && mountedRef.current) setRefusedTap((prev) => (prev && prev.why === "starting" ? null : prev)); }; // `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. `advance` @@ -1623,7 +1627,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); diff --git a/web-mobile/js/match_groups.jsx b/web-mobile/js/match_groups.jsx index 2530997ca..a1a30e1ee 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, repPicks, 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 @@ -35,9 +35,14 @@ 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): a change of their own, dated apart 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_PICKS = 'repPicks'; // 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_PICKS]; const BOUT_PREFIX = 'bout:'; @@ -81,6 +86,7 @@ export function groupLabel(group) { case GROUP_ENCHO: return 'overtime'; case GROUP_FLAGS: return 'flags'; case GROUP_REP: return 'the representative players'; + case GROUP_REP_PICKS: return 'the fighters picked for the representative bout'; default: { const pos = parseBoutGroup(group); if (pos === null) return String(group || ''); @@ -184,6 +190,9 @@ 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 them (an id left off the row is a pick cleared). + if (rowAt(next, -1)) out.push(GROUP_REP_PICKS); if (Array.isArray(next.subResults)) { for (const s of next.subResults) { const g = s ? boutGroup(num(s.position)) : null; @@ -220,17 +229,29 @@ function groupKey(wire, group, next) { has(next, 'repPlayerA') ? str(w.repPlayerA) : null, has(next, 'repPlayerB') ? str(w.repPlayerB) : null, ]); + case GROUP_REP_PICKS: { + // The server moves the two ids as one change and copies the row's + // picks exactly as sent, so an absent id reads as empty: a pick + // cleared leaves its key out, and must still be named. + const row = rowAt(w, -1) || {}; + return JSON.stringify([str(row.sideAMemberId), 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'); - // bc-dhrp: on the representative bout the member ids are the pick. A - // pick cleared leaves its key out, and the server copies a named row - // WHOLE, so an absent id must read as empty here or the clear is never - // named and the stored id survives. Numbered rows keep the rule above. - if (pos < 0) { keys.add('sideAMemberId'); keys.add('sideBMemberId'); keys.add('winnerMemberId'); } + // On the representative bout the two picks are GROUP_REP_PICKS' (the + // server copies the bout without them), so a pick never reads as a + // change of the bout. The winner's member id is the bout's: a key the + // row leaves out must read as empty, or a cleared one is never named. + // Numbered rows keep the rule above. + if (pos < 0) { + keys.delete('sideAMemberId'); + keys.delete('sideBMemberId'); + keys.add('winnerMemberId'); + } // 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..2971d17b8 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_PICKS } from './match_groups.jsx'; const { useState: useStateH, useEffect: useEffectH, useRef: useRefH } = React; @@ -112,6 +112,9 @@ 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_PICKS: + // The ids are member ids, not words: say who was picked, not which. + return v ? `Shiro ${v.sideBMemberId ? 'picked' : 'not picked'}, Aka ${v.sideAMemberId ? 'picked' : 'not picked'}` : 'not picked'; default: if (parseBoutGroup(group) === null) return ''; return v ? `${scorelineText(v)}${foulsText(v)}` : 'no bout'; From 195e5a3db6c66058d8a9067bdf206c49b4e7bbd7 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 22:33:37 +0100 Subject: [PATCH 20/38] fix: address code review round 6 on PR #463 - The self-run winner-id refusal is gone: the merge already keeps the representative bout's winner id naming a standing pick, and the refusal rejected a participant's winning point whenever another device had changed a pick. The judge now only checks a pick the write changes, against the picks' own stamp. - Restoring a stored hantei verdict restores its winner id with it. - Adding a representative bout dates the picks, so a pick made on the previous representative bout is held rather than landed on the new one. - A pick change on a decided representative bout names repPicks only, so it no longer re-sends the bout's score. - A typed representative still being named when the sheet closes creates no team member. - The side-change clear writes the sheet directly. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- internal/engine/scoring.go | 11 +- internal/engine/sub_hantei_preserve_test.go | 42 ++++++ internal/mobileapp/handlers_daihyosen.go | 10 +- internal/mobileapp/handlers_match.go | 84 ++++-------- internal/mobileapp/self_run_daihyosen_test.go | 120 ++++++++---------- specs/openapi.yaml | 23 ++-- .../js/__tests__/match_groups_table.test.jsx | 14 +- ...m_daihyosen_representative.render.test.jsx | 29 +++++ web-mobile/js/admin_scoring_team.jsx | 7 +- web-mobile/js/match_groups.jsx | 10 +- 11 files changed, 196 insertions(+), 156 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 082155dfd..5b58475c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,7 +286,7 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **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), `repPicks` (the two representatives picked for a team match's representative bout, SideAMemberID/SideBMemberID of the -1 row: dated apart from `bout:-1`, so a point on that bout never erases a pick made on another device 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`+`repPicks` and remove `bout:-1`+`result`+`encho`+`repPicks` (both date the 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`). `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. diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 8fc9c0dc4..e0c1f9608 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -541,8 +541,15 @@ 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 - // The winner's member id is the bout's, the representatives their own - // change: keep the pair consistent (SubMatchResult.ReconcileWinnerMemberID). + // The winner's member id travels with the winner it names: the incoming + // row is verdict-silent, so its own id is either empty (the stored one + // would be lost) or stale (it can name the OTHER side's pick, which + // ReconcileWinnerMemberID accepts, and id-first attribution would then + // credit the wrong side). + in.WinnerMemberID = prior.WinnerMemberID + // The representatives are their own change, so the stored id may now name + // a member no longer picked: keep the pair consistent + // (SubMatchResult.ReconcileWinnerMemberID). in.ReconcileWinnerMemberID() } diff --git a/internal/engine/sub_hantei_preserve_test.go b/internal/engine/sub_hantei_preserve_test.go index 56d319413..cdc3b3b1c 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 restored WITH the winner it belongs to. The +// restored hantei winner is the stored one, so an incoming winner id must not +// outlive it: an empty one would lose the stored id, and a stale one naming the +// other side's pick survives ReconcileWinnerMemberID (it does name a pick) and +// then credits the wrong side, id-first. +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/mobileapp/handlers_daihyosen.go b/internal/mobileapp/handlers_daihyosen.go index 94ea32e5e..063dbc1f1 100644 --- a/internal/mobileapp/handlers_daihyosen.go +++ b/internal/mobileapp/handlers_daihyosen.go @@ -474,9 +474,13 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da // What the add changes (bc-mrgc): the new representative bout and // the verdict, and the scoreline when clearing the verdict took a // hantei mark out of it. The new row is built with no - // representatives, so it names none: the picks are the pickers' to - // date. - u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult) + // representatives, and the add names state.GroupRepPicks all the + // same, as the remove does: it dates the (empty) picks of THIS + // bout, so a pick made on the previous representative bout, stamped + // between the remove and the add, is older than the add and is held + // rather than landing on the new bout through the remove's + // tombstone stamp. + u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult, state.GroupRepPicks) u.WriteDoor = engine.DoorDaihyosenAdd if _, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, mid, &u); err != nil { if errors.Is(err, engine.ErrMatchSuperseded) { diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index a46c2bc28..1380c15e3 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2110,59 +2110,24 @@ var errRepMemberNotInTeam = &selfRunRefusal{ message: "The representative chosen is not on this team. Pick again from the list.", } -// errRepWinnerNotARepresentative refuses a participant's representative bout whose -// winner id names neither of the two representatives the same row names. The -// client attributes the winner by those ids, so such a winner would read as no one -// while the server credits a team by name. Its own code, not -// team_member_not_in_team: the picks themselves are on their teams, it is the winner -// that names neither of them. -var errRepWinnerNotARepresentative = &selfRunRefusal{ - status: http.StatusBadRequest, - code: "representative_winner_invalid", - message: "The winner chosen is neither side's representative. Pick the winner from the two representatives.", -} - // repRowRefusal judges what a participant's representative bout row would -// change, change by change, as the merge will order them (bc-mrgc): the two -// representatives (state.GroupRepPicks) and the bout's own winner id (the bout -// group, state.BoutGroup(DaihyosenSubPosition)). A change the write does not -// name, or one the merge holds because its stamp is strictly older than the -// stored change's (domain.ApplyByTimestamp: an equal stamp applies), is never -// written, so it is not judged: the write is answered superseded and kept in -// the match's history, as any stale write is. Judging it would refuse a stale -// queued pick the merge was going to hold anyway. An unnamed write (an older -// client) changes every group its row carries. +// change, as the merge will order it (bc-mrgc): the two representatives +// (state.GroupRepPicks). A change the write does not name, or one the merge +// holds because its stamp is strictly older than the stored change's +// (domain.ApplyByTimestamp: an equal stamp applies), is never written, so it +// is not judged: the write is answered superseded and kept in the match's +// history, as any stale write is. Judging it would refuse a stale queued pick +// the merge was going to hold anyway. An unnamed write (an older client) +// changes every group its row carries. // -// The winner id is judged against the representatives that will STAND after -// the merge: the row's own when its picks apply, else the stored ones. The -// merge also re-derives a winner id that names neither stored pick -// (SubMatchResult.ReconcileWinnerMemberID), which backstops the case judged -// here only for a participant's own payload. +// The bout's winner id is NOT judged: it belongs to the bout group and the +// merge keeps it consistent with the representatives that stand +// (SubMatchResult.ReconcileWinnerMemberID), so a winning point carrying the id +// of a pick another device has since replaced is applied and its winner id +// re-derived, rather than refused and lost. func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result *state.MatchResult, row state.SubMatchResult, stored *state.SubMatchResult) error { - names := func(group string) bool { - return result.Changed == nil || slices.Contains(result.Changed, group) - } - applies := func(group string, storedStamp int64) bool { - return names(group) && domain.ApplyByTimestamp(result.ModifiedAt, storedStamp) - } - picksApply := applies(state.GroupRepPicks, snap.RepPicksStamp) - boutApplies := applies(state.BoutGroup(state.DaihyosenSubPosition), snap.RepStamp) - standingA, standingB := stored.SideAMemberID, stored.SideBMemberID - if picksApply { - standingA, standingB = row.SideAMemberID, row.SideBMemberID - } - // The winner is judged whenever the write introduces any of its three ids - // (the winner's, in the bout, or a pick that applies), so a winner kept from - // the stored row cannot be left naming neither representative once a side - // changes. A row wholly matching the stored one is not judged. - // WinnerIDNamesASide accepts an empty winner id, so only a non-empty one - // naming neither representative is refused. - picksChange := picksApply && (row.SideAMemberID != stored.SideAMemberID || row.SideBMemberID != stored.SideBMemberID) - if (boutApplies || picksChange) && (row.WinnerMemberID != stored.WinnerMemberID || picksChange) && - !domain.WinnerIDNamesASide(row.WinnerMemberID, standingA, standingB) { - return errRepWinnerNotARepresentative - } - if !picksApply { + named := result.Changed == nil || slices.Contains(result.Changed, state.GroupRepPicks) + if !named || !domain.ApplyByTimestamp(result.ModifiedAt, snap.RepPicksStamp) { return nil } return repMembersOutsideTeams(stx, compID, snap.Pairing, row, stored) @@ -2178,8 +2143,8 @@ func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result // guard's rule): a side is judged only when the write names a member for it that // the stored row does not already hold, and only against a stored side that // carries a team id. A side with no team id has nothing to judge the pick -// against, so it is not refused. The winner id is the bout's, not the picks': -// repRowRefusal judges it. +// against, so it is not refused. The winner id is the bout's, not the picks', +// and is not judged at all (repRowRefusal). // // stored is the match's representative bout and is never nil: the caller answers // a row the match does not have before it gets here (dropped or refused), so this @@ -2272,14 +2237,11 @@ type matchSnapshot struct { // verdict was last changed), which the correction-reason check reads to // tell a stale replay of a finish from a correction (bc-mrgc review). ResultStamp int64 - // RepStamp is the stored stamp of the representative bout's row (its bout:-1 - // group, read through GroupStamp, so a legacy match reads its ModifiedAt). The - // self-run judge orders a participant's bout change against it: the merge - // holds a change stamped before it, so such a change is not judged. - RepStamp int64 - // RepPicksStamp is the same for the representatives (state.GroupRepPicks), - // a change of their own: the judge orders a pick against it, not against the - // bout row's. + // RepPicksStamp is the stored stamp of the representatives + // (state.GroupRepPicks, read through GroupStamp, so a legacy match reads its + // ModifiedAt), a change of their own. The self-run judge orders a pick + // against it: the merge holds a pick stamped before it, so such a pick is + // not judged. RepPicksStamp int64 } @@ -2333,7 +2295,6 @@ func lookupMatchSnapshot(s matchStores, compID, matchID string) (matchSnapshot, RepBout: repBoutOf(poolMatches[i].SubResults), Pairing: poolMatches[i].Attribution(), ResultStamp: poolMatches[i].GroupStamp(state.GroupResult), - RepStamp: poolMatches[i].GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), RepPicksStamp: poolMatches[i].GroupStamp(state.GroupRepPicks), }, true, loadErr } @@ -2369,7 +2330,6 @@ func bracketMatchSnapshot(bm *state.BracketMatch) matchSnapshot { RepBout: repBoutOf(bm.SubResults), Pairing: bm.Attribution(), ResultStamp: bm.GroupStamp(state.GroupResult), - RepStamp: bm.GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), RepPicksStamp: bm.GroupStamp(state.GroupRepPicks), } } diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index 95b5c5800..0f37ce24a 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -1101,85 +1101,75 @@ func TestDaihyosenRemove_DatesTheRepresentativesToo(t *testing.T) { assert.False(t, carriesDaihyosenRow(storedB1(t, f.store, "c1").SubResults), "nothing is resurrected") } -// The winner a participant names on the representative bout must be one of the -// two representatives the same row names: the client attributes the winner by -// those ids, and the server credits it by name, so a winner id matching neither -// side's pick would read as no winner on one side and a team's win on the other. -// A winner equal to a side's representative is accepted. -func TestSelfRun_TheRepresentativeWinnerMustBeOneOfTheRepresentatives(t *testing.T) { +// Adding the representative bout dates the representatives with the add, as the +// remove does. Without it the new row inherits the stamp of the earlier removal's +// tombstone, and a pick made on the PREVIOUS representative bout, stamped between +// the remove and the add, lands on the new bout. +func TestDaihyosenAdd_DatesTheRepresentativesToo(t *testing.T) { f := newRepBoutFixture(t, true) f.addRepBout(t) squads, err := f.store.LoadSquads("c1") require.NoError(t, err) - membersA, membersB := squads[repBoutTeamAID], squads[repBoutTeamBID] - require.NotEmpty(t, membersA, "team A is seeded with members") - require.NotEmpty(t, membersB, "team B is seeded with members") + membersA := squads[repBoutTeamAID] + require.NotEmpty(t, membersA) + + w := f.send(http.MethodDelete, repBoutMatchPath+"/daihyosen", "main-pw", map[string]any{"modifiedAt": f.now + 100}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + w = f.send(http.MethodPost, repBoutMatchPath+"/daihyosen", "main-pw", map[string]any{"modifiedAt": f.now + 200}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + added := storedB1(t, f.store, "c1") + require.Equal(t, f.now+200, added.GroupStamp(state.GroupRepPicks), "the add dates the representatives") - // Team B's representative is picked on side B and scores the point; the winner - // id names team A's member, who is neither side's representative. - row := repBoutRow([]string{}, []string{"M"}, "TeamB") - row["sideBMemberId"] = membersB[0].ID - row["winnerMemberId"] = membersA[0].ID - w := f.score("", state.MatchStatusRunning, "", f.now+100, row) - requireRefusal(t, w, http.StatusBadRequest, "representative_winner_invalid", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") - assert.Empty(t, f.storedRepBout(t).WinnerMemberID, "a refused winner writes nothing") - - row = repBoutRow([]string{}, []string{"M"}, "TeamB") - row["sideBMemberId"] = membersB[0].ID - row["winnerMemberId"] = membersB[0].ID - w = f.score("", state.MatchStatusRunning, "", f.now+200, row) - require.Equal(t, http.StatusOK, w.Code, "a winner equal to a side's representative is accepted: %s", w.Body.String()) - assert.Equal(t, membersB[0].ID, f.storedRepBout(t).WinnerMemberID) + // A pick made on the previous bout, between the remove and the add, arrives late. + pick := repBoutRow([]string{}, []string{}, "") + pick["sideAMemberId"] = membersA[0].ID + sheet := scoreSheet(state.MatchStatusRunning, "", f.now+150, pick) + sheet["changed"] = []string{state.GroupRepPicks} + late := f.send(http.MethodPut, repBoutMatchPath+"/score", "main-pw", sheet) + require.Equal(t, http.StatusOK, late.Code, late.Body.String()) + assert.Contains(t, late.Body.String(), `"superseded"`, "a pick older than the add is held") + assert.Empty(t, f.storedRepBout(t).SideAMemberID, "the old bout's pick is not on the new bout") } -// A winner the write inherits from the stored row is still judged once a side's -// representative changes: a write that keeps the stored winner id but seats a new -// member on side A would otherwise leave the winner naming neither representative, -// and attribution would credit nobody. A row whose ids and winner all match the -// stored row is not judged. -func TestSelfRun_AChangedRepresentativeJudgesAnInheritedWinner(t *testing.T) { +// The winner id a participant's write carries is not judged: the merge keeps the +// pair consistent itself (SubMatchResult.ReconcileWinnerMemberID). Another device +// changed side A's pick; the participant's winning point (bout:-1, not the picks) +// still carries the OLD pick's id. The point is the participant's and applies, and +// the stored winner id ends up naming the stored pick of the winning side, rather +// than the write being refused and the point lost. +func TestSelfRun_AWinningPointCarryingAReplacedPicksIdIsAcceptedAndReconciled(t *testing.T) { f := newRepBoutFixture(t, true) f.addRepBout(t) squads, err := f.store.LoadSquads("c1") require.NoError(t, err) - membersA, membersB := squads[repBoutTeamAID], squads[repBoutTeamBID] - require.GreaterOrEqual(t, len(membersA), 3, "team A is seeded with members") - require.NotEmpty(t, membersB, "team B is seeded with members") - x, z, y := membersA[0].ID, membersA[1].ID, membersB[0].ID - f.setB1(t, func(bm *state.BracketMatch) { - row := &bm.SubResults[state.DaihyosenSubIndex(bm.SubResults)] - row.IpponsA = []string{"M"} - row.Winner = "TeamA" - row.SideAMemberID, row.SideBMemberID, row.WinnerMemberID = x, y, x - }) - pick := func(sideA, sideB, winner string) map[string]any { - row := repBoutRow([]string{"M"}, []string{}, "TeamA") - row["sideAMemberId"] = sideA - row["sideBMemberId"] = sideB - row["winnerMemberId"] = winner - return row - } + membersA := squads[repBoutTeamAID] + require.GreaterOrEqual(t, len(membersA), 2, "team A is seeded with members") + oldPick, newPick := membersA[0].ID, membersA[1].ID - // Side A's pick moves to team A's other member, and the winner keeps the stored - // id x, which is no longer either representative. - w := f.score("", state.MatchStatusRunning, "", f.now+100, pick(z, y, x)) - requireRefusal(t, w, http.StatusBadRequest, "representative_winner_invalid", "The winner chosen is neither side's representative. Pick the winner from the two representatives.") - stored := f.storedRepBout(t) - assert.Equal(t, x, stored.SideAMemberID, "a refused write leaves the stored pick") - assert.Equal(t, x, stored.WinnerMemberID, "a refused write leaves the stored winner") + organiserPick := func(memberID string, at int64) { + t.Helper() + row := repBoutRow([]string{}, []string{}, "") + row["sideAMemberId"] = memberID + sheet := scoreSheet(state.MatchStatusRunning, "", at, row) + sheet["changed"] = []string{state.GroupRepPicks} + w := f.send(http.MethodPut, repBoutMatchPath+"/score", "main-pw", sheet) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + organiserPick(oldPick, f.now+10) + organiserPick(newPick, f.now+100) // the other device's change, T2 - // The same change names the new representative as the winner, and is accepted. - w = f.score("", state.MatchStatusRunning, "", f.now+200, pick(z, y, z)) - require.Equal(t, http.StatusOK, w.Code, "a winner that is the new representative is accepted: %s", w.Body.String()) - assert.Equal(t, z, f.storedRepBout(t).WinnerMemberID) + point := repBoutRow([]string{"M"}, []string{}, "TeamA") + point["sideAMemberId"] = oldPick + point["winnerMemberId"] = oldPick + sheet := scoreSheet(state.MatchStatusRunning, "", f.now+200, point) + sheet["changed"] = []string{state.BoutGroup(state.DaihyosenSubPosition)} + w := f.send(http.MethodPut, repBoutMatchPath+"/score", "", sheet) + require.Equal(t, http.StatusOK, w.Code, "the point is not refused for the id of the pick it replaced: %s", w.Body.String()) - // A row the stored row wholly matches is an echo, not a change: its winner is - // not judged even though it names neither representative. - f.setB1(t, func(bm *state.BracketMatch) { - bm.SubResults[state.DaihyosenSubIndex(bm.SubResults)].WinnerMemberID = membersA[2].ID - }) - w = f.score("", state.MatchStatusRunning, "", f.now+300, pick(z, y, membersA[2].ID)) - require.Equal(t, http.StatusOK, w.Code, "a wholly inherited row is not judged: %s", w.Body.String()) + stored := f.storedRepBout(t) + assert.Equal(t, []string{"M"}, stored.IpponsA, "the winning point applied") + assert.Equal(t, newPick, stored.SideAMemberID, "the other device's pick stands") + assert.Equal(t, newPick, stored.WinnerMemberID, "the winner id names the stored pick of the winning side") } // A pick the stored row already holds is inherited, not introduced by the write: diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 4a827f428..18dcb91d8 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -52,9 +52,9 @@ info: write and the rest is written as usual, unless the write finishes the match, which may take its winner from that row and is refused with 409 `no_daihyosen`; a write listing two is refused with 400 `duplicate_daihyosen`, a representative pick that names a member the - side's team does not hold is refused with 400 `team_member_not_in_team`, and a winner that names - neither representative is refused with 400 `representative_winner_invalid`. A write stamped before - the stored representative bout is not judged for its picks: the merge holds that row, and the write + side's team does not hold is refused with 400 `team_member_not_in_team`. The row's winner member id + is not judged: the merge keeps it naming one of the two stored representatives. A write stamped + before the stored representatives is not judged for its picks: the merge holds them, and the write is answered as superseded. Nor does it remove one: a write listing the bouts without the representative bout the match has (a sheet a moment behind an add made elsewhere) keeps the stored row, scored or not, unless it finishes the match, @@ -2484,15 +2484,12 @@ paths: The same request with an empty password is refused with `error: team_member_not_in_team`, with a `message` to show, when its representative bout (position `-1`) names a member the side's team - does not hold (a pick the stored row does not already hold). It is - refused with `error: representative_winner_invalid`, with a `message` - to show, when its winner id names neither of the row's two - representatives (judged whenever the write introduces any of the row's - three ids). The `message` is "The representative chosen is not on this - team. Pick again from the list.", or "The winner chosen is neither - side's representative. Pick the winner from the two representatives." - A representative bout row stamped strictly before the stored one is not - judged: the merge holds that row, and the write is answered as + does not hold (a pick the stored row does not already hold). The + `message` is "The representative chosen is not on this team. Pick + again from the list." The row's winner member id is not judged: the + merge keeps it naming one of the two stored representatives, or empty. + A write whose stamp is strictly before the stored representatives is not + judged for its picks: the merge holds them, and the write is answered as superseded (an equal stamp applies, so it is judged). Nothing is written. The organiser's picks are not checked. @@ -2532,7 +2529,7 @@ paths: description: > The reason the payload was refused, or for the self-run refusals the machine-readable code (`duplicate_daihyosen`, - `team_member_not_in_team`, `representative_winner_invalid`). + `team_member_not_in_team`). message: type: string description: The sentence the public page shows as it is, for the self-run refusals. diff --git a/web-mobile/js/__tests__/match_groups_table.test.jsx b/web-mobile/js/__tests__/match_groups_table.test.jsx index ef2dea43b..4b1876400 100644 --- a/web-mobile/js/__tests__/match_groups_table.test.jsx +++ b/web-mobile/js/__tests__/match_groups_table.test.jsx @@ -189,9 +189,17 @@ describe('the representatives of the representative bout (repPicks)', () => { expect(changedGroups(next, stored)).toEqual(['repPicks', 'bout:-1']); }); - it('the winner\'s member id belongs to the bout', () => { - const stored = { subResults: [bout(-1, { sideAMemberId: 'm1a' })] }; - const next = { subResults: [bout(-1, { winner: 'Team A', winnerMemberId: 'm1a', sideAMemberId: 'm1a' })] }; + it('a decided representative bout whose pick is swapped names repPicks and not bout:-1', () => { + // The server re-derives the winner's member id from the stored picks, so a swap that + // moves it moves no bout: the score and the winner stand. + const stored = { subResults: [bout(-1, { ipponsA: ['M'], winner: 'Team A', winnerMemberId: 'm1a', sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; + const next = { subResults: [bout(-1, { ipponsA: ['M'], winner: 'Team A', winnerMemberId: 'm2a', sideAMemberId: 'm2a', sideBMemberId: 'm1b' })] }; + expect(changedGroups(next, stored)).toEqual(['repPicks']); + }); + + it('a decided representative bout whose winning side changes names bout:-1, and not repPicks when the picks stand', () => { + const stored = { subResults: [bout(-1, { winner: 'Team A', winnerMemberId: 'm1a', sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; + const next = { subResults: [bout(-1, { winner: 'Team B', winnerMemberId: 'm1b', sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; expect(changedGroups(next, stored)).toEqual(['bout:-1']); }); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 716a542e4..9b79e0da6 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -8,6 +8,7 @@ import { render, act, fireEvent, screen, waitFor, within } from '@testing-librar import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; import { toBackendMatchResult } from '../../api_serializers.jsx'; +import { FETCH_TIMEOUT_MS } from '../../write_result.jsx'; const SQUADS = { t1: [{ id: 'm1a', name: 'Alice', index: 1 }, { id: 'm2a', name: 'Brenda', index: 2 }], @@ -478,4 +479,32 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(window.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ message: 'Discard unsaved scoring changes?' })); expect(onClose).not.toHaveBeenCalled(); }); + + it('R23: a representative typed while the members are still being read is not named once the editor has closed', async () => { + // The member list never answers while the editor is open, so the typed name waits for it. + window.API.fetchSquads.mockImplementation(() => new Promise(() => {})); + window.API.addTeamMember.mockResolvedValue({ id: 'm9', name: 'Eve', index: 3 }); + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { unmount } = await mount(match); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + // The members wait's deadline is a setTimeout: faked from the Add tap, so the wait can end + // after the editor has unmounted (a cancelled read never answers it, only the deadline does). + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + await act(async () => { fireEvent.click(add.closest('button')); }); + await act(async () => { await Promise.resolve(); }); + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + await act(async () => { unmount(); }); + await act(async () => { + vi.advanceTimersByTime(FETCH_TIMEOUT_MS); + for (let i = 0; i < 50; i += 1) await Promise.resolve(); + }); + expect(window.API.addTeamMember, 'the editor is gone, so no member is minted for nobody\'s pick').not.toHaveBeenCalled(); + expect(window.API.renameTeamMember).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 786bd85ce..69aabe642 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -1650,8 +1650,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub if (serverSubs[daihyosenIdx]?.[memberKey] === shownPick) { followedRepClearRef.current = { ...followedRepClearRef.current, [side]: shownPick }; } - const followServerSub = (idx, fn) => setSubs(prev => reconcileRowsToPositions(prev, serverSubs).map((s, i) => i === idx ? fn(s) : s)); - pickDaihyosenRepresentative({ idx: daihyosenIdx, side, updateSub: followServerSub }, null); + setSubs(prev => reconcileRowsToPositions(prev, serverSubs).map((s, i) => i === daihyosenIdx ? { ...s, [memberKey]: "" } : s)); } }; useEffectA(() => { @@ -2047,7 +2046,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub let membersUnavailable = squadUnavailable; const waiting = membersWait.current[side].pending(); if (waiting && !(await waiting)) membersUnavailable = true; - if (sideChanged() || superseded()) return; + // 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; diff --git a/web-mobile/js/match_groups.jsx b/web-mobile/js/match_groups.jsx index a1a30e1ee..e288ce058 100644 --- a/web-mobile/js/match_groups.jsx +++ b/web-mobile/js/match_groups.jsx @@ -244,13 +244,15 @@ function groupKey(wire, group, next) { keys.add('position'); // On the representative bout the two picks are GROUP_REP_PICKS' (the // server copies the bout without them), so a pick never reads as a - // change of the bout. The winner's member id is the bout's: a key the - // row leaves out must read as empty, or a cleared one is never named. - // Numbered rows keep the rule above. + // 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. Numbered rows keep + // the rule above. if (pos < 0) { keys.delete('sideAMemberId'); keys.delete('sideBMemberId'); - keys.add('winnerMemberId'); + keys.delete('winnerMemberId'); } // A row the baseline does not hold reads as an empty one, so an // untouched blank bout is not a change. From 18003455d98a62959ef3401993c52adc97d7151e Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Fri, 9 Oct 2026 23:19:44 +0100 Subject: [PATCH 21/38] fix: address code review round 7 on PR #463 - A representative bout's winner member id is always the stored pick of the side its winner names, or empty (ReconcileWinnerMemberID derives it from the winner's name), so an id left naming the losing side's pick can no longer credit the bout to the wrong side. - The representative bout leaving the sheet supersedes every pick still being typed on it, whichever device removed it: a late answer lands no pick, no autosave, and no ghost row a re-added bout would adopt. Co-Authored-By: Claude Opus 5.5 --- internal/engine/match_merge.go | 5 +- internal/engine/match_merge_rep_picks_test.go | 37 ++++++++++++ internal/engine/scoring.go | 11 ++-- internal/engine/sub_hantei_preserve_test.go | 4 +- internal/state/models.go | 27 ++++++--- internal/state/models_test.go | 58 +++++++++++++++++++ ...m_daihyosen_representative.render.test.jsx | 48 +++++++++++++++ web-mobile/js/admin_scoring_team.jsx | 12 ++++ 8 files changed, 185 insertions(+), 17 deletions(-) diff --git a/internal/engine/match_merge.go b/internal/engine/match_merge.go index 766849360..679d2f5a1 100644 --- a/internal/engine/match_merge.go +++ b/internal/engine/match_merge.go @@ -501,8 +501,9 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic rep.ResultChanged = false } // The winner's member id is the bout's, the representatives their own - // change: after both are settled, an id that names neither stored pick is - // derived again from the winning side (or left empty). + // 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() } diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index 129d69b36..fe47c0a76 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -159,6 +159,43 @@ func TestMerge_RepPick_ReDerivesTheWinnerMemberID(t *testing.T) { 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, repPicksName))) + 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, repPicksName))) + + 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") + }) + }) +} + // 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) { diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index e0c1f9608..40f623898 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -543,12 +543,13 @@ func preserveSubHantei(stored, incoming []state.SubMatchResult) { in.Winner = prior.Winner // The winner's member id travels with the winner it names: the incoming // row is verdict-silent, so its own id is either empty (the stored one - // would be lost) or stale (it can name the OTHER side's pick, which - // ReconcileWinnerMemberID accepts, and id-first attribution would then - // credit the wrong side). + // would be lost) or stale (it can name the OTHER side's pick, and + // id-first attribution would then credit the wrong side). in.WinnerMemberID = prior.WinnerMemberID - // The representatives are their own change, so the stored id may now name - // a member no longer picked: keep the pair consistent + // 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() } diff --git a/internal/engine/sub_hantei_preserve_test.go b/internal/engine/sub_hantei_preserve_test.go index cdc3b3b1c..8aa98f8b9 100644 --- a/internal/engine/sub_hantei_preserve_test.go +++ b/internal/engine/sub_hantei_preserve_test.go @@ -955,8 +955,8 @@ func TestPreserveSubHantei_ZeroZeroWithdrawalIsNotSilence(t *testing.T) { // The winner's member id is restored WITH the winner it belongs to. The // restored hantei winner is the stored one, so an incoming winner id must not // outlive it: an empty one would lose the stored id, and a stale one naming the -// other side's pick survives ReconcileWinnerMemberID (it does name a pick) and -// then credits the wrong side, id-first. +// 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 { diff --git a/internal/state/models.go b/internal/state/models.go index e5fdec3b6..e965293c8 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1434,18 +1434,29 @@ func (s *SubMatchResult) ResolveMemberWinnerID() bool { // ReconcileWinnerMemberID keeps a representative-bout row's WinnerMemberID // consistent with the two representatives it names: WinnerMemberID belongs to // the bout's result and the representatives to their own change -// (GroupRepPicks), so a merge can leave a winner id naming a member who is no -// longer picked. A stored id that names neither side's representative is -// cleared and derived again from the winner's side and the side's pick -// (ResolveMemberWinnerID), so it names one of them or is empty. It reports +// (GroupRepPicks), so a merge can leave a winner id that disagrees with them. +// The id is always cleared and derived again by ResolveMemberWinnerID, from +// the winner's NAME alone and the side's stored pick, so afterwards it is +// exactly the pick of the side the Winner name names, or empty. It is empty +// when there is no Winner, when the Winner names neither side, when the +// winning side has no pick, and when the two side names are equal (two +// same-named teams are forbidden by rule, so that row is legacy data and the +// name cannot say who won; there is no special case for it). +// +// A stored id is never consulted to decide the winning side: SubBoutWinnerSide +// and Attribution read WinnerMemberID first, so a stale id would decide its own +// verdict, and an id naming the LOSING side's pick, which a test of "is it one +// of the two picks" accepts, would credit the wrong side id-first. It reports // whether the id changed. +// +// The rule is the representative bout's only. The numbered and kachinuki rows +// are not reconciled here: their winner id is the editor's (a same-name fighter +// pair cannot be told apart by name, see ResolveMemberWinnerID). func (s *SubMatchResult) ReconcileWinnerMemberID() bool { - if s.WinnerMemberID == "" || domain.WinnerIDNamesASide(s.WinnerMemberID, s.SideAMemberID, s.SideBMemberID) { - return false - } + before := s.WinnerMemberID s.WinnerMemberID = "" s.ResolveMemberWinnerID() - return true + return s.WinnerMemberID != before } // IneligibleSidesAnnotation names which side(s) of a SCHEDULED match are diff --git a/internal/state/models_test.go b/internal/state/models_test.go index 824212a63..1660779d4 100644 --- a/internal/state/models_test.go +++ b/internal/state/models_test.go @@ -220,6 +220,64 @@ func TestSubMatchResult_ResolveMemberWinnerID(t *testing.T) { }) } +// TestSubMatchResult_ReconcileWinnerMemberID pins the representative-bout rule +// (bc-mrgc, GroupRepPicks): after a merge, WinnerMemberID is EXACTLY the stored +// pick of the side the Winner NAME names, or empty. It is derived from the name +// alone and never decided through the stored id (SubBoutWinnerSide and +// Attribution read WinnerMemberID first, so a stale id would decide its own +// verdict): an id that names the LOSING side's pick is as wrong as one naming +// nobody, and id-first attribution would credit the wrong side. +func TestSubMatchResult_ReconcileWinnerMemberID(t *testing.T) { + row := func(winner, winnerID string) SubMatchResult { + return SubMatchResult{ + Position: DaihyosenSubPosition, SideA: "Kyoto", SideB: "Osaka", + SideAMemberID: "pick-a", SideBMemberID: "pick-b", + Winner: winner, WinnerMemberID: winnerID, + } + } + cases := []struct { + name string + sub SubMatchResult + want string + wantMoved bool + }{ + {"an id naming the winning side's pick stands", row("Kyoto", "pick-a"), "pick-a", false}, + {"an id naming the LOSING side's pick is replaced by the winning side's", row("Kyoto", "pick-b"), "pick-a", true}, + {"an id naming the losing side's pick, side B winning", row("Osaka", "pick-a"), "pick-b", true}, + {"an id naming nobody is replaced by the winning side's pick", row("Osaka", "gone"), "pick-b", true}, + {"no id is derived from the winning side's pick", row("Kyoto", ""), "pick-a", true}, + {"no winner: a stored id is cleared", row("", "pick-a"), "", true}, + {"no winner and no id: nothing to do", row("", ""), "", false}, + {"a winner naming neither side: the id is cleared", row("Nagoya", "pick-a"), "", true}, + {"the winning side has no pick: the other side's id is cleared, not kept", func() SubMatchResult { + s := row("Kyoto", "pick-b") + s.SideAMemberID = "" + return s + }(), "", true}, + {"the winning side has no pick and no id: still empty", func() SubMatchResult { + s := row("Kyoto", "") + s.SideAMemberID = "" + return s + }(), "", false}, + // Two same-named teams are forbidden by rule (team names are unique, + // helper.DuplicateNamesWithKeys), so this is legacy data: the name + // cannot say which side won, and the id is left empty rather than + // guessed, with no special case here. + {"same-named sides cannot be told apart: the id is cleared", func() SubMatchResult { + s := row("Kyoto", "pick-b") + s.SideB = "Kyoto" + return s + }(), "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sub := tc.sub + assert.Equal(t, tc.wantMoved, sub.ReconcileWinnerMemberID()) + assert.Equal(t, tc.want, sub.WinnerMemberID) + }) + } +} + // TestSubMatchResult_HanteiRoundTrip pins the wire/storage contract for the // per-bout hantei flag the viewer reads (mp-8sw). The flag is TRI-STATE // (*bool): true and explicit false both serialize (a withdrawal must reach diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 9b79e0da6..14df5feee 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -507,4 +507,52 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { vi.useRealTimers(); } }); + + it('R24: a representative typed on a row that then leaves the sheet lands nothing, and a bout added again shows no pick from it', async () => { + // The member POST is still out when another device removes the representative bout. + let answerEve; + window.API.addTeamMember.mockImplementation(() => new Promise((resolve) => { answerEve = resolve; })); + const t0 = Date.now(); + const { onSubmit, rerenderWith } = await mount(makeMatch({ modifiedAt: t0, subResults: [DH_EMPTY] })); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); + // Another device removes the representative bout: the row is gone from the sheet. + await rerenderWith(makeMatch({ modifiedAt: t0 + 1000 })); + expect(dhInputs(), 'the representative row has left the sheet').toHaveLength(0); + // The POST answers late. The pick belonged to a row that is gone, so it lands nothing. + await act(async () => { answerEve({ id: 'm9', name: 'Eve', index: 3 }); }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + // Past the autosave debounce: a pick that landed (and marked the sheet dirty) would be written here. + await act(async () => { await new Promise((r) => setTimeout(r, 500)); }); + expect(onSubmit, 'a pick on a row that is gone arms no autosave').not.toHaveBeenCalled(); + // The representative bout is added again (a fresh, empty row, stamped later). + await rerenderWith(makeMatch({ modifiedAt: t0 + 2000, subResults: [DH_EMPTY] })); + expect(dhInput('SHIRO').value, 'the new bout shows no pick from the old one').toBe(''); + await clickFinishTwice(); + const dh = dhEntryOf(finishPatchOf(onSubmit)); + expect('sideBMemberId' in dh, 'the old pick is not sent on the new bout').toBe(false); + }); + + it('R25: a representative typed before the bout was removed and added again lands nothing on the new bout, even if it answers after the add', async () => { + let answerEve; + window.API.addTeamMember.mockImplementation(() => new Promise((resolve) => { answerEve = resolve; })); + const t0 = Date.now(); + const { onSubmit, rerenderWith } = await mount(makeMatch({ modifiedAt: t0, subResults: [DH_EMPTY] })); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); + await rerenderWith(makeMatch({ modifiedAt: t0 + 1000 })); + await rerenderWith(makeMatch({ modifiedAt: t0 + 2000, subResults: [DH_EMPTY] })); + // The POST answers only now, with the new representative bout already on the sheet. + await act(async () => { answerEve({ id: 'm9', name: 'Eve', index: 3 }); }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await act(async () => { await new Promise((r) => setTimeout(r, 500)); }); + expect(dhInput('SHIRO').value, 'the answer of a pick typed on the removed bout names no one on the new one').toBe(''); + expect(onSubmit).not.toHaveBeenCalled(); + }); }); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 69aabe642..172ba8041 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -1307,6 +1307,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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]); // The representative picks giveSideAnotherTeam cleared while the server still held them: // side -> the member id the clear superseded. repBaselineAfterFollowedClear reads it, and // the operator's next edit to the representative row drops it (updateSub). From cabf470e5ed77f95d886a22f4a94b16c253d18eb Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 00:36:14 +0100 Subject: [PATCH 22/38] fix: address code review round 8 on PR #463 - A correction that seats another team on a side of a knockout team match clears that side's representative pick in the same bracket write, through one owner (seatBracketSide) every side writer goes through (propagation, retraction, slot resolution). The clear is stamped above the pick it clears, so a replay of the old pick is held; a played downstream match's reopen history line names it. A side whose team did not change keeps it. - The team sheet follows the representative bout per part: a pick made here no longer stops it showing a point scored elsewhere, and a point struck here keeps the pick. The client-side clear of a re-seated side's stored pick and its dirty-baseline bookkeeping are gone (the server clears it); a local unsaved pick is still dropped. Clearing a pick adopted from another device is now named in the next write (useChangedGroups.agree). - Legacy stamp materialization no longer adds a repPicks stamp to matches without a representative bout; a dead assignment in preserveSubHantei is gone. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/data-model.md | 8 +- .../engine/bracket_reseat_rep_picks_test.go | 234 +++++++++++++ internal/engine/bracket_seat.go | 139 ++++++++ internal/engine/bracket_seat_test.go | 322 ++++++++++++++++++ internal/engine/engi.go | 3 +- internal/engine/kachinuki.go | 29 +- internal/engine/knockout.go | 20 +- internal/engine/match_merge_rep_picks_test.go | 18 + internal/engine/pool_requalify.go | 3 + internal/engine/scoring.go | 76 +++-- internal/engine/sub_hantei_preserve_test.go | 4 +- internal/state/match_groups.go | 61 +++- internal/state/match_groups_table_test.go | 28 ++ ...m_daihyosen_representative.render.test.jsx | 120 ++++++- web-mobile/js/admin_scoring_autosave.jsx | 15 +- web-mobile/js/admin_scoring_team.jsx | 152 ++++++--- 16 files changed, 1103 insertions(+), 129 deletions(-) create mode 100644 internal/engine/bracket_reseat_rep_picks_test.go create mode 100644 internal/engine/bracket_seat.go create mode 100644 internal/engine/bracket_seat_test.go diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index f2b3b4c3d..ef860c7ab 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -389,7 +389,13 @@ side. Those two ids are a change of their own, ordered apart from the bout's sco result: a point struck on the representative bout never alters who was picked, and a pick never alters the score, so two boards doing one each both land. 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. +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 given the same team +again, or only renamed, keeps its pick. 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 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..eaa51b37e --- /dev/null +++ b/internal/engine/bracket_reseat_rep_picks_test.go @@ -0,0 +1,234 @@ +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 (GroupRepPicks, +// 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.GroupRepPicks: 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 both picks, replayed: it names the +// picks alone, at the stamp they were 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.GroupRepPicks}, 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.GroupRepPicks), mmT1, "dated after the stamp the picks held") + assert.GreaterOrEqual(t, next.GroupStamp(state.GroupRepPicks), priorModifiedAt, "and never before the match") + assert.GreaterOrEqual(t, next.ModifiedAt, next.GroupStamp(state.GroupRepPicks)) +} + +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.GroupRepPicks}, 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.True(t, reopened[0].RepPickCleared, "the reopened match is told its 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.GroupRepPicks) + 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.False(t, reopened[0].RepPickCleared) + entries, err := store.LoadMatchHistory(compID, rpNextID) + require.NoError(t, err) + for _, e := range entries { + assert.NotContains(t, e.Changed, state.GroupRepPicks, "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.GroupRepPicks), "the picks keep their date") + assert.Equal(t, prior.ModifiedAt, next.ModifiedAt, "and the match is not touched") +} diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go new file mode 100644 index 000000000..0359e2b48 --- /dev/null +++ b/internal/engine/bracket_seat.go @@ -0,0 +1,139 @@ +package engine + +import ( + "log" + + "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.GroupRepPicks +// group), so a side given ANOTHER team takes its pick away. +// +// - The id is unchanged (a rename, or a correction that stores the winner +// already recorded and so re-propagates the same team): the name is written +// and nothing else, the pick stays. +// - The id changes (team X to team Y, to "" when the slot goes back to a +// placeholder, or from "" when a placeholder is resolved): name and id are +// written, and a pick held for THAT side is cleared. The other side's pick +// is never touched. The rule is id-based, with no name fallback: an id-less +// side cannot carry a pick (the picker's roster and the member mint both +// need the team id). +// - No pick on that side, or no rep bout row at all: nothing else moves, no +// stamp, no ModifiedAt, so the draw-time callers produce exactly the +// bracket they always did. +// +// The cleared pick is dated max(now, its previous stamp + 1, the match's +// ModifiedAt) through StampGroups, which never lowers: 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); 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 + } + idChanged := *idField != id + *nameField = name + if !idChanged { + return + } + *idField = id + + row := state.DaihyosenSubIndex(bm.SubResults) + if row < 0 { + return + } + pickA, pickB := bm.RepPicks() + held := pickA + if side == domain.MatchSideB { + held = pickB + } + if held == "" { + return + } + if side == domain.MatchSideA { + pickA = "" + } else { + pickB = "" + } + bm.SetRepPicks(pickA, pickB) + // 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) + bm.StampGroups(max(serverNowMs(), bm.GroupStamp(state.GroupRepPicks)+1, bm.ModifiedAt), state.GroupRepPicks) +} + +// 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 +} + +// downstreamRepPicks is the representatives held, when it was taken, by each +// match a winner feeds that holds any (the next match past any byes, and the +// 3rd-place match), by match id. +type downstreamRepPicks map[string][2]string + +// snapshotDownstreamRepPicks takes it for the matches the winner of +// bracket.Rounds[rIdx][mIdx] is propagated into. A write that propagates takes +// it BEFORE propagateBracketWinner, so forceReopenDownstreamChain can tell +// which of the matches it reopens lost a pick to the re-seat. +func snapshotDownstreamRepPicks(bracket *state.Bracket, rIdx, mIdx int) downstreamRepPicks { + d := propagatedDownstreamOf(bracket, rIdx, mIdx) + out := downstreamRepPicks{} + for _, m := range []*state.BracketMatch{d.next, d.bronze} { + if m == nil { + continue + } + if a, b := m.RepPicks(); a != "" || b != "" { + out[m.ID] = [2]string{a, b} + } + } + return out +} + +// markRepPicksCleared sets RepPickCleared on every reopened match that no +// longer holds a pick it held when its prior picks were captured (at its +// reopen, or from a snapshotDownstreamRepPicks taken before the re-seat). Call +// it once the callback's re-seats are done: the only writer of a pick inside +// these callbacks is seatBracketSide, which only clears. +func markRepPicksCleared(bracket *state.Bracket, reopened []ReopenedMatch) { + for i := range reopened { + m := bracket.MatchByID(reopened[i].ID) + if m == nil { + continue + } + a, b := m.RepPicks() + if (reopened[i].priorPickA != "" && a != reopened[i].priorPickA) || + (reopened[i].priorPickB != "" && b != reopened[i].priorPickB) { + reopened[i].RepPickCleared = true + } + } +} diff --git a/internal/engine/bracket_seat_test.go b/internal/engine/bracket_seat_test.go new file mode 100644 index 000000000..bf241deee --- /dev/null +++ b/internal/engine/bracket_seat_test.go @@ -0,0 +1,322 @@ +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.GroupRepPicks: 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.GroupRepPicks)) + assert.Equal(t, int64(500), bm.ModifiedAt) + }) + + 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) + 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.GroupRepPicks) + 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.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") + 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") + }) + + 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) + a, _ := bm.RepPicks() + assert.Empty(t, a) + }) + + 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.GroupRepPicks] = ahead + bm.ModifiedAt = ahead + seatBracketSide(bm, domain.MatchSideA, "Tora", "b") + assert.Equal(t, ahead+1, bm.GroupStamp(state.GroupRepPicks)) + }) + + 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.GroupRepPicks)) + 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.GroupRepPicks)) + 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) + }) +} + +// 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(a, b string) []state.SubMatchResult { + return []state.SubMatchResult{{ + Position: state.DaihyosenSubPosition, 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("pb", "pk")}, + }, + }, + ThirdPlaceMatch: &state.BracketMatch{ + ID: "bronze", SideA: "Ryu", SideAID: "a", SideB: "Hawk", SideBID: "d", + Status: state.MatchStatusScheduled, SubResults: row("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") + 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") +} + +// 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) + a, c := next.RepPicks() + assert.Empty(t, a) + assert.Equal(t, rpPickC, c) + assert.Greater(t, next.GroupStamp(state.GroupRepPicks), mmT1) + assert.GreaterOrEqual(t, next.GroupStamp(state.GroupRepPicks), prior.ModifiedAt) + }) + + 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.True(t, reopened[0].RepPickCleared, "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 bool + for _, e := range entries { + if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, state.GroupRepPicks) { + named = true + } + } + assert.True(t, named, "the reopen line names the picks group") + }) +} + +// 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.GroupRepPicks: 400, state.BoutGroup(state.DaihyosenSubPosition): 400}, + SubResults: []state.SubMatchResult{{ + Position: state.DaihyosenSubPosition, 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.Greater(t, m.GroupStamp(state.GroupRepPicks), int64(400)) + }) + + 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") + a, b := m.RepPicks() + assert.Equal(t, "pa", a) + assert.Equal(t, "pb", b) + assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPicks)) + }) +} + +// 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) + assert.True(t, reopened[0].RepPickCleared) + + 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 bool + for _, e := range entries { + if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, state.GroupRepPicks) { + named = true + } + } + assert.True(t, named, "the reopen line names the picks group") +} diff --git a/internal/engine/engi.go b/internal/engine/engi.go index b9e000269..ebf80b220 100644 --- a/internal/engine/engi.go +++ b/internal/engine/engi.go @@ -319,11 +319,12 @@ func (e *Engine) recordEngiMatch( return err } priorWinner, priorWinnerID := propagatedWinnerOf(b, rIdx, mIdx, bm) + priorPicks := snapshotDownstreamRepPicks(b, rIdx, mIdx) 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) + reopened = forceReopenDownstreamChain(b, rIdx, mIdx, bm.ID, priorPicks) } return nil } diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go index be6ae6989..70aadb22b 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -1148,12 +1148,16 @@ func (e *Engine) reopenUnderCourtLock(compID string, comp *state.Competition, ma var reopenedDownstream []ReopenedMatch if !h.Bronze { if fo.Force { - reopenedDownstream = forceReopenDownstreamChain(h.BracketRoot, h.RIdx, h.MIdx, matchID) + // 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. + reopenedDownstream = forceReopenDownstreamChain(h.BracketRoot, h.RIdx, h.MIdx, matchID, nil) } if derr := retractPropagatedWinner(h.BracketRoot, h.RIdx, h.MIdx); derr != nil { opErr = derr return nil } + markRepPicksCleared(h.BracketRoot, reopenedDownstream) } prior := h.Bracket.Decision reopenBracketMatchKeepingTheFight(h.Bracket, reason, targetStatus) @@ -2209,14 +2213,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 +2225,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/knockout.go b/internal/engine/knockout.go index ff9143991..a07835211 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" ) @@ -304,7 +305,12 @@ 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) { + // + // side names which of the match's two sides name/id are, or is + // domain.MatchSideNone for the Winner paint (not a side): a side is + // written through seatBracketSide, which takes away the representative + // the team that leaves the slot held. + paint := func(label string, side domain.MatchSide, name, id *string) { rf, ok := resolver[label] if !ok || (*name == rf.Name && *id == rf.ID) { return @@ -316,13 +322,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, &m.SideA, &m.SideAID) + paint(m.PlaceholderB, domain.MatchSideB, &m.SideB, &m.SideBID) // 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, &m.Winner, &m.WinnerID) } for ri := range bracket.Rounds { for mi := range bracket.Rounds[ri] { diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index fe47c0a76..f0bac3f9c 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -235,3 +235,21 @@ func TestMerge_RepPicks_LegacyStampFallsBackToTheBout(t *testing.T) { require.NoError(t, h.write(repWrite(h, mmT3, repRow("dana", "", nil, nil), repPicksName))) assert.Equal(t, "dana", h.load(t).SubResults[0].SideAMemberID) } + +// A legacy match (no stamp map) that holds no representative bout row is not +// given a repPicks stamp by an ordinary write: the materialization that runs +// before the first group is stamped adds the key 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, repPicksName) + }) +} diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go index bf35754d0..e165a2b03 100644 --- a/internal/engine/pool_requalify.go +++ b/internal/engine/pool_requalify.go @@ -311,6 +311,9 @@ func (e *Engine) applyRequalification(tx state.StoreTx, compID, reason string, p } } e.resolveSlots(b, plan.resolver) + // The reopens above captured each match's picks; the repaint may have + // given a side another team and taken its pick with it. + markRepPicksCleared(b, reopened) return nil }) if err != nil { diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 40f623898..c198a035d 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -541,11 +541,6 @@ 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 - // The winner's member id travels with the winner it names: the incoming - // row is verdict-silent, so its own id is either empty (the stored one - // would be lost) or stale (it can name the OTHER side's pick, and - // id-first attribution would then credit the wrong side). - in.WinnerMemberID = prior.WinnerMemberID // 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 @@ -647,6 +642,15 @@ 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 + // RepPickCleared is set when the same write that reopened the match gave + // one of its sides another team, which took that side's representative + // with it (seatBracketSide). Its reopen history line then names the + // repPicks group beside the verdict's. + RepPickCleared bool + // priorPickA and priorPickB are the representatives the match held when it + // was reopened (or, for a downstream match a winner is re-propagated into, + // before that write began), for markRepPicksCleared to compare. + priorPickA, priorPickB string } // bracketMatchRef is the ONE way a bracket match becomes a ReopenedMatch, so @@ -2697,6 +2701,10 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // cleared the next round for a write that stored the same winner -- // including one confirmed for an unrelated reason. priorWinner, priorWinnerID := propagatedWinnerOf(bracket, rIdx, mIdx, bm) + // And the representatives the matches it feeds hold before the + // propagation below can re-seat a side and take one away, so the + // reopen of a played one can say its pick went too. + priorPicks := snapshotDownstreamRepPicks(bracket, rIdx, mIdx) if _, err := applyBracketMatchResult(bm, result, policy); err != nil { return nil, false, err } @@ -2712,7 +2720,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st e.propagateBracketWinner(bracket, rIdx, mIdx) if force && policy == matchWriteForward && winnerActuallyChanged(priorWinner, priorWinnerID, bm) { - reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, bm.ID) + reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, bm.ID, priorPicks) } } return reopened, inherited, nil @@ -2849,11 +2857,24 @@ 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. -func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correctedID string) []ReopenedMatch { +// +// before is what snapshotDownstreamRepPicks took ahead of the propagation that +// preceded this call (the score, override and engi doors); the reopen door, +// which retracts afterwards, passes nil and judges the picks itself. Each +// reopened match whose representative went with a re-seated side comes back +// with RepPickCleared set. +func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correctedID string, before downstreamRepPicks) []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)) + // A caller that propagated before this reopen (the score, override + // and engi doors) took the picks the match held before the re-seat; + // the picks it holds now are the ones left after it. + if p, ok := before[m.ID]; ok { + ref.priorPickA, ref.priorPickB = p[0], p[1] + } + 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 @@ -2862,6 +2883,7 @@ func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correcte retractIntoUntouched(bracket, d.feed.R+1, d.feed.M/2) } } + markRepPicksCleared(bracket, reopened) return reopened } @@ -2913,6 +2935,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 markRepPicksCleared against these. + ref.priorPickA, ref.priorPickB = m.RepPicks() return ref } @@ -2949,7 +2974,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 reopened[i].RepPickCleared { + // A copy: reopenedBracketGroups is shared. + groups = append(append([]string(nil), groups...), state.GroupRepPicks) + } + e.recordDirectHistory(h, compID, reopened[i].ID, doorDownstreamReopen, serverNowMs(), groups...) } } @@ -3233,13 +3263,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 @@ -3270,13 +3297,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) } } @@ -3287,8 +3308,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) } } } @@ -3297,8 +3317,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) } } } @@ -3468,6 +3487,7 @@ 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) + priorPicks := snapshotDownstreamRepPicks(bracket, rIdx, mIdx) setBracketOverrideWinner(m, winnerName) m.IsOverridden = true m.Status = state.MatchStatusCompleted @@ -3483,7 +3503,7 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName // nothing downstream to unwind. displacedWinner is this // match's winner as it stood before setBracketOverrideWinner. if fo.Force && winnerActuallyChanged(priorWinner, priorWinnerID, m) { - reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, m.ID) + reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, m.ID, priorPicks) } return nil } diff --git a/internal/engine/sub_hantei_preserve_test.go b/internal/engine/sub_hantei_preserve_test.go index 8aa98f8b9..31e691a63 100644 --- a/internal/engine/sub_hantei_preserve_test.go +++ b/internal/engine/sub_hantei_preserve_test.go @@ -952,9 +952,9 @@ func TestPreserveSubHantei_ZeroZeroWithdrawalIsNotSilence(t *testing.T) { }) } -// The winner's member id is restored WITH the winner it belongs to. The +// 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 stored id, and a stale one naming the +// 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) { diff --git a/internal/state/match_groups.go b/internal/state/match_groups.go index fc35f834d..795700f7e 100644 --- a/internal/state/match_groups.go +++ b/internal/state/match_groups.go @@ -126,29 +126,52 @@ func subAt(subs []SubMatchResult, position int) *SubMatchResult { return nil } -// RepPicks returns the two representatives the row at DaihyosenSubPosition -// names, empty when the match has no such row. -func (m *MatchResult) RepPicks() (sideA, sideB string) { - if row := subAt(m.SubResults, DaihyosenSubPosition); row != nil { +// repPicksOf returns the two representatives the row at DaihyosenSubPosition +// of subs names, empty when there is no such row. +func repPicksOf(subs []SubMatchResult) (sideA, sideB string) { + if row := subAt(subs, DaihyosenSubPosition); row != nil { return row.SideAMemberID, row.SideBMemberID } return "", "" } -// SetRepPicks puts the two representatives on the row at DaihyosenSubPosition. -// It does nothing when the match has no such row: a pick has no bout to land -// on. The list is replaced, not edited in place, so a row another copy of the -// match shares is never written through. -func (m *MatchResult) SetRepPicks(sideA, sideB string) { - for i := range m.SubResults { - if m.SubResults[i].Position != DaihyosenSubPosition { +// withRepPicks returns a copy of subs whose row at DaihyosenSubPosition names +// the two representatives, and whether there was such a row. The list is +// copied, never edited in place, so a row another copy of the match shares is +// never written through. +func withRepPicks(subs []SubMatchResult, sideA, sideB string) ([]SubMatchResult, bool) { + for i := range subs { + if subs[i].Position != DaihyosenSubPosition { continue } - subs := append([]SubMatchResult(nil), m.SubResults...) - subs[i].SideAMemberID, subs[i].SideBMemberID = sideA, sideB - m.SubResults = subs - return + out := append([]SubMatchResult(nil), subs...) + out[i].SideAMemberID, out[i].SideBMemberID = sideA, sideB + return out, true } + return subs, false +} + +// RepPicks returns the two representatives the row at DaihyosenSubPosition +// names, empty when the match has no such row. +func (m *MatchResult) RepPicks() (sideA, sideB string) { + return repPicksOf(m.SubResults) +} + +// SetRepPicks puts the two representatives on the row at DaihyosenSubPosition. +// It does nothing when the match has no such row: a pick has no bout to land +// on. The list is replaced, not edited in place (withRepPicks). +func (m *MatchResult) SetRepPicks(sideA, sideB string) { + m.SubResults, _ = withRepPicks(m.SubResults, sideA, sideB) +} + +// RepPicks is MatchResult.RepPicks for a bracket match. +func (bm *BracketMatch) RepPicks() (sideA, sideB string) { + return repPicksOf(bm.SubResults) +} + +// SetRepPicks is MatchResult.SetRepPicks for a bracket match. +func (bm *BracketMatch) SetRepPicks(sideA, sideB string) { + bm.SubResults, _ = withRepPicks(bm.SubResults, sideA, sideB) } // boutOrderKey sorts bout rows the way every writer appends them: numbered @@ -393,7 +416,15 @@ func MaterializedGroupStamps(stamps map[string]int64, modifiedAt int64, position if modifiedAt <= 0 { return out } + hasRepBout := slices.Contains(positions, DaihyosenSubPosition) for _, g := range ScalarGroups { + // The representatives' group lives on the representative bout's row: + // a match without that row has nothing to date, so a legacy match is + // not given a key it never held (a stamp a write NAMED is another + // matter: stampGroups adds it itself). + if g == GroupRepPicks && !hasRepBout { + continue + } out[g] = modifiedAt } for _, p := range positions { diff --git a/internal/state/match_groups_table_test.go b/internal/state/match_groups_table_test.go index 219d01e98..7bc0950a3 100644 --- a/internal/state/match_groups_table_test.go +++ b/internal/state/match_groups_table_test.go @@ -221,3 +221,31 @@ func TestRepPicks_LegacyStampFollowsTheBoutOnceThenStandsAlone(t *testing.T) { legacy.StampGroups(60, bout) assert.Equal(t, int64(30), legacy.GroupStamp(GroupRepPicks), "materialized with every other group") } + +// A legacy (map-less) match is given a repPicks entry when it materializes only +// if it holds a representative bout row: the picks live on that row, so a match +// without one has nothing to date and gains no key it never held. +func TestMaterializedGroupStamps_RepPicksOnlyWithARepresentativeRow(t *testing.T) { + t.Run("a match without the row gets every other scalar group, not repPicks", func(t *testing.T) { + got := MaterializedGroupStamps(nil, 70, []int{1, 2}) + assert.NotContains(t, got, GroupRepPicks) + for _, g := range ScalarGroups { + if g != GroupRepPicks { + assert.Equal(t, int64(70), got[g], g) + } + } + assert.Equal(t, int64(70), got[BoutGroup(1)]) + assert.Equal(t, int64(70), got[BoutGroup(2)]) + }) + t.Run("a match with the row gets repPicks at its ModifiedAt", func(t *testing.T) { + got := MaterializedGroupStamps(nil, 70, []int{1, DaihyosenSubPosition}) + assert.Equal(t, int64(70), got[GroupRepPicks]) + assert.Equal(t, int64(70), got[BoutGroup(DaihyosenSubPosition)]) + }) + t.Run("a stamp a write named is kept on a match with no row", func(t *testing.T) { + m := &MatchResult{ModifiedAt: 70} + m.StampGroups(90, GroupRepPicks) + assert.Equal(t, int64(90), m.GroupStamps[GroupRepPicks], "naming the group stamps it, row or not") + assert.Equal(t, int64(90), m.GroupStamp(GroupRepPicks)) + }) +} diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 14df5feee..2420db1ad 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites import { installWindowStubs } from '../helpers/stub_globals.js'; import { toBackendMatchResult } from '../../api_serializers.jsx'; import { FETCH_TIMEOUT_MS } from '../../write_result.jsx'; +import { AUTOSAVE_DEBOUNCE_MS } from '../../admin_scoring_autosave.jsx'; const SQUADS = { t1: [{ id: 'm1a', name: 'Alice', index: 1 }, { id: 'm2a', name: 'Brenda', index: 2 }], @@ -293,8 +294,9 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); const { onSubmit, rerenderWith } = await mount(match); expect(dhInput('AKA').value).toBe('Alice'); - // A correction elsewhere seats team t3 on side A; the stored row still names Alice. - await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + // A correction elsewhere seats team t3 on side A. The server's write clears the side's + // stored pick in the same change, so the push that brings the new team has no pick. + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [DH_EMPTY] })); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(dhInput('AKA').value).toBe(''); await clickFinishTwice(); @@ -371,14 +373,20 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { }); it('R15: a side given another team clears a stored pick and sends nothing: the clear is the server following, not an operator edit', async () => { + window.confirmDialog.mockClear(); const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); - const { onSubmit, rerenderWith } = await mount(match); + const { onSubmit, onClose, rerenderWith } = await mount(match); expect(dhInput('AKA').value).toBe('Alice'); - await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + // The server's shape: the push that seats the new team has already cleared the side's pick. + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [DH_EMPTY] })); expect(dhInput('AKA').value).toBe(''); // Past the autosave debounce: a clear that marked the sheet dirty would be written here. await act(async () => { await new Promise((r) => setTimeout(r, 500)); }); expect(onSubmit).not.toHaveBeenCalled(); + // The board agrees with the server, so the sheet is not dirty: Close asks nothing. + await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); }); it('R16: a side given another team leaves an armed Finish armed', async () => { @@ -427,8 +435,9 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const match = makeMatch({ status: 'completed', subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); const { rerenderWith, onClose } = await mount(match); expect(dhInput('AKA').value).toBe('Alice'); - // A correction elsewhere seats team t3 on side A. The clear is the server following, not an edit. - await rerenderWith(makeMatch({ status: 'completed', sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + // A correction elsewhere seats team t3 on side A. The push that brings it has cleared the + // side's pick; adopting that is the server following, not an edit. + await rerenderWith(makeMatch({ status: 'completed', sideA: { id: 't3', name: 'Team C' }, subResults: [DH_EMPTY] })); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(dhInput('AKA').value).toBe(''); await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); @@ -454,7 +463,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { it('R21: a side given another team clears the stored pick without making the representative row a touched one: the next Finish leaves its scoreline unstated', async () => { const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); const { onSubmit, rerenderWith } = await mount(match); - await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [DH_EMPTY] })); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); await clickFinishTwice(); const dh = dhEntryOf(finishPatchOf(onSubmit)); @@ -555,4 +564,101 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dhInput('SHIRO').value, 'the answer of a pick typed on the removed bout names no one on the new one').toBe(''); expect(onSubmit).not.toHaveBeenCalled(); }); + + // The representative row follows the server per PART: the score and each side's pick + // are separate edits (bc-mrgc orders the picks apart from the bout row). + const dhRow = () => [...document.querySelectorAll('.team-sub-match')].pop(); + const dhIppon = (color, letter) => [...dhRow().querySelectorAll(`.team-sub-match__side--${color} button.ipt-btn`)].find((b) => b.textContent === letter); + const dhMarks = (color) => [...dhRow().querySelectorAll(`.tsm-center-pts--${color} button.editor-side__pt`)].map((b) => b.textContent.trim()); + const pastDebounce = () => act(async () => { await new Promise((r) => setTimeout(r, AUTOSAVE_DEBOUNCE_MS + 150)); }); + const lastSubmitted = (onSubmit) => { + expect(onSubmit).toHaveBeenCalled(); + const calls = onSubmit.mock.calls; + return calls[calls.length - 1][0]; + }; + + it('R26: a pick made here does not hold another device\'s point on the bout, and a point struck here does not drop the pick', async () => { + const { onSubmit, rerenderWith } = await mount(makeMatch({ subResults: [DH_EMPTY] })); + await pickFromDh('AKA', 'Alice'); + expect(dhInput('AKA').value).toBe('Alice'); + // Another device scores Shiro's point. The pick has not landed there, so the push carries none. + await rerenderWith(makeMatch({ modifiedAt: Date.now() + 60000, subResults: [{ ...DH_EMPTY, ipponsB: ['M'] }] })); + expect(dhMarks('shiro'), 'the point scored elsewhere is shown').toContain('M'); + expect(dhInput('AKA').value, 'the pick is still the operator\'s until the server holds it').toBe('Alice'); + // Strike Aka's point here: the write carries both points and the pick, and names both groups. + await act(async () => { fireEvent.click(dhIppon('aka', 'M')); }); + await pastDebounce(); + const patch = lastSubmitted(onSubmit); + const dh = dhEntryOf(patch); + expect(dh.ipponsB, 'Shiro\'s point is not written over').toEqual(['M']); + expect(dh.ipponsA).toEqual(['M']); + expect(dh.sideAMemberId).toBe('m1a'); + expect(patch.changed).toContain('bout:-1'); + expect(patch.changed).toContain('repPicks'); + }); + + it('R27: a pick adopted from another device and then cleared here is named in the next write', async () => { + // The editor MUST mount before the pick exists (R4 mounts with it, so its mount-time + // baseline already holds the pick and the clear is a change against it). + const match = makeMatch({ subResults: [DH_EMPTY] }); + const { onSubmit, rerenderWith } = await mount(match); + await rerenderWith(makeMatch({ modifiedAt: Date.now() + 60000, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] })); + expect(dhInput('AKA').value).toBe('Alice'); + const wrap = dhInput('AKA').closest('.lineup-name'); + await act(async () => { fireEvent.click(within(wrap).getByRole('button', { name: 'Clear player' })); }); + await clickFinishTwice(); + const patch = finishPatchOf(onSubmit); + expect('sideAMemberId' in dhEntryOf(patch)).toBe(false); + expect(toBackendMatchResult(patch, match).changed, 'the server keeps the other device\'s pick unless repPicks is named').toContain('repPicks'); + }); + + it('R28: a pick adopted for one side does not hide the pick kept on the other: the next write still names repPicks and carries both', async () => { + const { onSubmit, rerenderWith } = await mount(makeMatch({ subResults: [DH_EMPTY] })); + await pickFromDh('AKA', 'Alice'); + // Another device picks Carol for Shiro; Alice has not landed. + await rerenderWith(makeMatch({ modifiedAt: Date.now() + 60000, subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b' }] })); + expect(dhInput('SHIRO').value, 'the other side follows the server').toBe('Carol'); + expect(dhInput('AKA').value, 'the side the operator picked stays theirs').toBe('Alice'); + await clickFinishTwice(); + const patch = finishPatchOf(onSubmit); + const dh = dhEntryOf(patch); + expect(dh.sideAMemberId).toBe('m1a'); + expect(dh.sideBMemberId).toBe('m1b'); + expect(patch.changed).toContain('repPicks'); + }); + + it('R29: a pick made here and not yet written is dropped when its side is given another team: nothing is sent for it, and the sheet is not dirty', async () => { + window.confirmDialog.mockClear(); + const { onSubmit, onClose, rerenderWith } = await mount(makeMatch({ subResults: [DH_EMPTY] })); + await pickFromDh('AKA', 'Alice'); + expect(dhInput('AKA').value).toBe('Alice'); + // The server never received the pick, so the push that seats team t3 cannot clear it. + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [DH_EMPTY] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(dhInput('AKA').value).toBe(''); + // The pick armed an autosave that still fires; whatever it writes names no member of the old team. + await pastDebounce(); + for (const [patch] of onSubmit.mock.calls) { + expect('sideAMemberId' in (dhEntryOf(patch) || {}), 'the dropped pick is not sent').toBe(false); + expect(patch.changed || [], 'the picks did not change').not.toContain('repPicks'); + } + await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('R30: the dropped pick takes its edit stamp with it: a pick another device makes for the new team is followed even when its push is stamped before the dropped pick', async () => { + window.API.fetchSquads.mockResolvedValue({ ...SQUADS, t3: [{ id: 'm1c', name: 'Erin', index: 1 }] }); + const { rerenderWith } = await mount(makeMatch({ subResults: [DH_EMPTY] })); + await pickFromDh('AKA', 'Alice'); + const pickedAt = Date.now(); + await rerenderWith(makeMatch({ sideA: { id: 't3', name: 'Team C' }, subResults: [DH_EMPTY] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(dhInput('AKA').value).toBe(''); + // The correction that seated team t3 reached this device late: the other device's pick for t3 + // was made after it but before the pick dropped here, so its stamp is older than that pick's. + await rerenderWith(makeMatch({ modifiedAt: pickedAt - 500, sideA: { id: 't3', name: 'Team C' }, subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1c' }] })); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(dhInput('AKA').value).toBe('Erin'); + }); }); diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index 9a497be10..912a53478 100644 --- a/web-mobile/js/admin_scoring_autosave.jsx +++ b/web-mobile/js/admin_scoring_autosave.jsx @@ -56,7 +56,7 @@ 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) => { + const claim = (patch) => { if (!patch) return patch; const m = matchRef.current; const st = stateRef.current; @@ -69,6 +69,19 @@ export function useChangedGroups(match) { st.last = 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. 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 after an adopt with no build in between 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. The caller states + // which group it took, 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) => { + stateRef.current.agreed[group] = matchWire(matchRef.current); + }; + return claim; } // --------------------------------------------------------------------------- diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 172ba8041..ba8bec657 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -51,6 +51,7 @@ import { isOlderRunningCopy } from './patch.jsx'; import { useDebouncedRunningWrite, SyncStatusPill, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx'; import { MatchHistoryDisclosure } from './match_history_view.jsx'; +import { GROUP_REP_PICKS } from './match_groups.jsx'; import { serverNowMs } from './server_clock.jsx'; import { publishHeight } from './published_height.jsx'; import { SideLabel, sideWithColour } from './side_cell.jsx'; @@ -689,29 +690,53 @@ const REPRESENTATIVE_KEY = "daihyosen"; // lineup write here: "daihyosen" is not a lineup key. function pickDaihyosenRepresentative({ idx, side, updateSub }, member) { const memberIdKey = `${side}MemberIdOverride`; - updateSub(idx, prev => ({ ...prev, [memberIdKey]: (member && member.id) || "" })); + // A pick is an edit of that side's pick alone, not of the row (updateSub's repPickSide). + updateSub(idx, prev => ({ ...prev, [memberIdKey]: (member && member.id) || "" }), { repPickSide: side }); } -// repBaselineAfterFollowedClear: serverSubs as the sheet's dirty baseline, with a -// representative pick that a side-change clear emptied (giveSideAnotherTeam) read as -// empty too. `followed` maps a side to the member id that clear superseded -// (followedRepClearRef). A pick is cleared in the baseline only where the sheet shows -// it empty AND the server still holds that same id, so a write that lands, or another -// device's change, moves the server off it and the clear stops counting. A pick the -// sheet shows is never hidden here. -function repBaselineAfterFollowedClear(serverSubs, dIdx, subs, followed) { - const served = serverSubs[dIdx]; - const shown = subs[dIdx]; - if (dIdx < 0 || !served || !shown) return serverSubs; - let row = null; - for (const side of ["a", "b"]) { - const key = `${side}MemberIdOverride`; - if (!followed[side] || served[key] !== followed[side] || shown[key]) continue; - row = row || { ...served }; - row[key] = ""; - } - if (!row) return serverSubs; - return serverSubs.map((s, i) => (i === dIdx ? row : s)); +// 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); +} + +// 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, served, 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 @@ -1319,10 +1344,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub if (hadDaihyosen.current && !hasDaihyosen) { newRepPick("a"); newRepPick("b"); } hadDaihyosen.current = hasDaihyosen; }, [hasDaihyosen]); - // The representative picks giveSideAnotherTeam cleared while the server still held them: - // side -> the member id the clear superseded. repBaselineAfterFollowedClear reads it, and - // the operator's next edit to the representative row drops it (updateSub). - const followedRepClearRef = useRefA({}); // 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 @@ -1649,19 +1670,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub dropLineup(side); newRepPick(side); // The representative the side picked is a member of the team it no longer has, so the - // pick is cleared and the sheet stops sending that member's id. Only a pick the row - // holds is cleared, and it is cleared the server-following way (setSubs, not + // 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. The server still holds the old id - // until the operator's next write, which carries the cleared row. + // 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`; - const shownPick = subs[daihyosenIdx][memberKey]; - // Recorded for the dirty baseline only where the server holds the pick the sheet shows. - // A pick the operator made and has not saved is their own edit, and clearing it still counts. - if (serverSubs[daihyosenIdx]?.[memberKey] === shownPick) { - followedRepClearRef.current = { ...followedRepClearRef.current, [side]: shownPick }; - } setSubs(prev => reconcileRowsToPositions(prev, serverSubs).map((s, i) => i === daihyosenIdx ? { ...s, [memberKey]: "" } : s)); } }; @@ -2187,9 +2207,6 @@ 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); - // What the sheet's dirty checks (isDirty, daihyosenTouched) compare against: the server board, - // with a representative pick that a side-change clear emptied read as cleared too. - const dirtyBaseline = repBaselineAfterFollowedClear(serverSubs, daihyosenIdx, subs, followedRepClearRef.current); // 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 @@ -2205,6 +2222,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. @@ -2218,11 +2239,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()); - // An operator edit to the representative row ends the side-change clear's baseline there: - // from here a pick the operator puts back or clears is their own edit. - if (idx === daihyosenIdx) followedRepClearRef.current = {}; + // `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); @@ -3125,7 +3147,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(dirtyBaseline[daihyosenIdx]) || + JSON.stringify(subs[daihyosenIdx]) !== JSON.stringify(serverSubs[daihyosenIdx]) || daihyosenVerdictDirty ); const daihyosenKnownLocally = hasDaihyosen && ( @@ -3233,9 +3255,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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: that row's group is named as changed - // (match_groups.jsx) and the server copies the row whole, so the omitted - // key clears the stored id. A typed-name override resolves to no id at all + // is how a CLEARED pick is sent, by naming the picks' own group: the write + // carries that row, so statedGroups adds repPicks (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 the picks + // as their own group, so an id left off the row clears the stored one only + // when repPicks 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; @@ -3583,12 +3608,12 @@ 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. isDirty compares against - // dirtyBaseline, which reads a side-change clear of a representative pick as no - // operator change; it is the server board itself (and so this signature) unless - // such a clear is pending. + // 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) !== (dirtyBaseline === serverSubs ? serverSubsSig : JSON.stringify(dirtyBaseline)) || daihyosenVerdictDirty + const isDirty = JSON.stringify(subs) !== serverSubsSig || daihyosenVerdictDirty // A removed withdrawal is unsaved until Save correction sends it. || removingWithdrawal; // bc-dscn: an edit the running patch would NOT carry. Under kachinuki @@ -3637,11 +3662,29 @@ 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, dhServed, 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; + 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, whatever // it now equals. Striking a point and taking it back returns the @@ -3661,6 +3704,11 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub return local; }); }); + // A pick taken from the server is one this editor now agrees with, so an edit + // that puts it back to what the editor mounted with (clearing a pick another + // device made) is still a change of the picks and is named. A side kept is the + // operator's own edit and stays one; both sides kept says nothing. + if (repTakes && (repTakes.a || repTakes.b)) claimChanged.agree(GROUP_REP_PICKS); // 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 From aa0fb3b37d8fb68cb1c5b82a6a6327d596fc51f6 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 01:33:41 +0100 Subject: [PATCH 23/38] fix: polish after code review round 9 on PR #463 - Prev and Next are disabled while a typed representative is still being named, as Close and Cancel are (they did nothing then, looking enabled). - The representative bout's change no longer counts its winner member id, which the server derives after every merge and the client already leaves out: an unnamed echo that differs only in that id is an echo, so it no longer moves the bout's stamp or reads as a scoring change. - openapi: a participant's stale pick is held on its own and the rest of the write applies (heldGroups: ["repPicks"]); it is answered superseded only when everything it changes is held. - Dropped an unused parameter; corrected the history view's comment. Co-Authored-By: Claude Opus 5.5 --- internal/engine/match_merge_rep_picks_test.go | 45 +++++++++++++++++++ internal/mobileapp/handlers_match.go | 10 +++-- internal/state/match_groups.go | 18 +++++--- specs/openapi.yaml | 18 +++++--- ...m_daihyosen_representative.render.test.jsx | 25 ++++++++++- web-mobile/js/admin_scoring_team.jsx | 8 ++-- web-mobile/js/match_history_view.jsx | 2 +- 7 files changed, 103 insertions(+), 23 deletions(-) diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index f0bac3f9c..27997fb58 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -196,6 +196,51 @@ func TestMerge_RepBout_WinnerMemberIDFollowsTheWinnersName(t *testing.T) { }) } +// 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, repPicksName))) + 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) { diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 1380c15e3..35cca2f83 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2115,10 +2115,12 @@ var errRepMemberNotInTeam = &selfRunRefusal{ // (state.GroupRepPicks). A change the write does not name, or one the merge // holds because its stamp is strictly older than the stored change's // (domain.ApplyByTimestamp: an equal stamp applies), is never written, so it -// is not judged: the write is answered superseded and kept in the match's -// history, as any stale write is. Judging it would refuse a stale queued pick -// the merge was going to hold anyway. An unnamed write (an older client) -// changes every group its row carries. +// is not judged: the picks are held and kept in the match's history, as any +// stale change is. The rest of the write is ordered by its own stamps, so the +// write is applied and answered with heldGroups ["repPicks"]; it is answered +// superseded only when every group it changes is held. Judging the picks would +// refuse a stale queued pick the merge was going to hold anyway. An unnamed +// write (an older client) changes every group its row carries. // // The bout's winner id is NOT judged: it belongs to the bout group and the // merge keeps it consistent with the representatives that stand diff --git a/internal/state/match_groups.go b/internal/state/match_groups.go index 795700f7e..4c98bbd74 100644 --- a/internal/state/match_groups.go +++ b/internal/state/match_groups.go @@ -49,10 +49,11 @@ const ( // ordered apart from the bout row they sit on (bout:-1), which keeps the // bout's score and result: a point struck on the bout never alters who was // picked, and a pick never alters the score. The row's WinnerMemberID - // stays with the bout (it is part of the result), and the merge - // re-derives it whenever the two disagree (SubMatchResult. - // ReconcileWinnerMemberID). Not to be confused with GroupRep, the pool - // daihyosen/tiebreaker players. + // moves with the bout (CopyGroup copies the row), but it is derived state: + // neither group's comparison includes it, and the merge works it out again + // from the winner's name and the stored picks after every write + // (SubMatchResult.ReconcileWinnerMemberID). Not to be confused with + // GroupRep, the pool daihyosen/tiebreaker players. GroupRepPicks = "repPicks" boutGroupPrefix = "bout:" @@ -335,8 +336,13 @@ func groupProjection(m *MatchResult, group string) any { c := CloneSubResults([]SubMatchResult{*row})[0] c.IpponsA, c.IpponsB = nonNilStrings(c.IpponsA), nonNilStrings(c.IpponsB) if pos == DaihyosenSubPosition { - // The representatives are GroupRepPicks', not the bout's. - c.SideAMemberID, c.SideBMemberID = "", "" + // The representatives are GroupRepPicks', not the bout's. The row's + // WinnerMemberID is derived from the winner's name and those picks + // after every merge (SubMatchResult.ReconcileWinnerMemberID), and the + // client's bout comparison leaves it out, so it is no change of the + // bout's: a write that echoes the row with another id (or none) has + // changed nothing. + c.SideAMemberID, c.SideBMemberID, c.WinnerMemberID = "", "", "" } return &c } diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 18dcb91d8..8fa5cc48e 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -54,8 +54,10 @@ info: two is refused with 400 `duplicate_daihyosen`, a representative pick that names a member the side's team does not hold is refused with 400 `team_member_not_in_team`. The row's winner member id is not judged: the merge keeps it naming one of the two stored representatives. A write stamped - before the stored representatives is not judged for its picks: the merge holds them, and the write - is answered as superseded. Nor does it remove one: a write listing + before the stored representatives is not judged for its picks: the merge holds the picks, which + are not written and are kept in the match's history. The rest of the write is ordered by its own + stamps, so it is applied and answered with `heldGroups: ["repPicks"]`; it is answered as + superseded only when every group it changes is held. Nor does it remove one: a write listing the bouts without the representative bout the match has (a sheet a moment behind an add made elsewhere) keeps the stored row, scored or not, unless it finishes the match, which is refused with 409 `daihyosen_added`. It carries only what the competitors saw: a match @@ -2489,9 +2491,12 @@ paths: again from the list." The row's winner member id is not judged: the merge keeps it naming one of the two stored representatives, or empty. A write whose stamp is strictly before the stored representatives is not - judged for its picks: the merge holds them, and the write is answered as - superseded (an equal stamp applies, so it is judged). Nothing is - written. The organiser's picks are not checked. + judged for its picks: the merge holds them, so the picks are not + written and are kept in the match's history (an equal stamp applies, + so it is judged). The rest of the write is ordered by its own stamps, + so it is applied and answered with `heldGroups: ["repPicks"]`; it is + answered as superseded, with nothing written, only when every group + it changes is held. The organiser's picks are not checked. A `completed` write whose `decision` is empty or `hikiwake` on a match whose stored result is a completed withdrawal (kiken or @@ -5400,7 +5405,8 @@ components: row), a change of their own that a point struck on the bout never alters and that never alters the bout's score; and `bout:` one bout row (1..n, or -1 for the representative bout, without the - two representatives). The sides, court, schedule and round are not + two representatives or the winner's member id, which the server + derives). The sides, court, schedule and round are not groups. A write names the groups it changes in `changed`; the match records the stamp of the last applied change to each in `groupStamps`. diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 2420db1ad..9282f8b9e 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -94,10 +94,11 @@ function dhEntryOf(patch) { return (patch.subResults || []).find((s) => s.position === -1); } -async function mount(match, { selfReport = false, onClose = vi.fn() } = {}) { +async function mount(match, { selfReport = false, onClose = vi.fn(), prevMatch, nextMatch, onPrev, onNext } = {}) { const onSubmit = vi.fn(); const view = (m) => ( - + ); let utils; await act(async () => { utils = render(view(match)); }); @@ -430,6 +431,26 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(screen.getByTestId('team-daihyosen-remove').disabled).toBe(false); }); + it('R18b: Prev and Next are disabled, like Close, while a typed representative is still being named, and offered again once it is', async () => { + let answerEve; + window.API.addTeamMember.mockImplementation(() => new Promise((resolve) => { answerEve = resolve; })); + const neighbour = makeMatch(); + const match = makeMatch({ subResults: [DH_EMPTY] }); + await mount(match, { prevMatch: neighbour, nextMatch: neighbour, onPrev: vi.fn(), onNext: vi.fn() }); + const input = dhInput('SHIRO'); + await act(async () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'Eve' } }); }); + const add = await screen.findByText(/Add “Eve”/); + await act(async () => { fireEvent.click(add.closest('button')); }); + await waitFor(() => expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 't2', 'Eve', '')); + // leaveEditor does nothing while the member POST is out, so the buttons must not look available. + expect(screen.getByText('← Prev').closest('button').disabled).toBe(true); + expect(screen.getByText('Next →').closest('button').disabled).toBe(true); + await act(async () => { answerEve({ id: 'm9', name: 'Eve', index: 3 }); }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(screen.getByText('← Prev').closest('button').disabled).toBe(false); + expect(screen.getByText('Next →').closest('button').disabled).toBe(false); + }); + it('R19: a completed match whose side was given another team clears the stored pick, and Close closes at once with no discard prompt', async () => { window.confirmDialog.mockClear(); const match = makeMatch({ status: 'completed', subResults: [{ ...DH_EMPTY, sideAMemberId: 'm1a' }] }); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index ba8bec657..810affd32 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -716,7 +716,7 @@ function withoutRepPicks(row) { // 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, served, prior, stamps, modifiedAt) { +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 { @@ -3673,7 +3673,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const dhServed = daihyosenIdx >= 0 ? serverSubs[daihyosenIdx] : null; const dhShown = daihyosenIdx >= 0 ? subs[daihyosenIdx] : null; const repTakes = dhServed && dhShown - ? repRowTakes(dhShown, dhServed, priorByPos.get(DAIHYOSEN_POSITION), { + ? repRowTakes(dhShown, priorByPos.get(DAIHYOSEN_POSITION), { score: lastRowEditRef.current.get(DAIHYOSEN_POSITION), a: lastRepPickEditRef.current.a, b: lastRepPickEditRef.current.b, @@ -4985,7 +4985,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub )}
{prevMatch ? ( - + ) : }
{m.status === "scheduled" && !isBarredMatch(m) && ( @@ -5188,7 +5188,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub )}
{nextMatch ? ( - + ) : }
diff --git a/web-mobile/js/match_history_view.jsx b/web-mobile/js/match_history_view.jsx index 2971d17b8..d59578aff 100644 --- a/web-mobile/js/match_history_view.jsx +++ b/web-mobile/js/match_history_view.jsx @@ -113,7 +113,7 @@ export function heldValueText(group, value) { case GROUP_REP: return v ? `Shiro ${v.repPlayerB || 'not picked'}, Aka ${v.repPlayerA || 'not picked'}` : 'not picked'; case GROUP_REP_PICKS: - // The ids are member ids, not words: say who was picked, not which. + // The values are member ids, not names: say whether each side was picked, not who. return v ? `Shiro ${v.sideBMemberId ? 'picked' : 'not picked'}, Aka ${v.sideAMemberId ? 'picked' : 'not picked'}` : 'not picked'; default: if (parseBoutGroup(group) === null) return ''; From 0c2aaa48146ec8f18ce737cb4ffc29dc8f0f8715 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 02:54:33 +0100 Subject: [PATCH 24/38] fix: order each representative pick on its own (PR #463 round 10) Both representatives of a representative bout were one dated change (repPicks), so two captains picking their own side on their own phones at about the same moment erased each other's pick, with nothing held. Each side is now its own change, repPickA and repPickB, ordered by its own stamp: a pick for one side never alters the other, and a stale one is held and kept in the history while a newer pick for the other side applies. The picks stay on the representative row, so storage and the wire are unchanged. A re-seat clears and dates only the re-seated side; the self-run judge checks only the side a write names; the daihyosen add and remove date both. A dev-only stored repPicks stamp is read as both sides' stamp and dropped at the next write. Also: one rule for which side of the next match a round's match feeds (feedsSide) at the three sites that restated it; a dead history clause and an unused return value removed. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- docs/architecture/data-model.md | 13 +- .../engine/bracket_reseat_rep_picks_test.go | 35 ++-- internal/engine/bracket_seat.go | 62 +++--- internal/engine/bracket_seat_test.go | 63 ++++-- internal/engine/kachinuki.go | 2 +- internal/engine/match_history.go | 7 +- internal/engine/match_merge.go | 39 ++-- internal/engine/match_merge_rep_picks_test.go | 191 +++++++++++++++--- internal/engine/scoring.go | 21 +- internal/mobileapp/handlers_daihyosen.go | 23 ++- internal/mobileapp/handlers_match.go | 66 +++--- internal/mobileapp/self_run_daihyosen_test.go | 82 ++++++-- internal/state/match_groups.go | 167 ++++++++++----- internal/state/match_groups_table_test.go | 147 ++++++++++---- internal/state/models.go | 5 +- internal/state/models_test.go | 2 +- internal/state/testdata/match_groups.json | 6 +- specs/openapi.yaml | 35 ++-- .../js/__tests__/match_groups_table.test.jsx | 47 +++-- .../render/match_history_view.render.test.jsx | 8 +- ...m_daihyosen_representative.render.test.jsx | 24 ++- web-mobile/js/admin_scoring_team.jsx | 25 +-- web-mobile/js/match_groups.jsx | 49 +++-- web-mobile/js/match_history_view.jsx | 10 +- 25 files changed, 774 insertions(+), 359 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b58475c2..57d278ebb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,9 +284,9 @@ 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), `repPicks` (the two representatives picked for a team match's representative bout, SideAMemberID/SideBMemberID of the -1 row: dated apart from `bout:-1`, so a point on that bout never erases a pick made on another device 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 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`+`repPicks` and remove `bout:-1`+`result`+`encho`+`repPicks` (both date the 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`). `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`). `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. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index ef860c7ab..381f1fbe0 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -385,9 +385,11 @@ a bout between two people who happen to share a display name could not be attrib all, because the name identifies neither of them, and competitors are allowed to share 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. Those two ids are a change of their own, ordered apart from the bout's score and -result: a point struck on the representative bout never alters who was picked, and a pick -never alters the score, so two boards doing one each both land. The winner's member id +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 @@ -587,8 +589,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, the two fighters picked for a -representative bout, 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/internal/engine/bracket_reseat_rep_picks_test.go b/internal/engine/bracket_reseat_rep_picks_test.go index eaa51b37e..cab505e7a 100644 --- a/internal/engine/bracket_reseat_rep_picks_test.go +++ b/internal/engine/bracket_reseat_rep_picks_test.go @@ -2,8 +2,8 @@ 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 (GroupRepPicks, -// state/match_groups.go). seatBracketSide is the one owner; these tests drive +// 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. @@ -52,7 +52,8 @@ func rpSetup(t *testing.T, played bool) (*Engine, *state.Store, string) { SubResults: []state.SubMatchResult{rpRow()}, ModifiedAt: mmT2, GroupStamps: map[string]int64{ - state.GroupRepPicks: mmT1, + state.GroupRepPickA: mmT1, + state.GroupRepPickB: mmT1, state.BoutGroup(state.DaihyosenSubPosition): mmT1, state.GroupPoints: mmT2, }, @@ -102,13 +103,13 @@ func rpNext(t *testing.T, store *state.Store, compID string) state.BracketMatch return *m } -// rpOldPickReplay is the write that seated both picks, replayed: it names the -// picks alone, at the stamp they were made at, and carries no sides (the stored +// 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.GroupRepPicks}, WriteDoor: DoorScore, + Changed: []string{state.GroupRepPickA}, WriteDoor: DoorScore, SubResults: []state.SubMatchResult{rpRow()}, } } @@ -123,9 +124,10 @@ func assertSideAPickGone(t *testing.T, next state.BracketMatch, priorModifiedAt 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.GroupRepPicks), mmT1, "dated after the stamp the picks held") - assert.GreaterOrEqual(t, next.GroupStamp(state.GroupRepPicks), priorModifiedAt, "and never before the match") - assert.GreaterOrEqual(t, next.ModifiedAt, next.GroupStamp(state.GroupRepPicks)) + 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) { @@ -142,7 +144,7 @@ func TestReseat_ScheduledDownstreamLosesTheReseatedSidesPick(t *testing.T) { // 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.GroupRepPicks}, HeldGroupsOf(err)) + 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") @@ -160,7 +162,7 @@ func TestReseat_PlayedDownstreamLosesThePickAndItsReopenLineNamesIt(t *testing.T return err })) require.Equal(t, []string{rpNextID}, reopenedIDs(reopened)) - assert.True(t, reopened[0].RepPickCleared, "the reopened match is told its pick went with the team") + 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) @@ -177,7 +179,8 @@ func TestReseat_PlayedDownstreamLosesThePickAndItsReopenLineNamesIt(t *testing.T } } require.NotNil(t, reopenLine, "the played match gets a line for its reopen") - assert.Contains(t, reopenLine.Changed, state.GroupRepPicks) + 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) } @@ -196,11 +199,12 @@ func TestReseat_PlayedDownstreamReopenLineNamesNoPicksWhenNoneWentWithIt(t *test return err })) require.Equal(t, []string{rpNextID}, reopenedIDs(reopened)) - assert.False(t, reopened[0].RepPickCleared) + 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.GroupRepPicks, "there was no pick to clear") + 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") } } @@ -229,6 +233,7 @@ func TestReseat_CorrectionStoringTheSameWinnerLeavesThePicksAlone(t *testing.T) a, c := next.RepPicks() assert.Equal(t, rpPickA, a) assert.Equal(t, rpPickC, c) - assert.Equal(t, mmT1, next.GroupStamp(state.GroupRepPicks), "the picks keep their date") + 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_seat.go b/internal/engine/bracket_seat.go index 0359e2b48..d64085aa4 100644 --- a/internal/engine/bracket_seat.go +++ b/internal/engine/bracket_seat.go @@ -2,6 +2,7 @@ package engine import ( "log" + "slices" "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/gitrgoliveira/bracket-creator/internal/state" @@ -14,8 +15,8 @@ import ( // 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.GroupRepPicks -// group), so a side given ANOTHER team takes its pick away. +// (SubMatchResult.SideAMemberID/SideBMemberID, the state.GroupRepPickA and +// state.GroupRepPickB groups), so a side given ANOTHER team takes its pick away. // // - The id is unchanged (a rename, or a correction that stores the winner // already recorded and so re-propagates the same team): the name is written @@ -31,13 +32,15 @@ import ( // bracket they always did. // // The cleared pick is dated max(now, its previous stamp + 1, the match's -// ModifiedAt) through StampGroups, which never lowers: 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. +// 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 @@ -73,17 +76,13 @@ func seatBracketSide(bm *state.BracketMatch, side domain.MatchSide, name, id str if held == "" { return } - if side == domain.MatchSideA { - pickA = "" - } else { - pickB = "" - } - bm.SetRepPicks(pickA, pickB) + 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) - bm.StampGroups(max(serverNowMs(), bm.GroupStamp(state.GroupRepPicks)+1, bm.ModifiedAt), state.GroupRepPicks) + g := state.RepPickGroup(side) + bm.StampGroups(max(serverNowMs(), bm.GroupStamp(g)+1, bm.ModifiedAt), g) } // feedsSide is the side of the next match (and of the 3rd-place match, for a @@ -119,11 +118,12 @@ func snapshotDownstreamRepPicks(bracket *state.Bracket, rIdx, mIdx int) downstre return out } -// markRepPicksCleared sets RepPickCleared on every reopened match that no -// longer holds a pick it held when its prior picks were captured (at its -// reopen, or from a snapshotDownstreamRepPicks taken before the re-seat). Call -// it once the callback's re-seats are done: the only writer of a pick inside -// these callbacks is seatBracketSide, which only clears. +// markRepPicksCleared records, on every reopened match that no longer holds a +// pick it held when its prior picks were captured (at its reopen, or from a +// snapshotDownstreamRepPicks taken before the re-seat), the group of each side +// whose pick went (RepPicksCleared). Call it once the callback's re-seats are +// done: the only writer of a pick inside these callbacks is seatBracketSide, +// which only clears. func markRepPicksCleared(bracket *state.Bracket, reopened []ReopenedMatch) { for i := range reopened { m := bracket.MatchByID(reopened[i].ID) @@ -131,9 +131,23 @@ func markRepPicksCleared(bracket *state.Bracket, reopened []ReopenedMatch) { continue } a, b := m.RepPicks() - if (reopened[i].priorPickA != "" && a != reopened[i].priorPickA) || - (reopened[i].priorPickB != "" && b != reopened[i].priorPickB) { - reopened[i].RepPickCleared = true + r := &reopened[i] + if r.priorPickA != "" && a != r.priorPickA { + r.RepPicksCleared = appendUnique(r.RepPicksCleared, state.RepPickGroup(domain.MatchSideA)) + } + if r.priorPickB != "" && b != r.priorPickB { + r.RepPicksCleared = appendUnique(r.RepPicksCleared, state.RepPickGroup(domain.MatchSideB)) } } } + +// appendUnique appends s to list unless it is already there. markRepPicksCleared +// can judge the same reopened match twice (the reopen door marks the chain +// forceReopenDownstreamChain already marked, after its retraction), and a group +// must be named once. +func appendUnique(list []string, s string) []string { + if slices.Contains(list, s) { + return list + } + return append(list, s) +} diff --git a/internal/engine/bracket_seat_test.go b/internal/engine/bracket_seat_test.go index bf241deee..a1af096a7 100644 --- a/internal/engine/bracket_seat_test.go +++ b/internal/engine/bracket_seat_test.go @@ -24,7 +24,8 @@ func seatFixture() *state.BracketMatch { SideA: "Ryu", SideAID: "a", SideB: "Kuma", SideBID: "c", ModifiedAt: 500, GroupStamps: map[string]int64{ - state.GroupRepPicks: 400, + state.GroupRepPickA: 400, + state.GroupRepPickB: 400, state.BoutGroup(state.DaihyosenSubPosition): 400, state.GroupPoints: 500, }, @@ -45,7 +46,8 @@ func TestSeatBracketSide(t *testing.T) { a, c := bm.RepPicks() assert.Equal(t, "pa", a) assert.Equal(t, "pc", c) - assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPicks)) + 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) }) @@ -58,10 +60,11 @@ func TestSeatBracketSide(t *testing.T) { 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.GroupRepPicks) + 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") }) @@ -72,6 +75,8 @@ func TestSeatBracketSide(t *testing.T) { 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) { @@ -86,10 +91,10 @@ func TestSeatBracketSide(t *testing.T) { 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.GroupRepPicks] = ahead + bm.GroupStamps[state.GroupRepPickA] = ahead bm.ModifiedAt = ahead seatBracketSide(bm, domain.MatchSideA, "Tora", "b") - assert.Equal(t, ahead+1, bm.GroupStamp(state.GroupRepPicks)) + 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) { @@ -99,7 +104,8 @@ func TestSeatBracketSide(t *testing.T) { 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.GroupRepPicks)) + 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) }) @@ -109,7 +115,8 @@ func TestSeatBracketSide(t *testing.T) { seatBracketSide(bm, domain.MatchSideA, "Tora", "b") _, c := bm.RepPicks() assert.Equal(t, "pc", c) - assert.Equal(t, int64(400), bm.GroupStamp(state.GroupRepPicks)) + 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) }) @@ -201,8 +208,9 @@ func TestReopenMatch_RetractionTakesThePickOfTheClearedSlot(t *testing.T) { a, c := next.RepPicks() assert.Empty(t, a) assert.Equal(t, rpPickC, c) - assert.Greater(t, next.GroupStamp(state.GroupRepPicks), mmT1) - assert.GreaterOrEqual(t, next.GroupStamp(state.GroupRepPicks), prior.ModifiedAt) + 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) { @@ -212,20 +220,24 @@ func TestReopenMatch_RetractionTakesThePickOfTheClearedSlot(t *testing.T) { _, 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.True(t, reopened[0].RepPickCleared, "judged after the retraction, which comes after the reopen") + 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 bool + var named, namedB bool for _, e := range entries { - if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, state.GroupRepPicks) { + 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 picks group") + 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") }) } @@ -238,7 +250,7 @@ func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T 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.GroupRepPicks: 400, state.BoutGroup(state.DaihyosenSubPosition): 400}, + GroupStamps: map[string]int64{state.GroupRepPickA: 400, state.GroupRepPickB: 400, state.BoutGroup(state.DaihyosenSubPosition): 400}, SubResults: []state.SubMatchResult{{ Position: state.DaihyosenSubPosition, SideAMemberID: "pa", SideBMemberID: "pb", }}, @@ -259,7 +271,8 @@ func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T a, b := m.RepPicks() assert.Empty(t, a, "side A was given another team") assert.Equal(t, "pb", b, "side B was not") - assert.Greater(t, m.GroupStamp(state.GroupRepPicks), int64(400)) + 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) { @@ -271,7 +284,8 @@ func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T a, b := m.RepPicks() assert.Equal(t, "pa", a) assert.Equal(t, "pb", b) - assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPicks)) + assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPickA)) + assert.Equal(t, int64(400), m.GroupStamp(state.GroupRepPickB)) }) } @@ -301,7 +315,12 @@ func TestRequalify_ForcedReopenTakesThePickAndItsLineNamesIt(t *testing.T) { require.NoError(t, f.write("Pool A-0", f.poolResult("Pool A-0", "A2"), ForceOptions{Force: true, Reopened: &reopened})) require.Len(t, reopened, 1) assert.Equal(t, m1.ID, reopened[0].ID) - assert.True(t, reopened[0].RepPickCleared) + // 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) @@ -312,11 +331,15 @@ func TestRequalify_ForcedReopenTakesThePickAndItsLineNamesIt(t *testing.T) { entries, err := f.store.LoadMatchHistory(f.compID, m1.ID) require.NoError(t, err) - var named bool + var named, namedKept bool for _, e := range entries { - if e.Door == doorDownstreamReopen && slices.Contains(e.Changed, state.GroupRepPicks) { + 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 picks group") + 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") } diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go index 70aadb22b..5bc68f1a4 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -1978,7 +1978,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 && diff --git a/internal/engine/match_history.go b/internal/engine/match_history.go index e4d1e6742..b8b15f67e 100644 --- a/internal/engine/match_history.go +++ b/internal/engine/match_history.go @@ -200,10 +200,9 @@ func noteServerBoutChanges(result *state.MatchResult, before, stored []state.Sub result.Changed = append(result.Changed, g) } } - // The representatives are a change of their own, not part of the bout row. - if !slices.Contains(result.Changed, state.GroupRepPicks) && state.GroupDiffers(was, result, state.GroupRepPicks) { - result.Changed = append(result.Changed, state.GroupRepPicks) - } + // 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 679d2f5a1..aa3299d61 100644 --- a/internal/engine/match_merge.go +++ b/internal/engine/match_merge.go @@ -126,9 +126,11 @@ func defaultChangedGroups(stored, incoming *state.MatchResult, nilSubsClear bool 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 them. + // 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.GroupRepPicks }) + out = slices.DeleteFunc(out, func(g string) bool { + return g == state.GroupRepPickA || g == state.GroupRepPickB + }) } return out } @@ -325,18 +327,27 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic } // A pick has no bout to land on once the representative bout is gone (a // removal's tombstone, or a row the write carried that its stamp could not - // bring back): the picks are held, in the history, not applied onto nothing. - if i := slices.Index(rep.Applied, state.GroupRepPicks); i >= 0 && (payloadPickA != "" || payloadPickB != "") && - state.DaihyosenSubIndex(incoming.SubResults) < 0 { - rep.Applied = slices.Delete(rep.Applied, i, i+1) - ghost := &state.MatchResult{SubResults: []state.SubMatchResult{{ - Position: state.DaihyosenSubPosition, SideAMemberID: payloadPickA, SideBMemberID: payloadPickB, - }}} - reportHeld(rep, state.GroupRepPicks, stored, ghost, nil) - if s, ok := storedStamps[state.GroupRepPicks]; ok { - stamps[state.GroupRepPicks] = s - } else { - delete(stamps, state.GroupRepPicks) + // bring back): each side's pick is held, in the history, not applied onto + // nothing, and that side's stamp goes back to what it was. + if state.DaihyosenSubIndex(incoming.SubResults) < 0 { + for _, pick := range []struct { + side domain.MatchSide + id string + }{{domain.MatchSideA, payloadPickA}, {domain.MatchSideB, payloadPickB}} { + g := state.RepPickGroup(pick.side) + i := slices.Index(rep.Applied, g) + if i < 0 || pick.id == "" { + continue + } + rep.Applied = slices.Delete(rep.Applied, i, i+1) + ghost := &state.MatchResult{SubResults: []state.SubMatchResult{{Position: state.DaihyosenSubPosition}}} + ghost.SetRepPick(pick.side, pick.id) + reportHeld(rep, g, stored, ghost, nil) + if s, ok := storedStamps[g]; ok { + stamps[g] = s + } else { + delete(stamps, g) + } } } resultApplied := inChanged[state.GroupResult] && !hold[state.GroupResult] diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index 27997fb58..3f6d449de 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -3,10 +3,10 @@ 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 their own dated change, state.GroupRepPicks, ordered apart -// from the bout's score and result: a point never alters the picks, a pick -// never alters the point, and an older change of either is kept in the -// match's history. +// 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" @@ -20,7 +20,9 @@ import ( const ( repBoutGroup = "bout:-1" - repPicksName = state.GroupRepPicks + // 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 @@ -47,12 +49,20 @@ func repWrite(h mmHome, at int64, row state.SubMatchResult, changed ...string) * // 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, repPicksName))) + 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. @@ -75,7 +85,7 @@ func TestMerge_RepPick_KeepsAnotherDevicesPoint(t *testing.T) { 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), repPicksName))) + 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") @@ -87,21 +97,21 @@ func TestMerge_RepPick_KeepsAnotherDevicesPoint(t *testing.T) { 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, repPicksName))) + require.NoError(t, h.write(repWrite(h, mmT2, repRow("carol", "", nil, nil), repBoutGroup, repPickAName))) - err := h.write(repWrite(h, mmT1, repRow("dana", "", nil, nil), repPicksName)) + 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{repPicksName}, HeldGroupsOf(err)) + 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[repPicksName]) - assert.JSONEq(t, `{"sideAMemberId":"dana","sideBMemberId":""}`, string(held.Held[repPicksName]), + 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), repPicksName))) + 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") }) } @@ -126,7 +136,7 @@ func TestMerge_RepPicks_SurviveAWriteThatNamesNothingAndSendsNoBouts(t *testing. // 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, repPicksName))) + 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 @@ -143,9 +153,9 @@ 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, repPicksName))) + require.NoError(t, h.write(repWrite(h, mmT1, won, repBoutGroup, repPickAName))) - require.NoError(t, h.write(repWrite(h, mmT2, repRow("dana", "", nil, nil), repPicksName))) + 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) @@ -153,7 +163,7 @@ func TestMerge_RepPick_ReDerivesTheWinnerMemberID(t *testing.T) { 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), repPicksName))) + 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") @@ -170,7 +180,7 @@ func TestMerge_RepBout_WinnerMemberIDFollowsTheWinnersName(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, repPicksName))) + 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) @@ -186,7 +196,7 @@ func TestMerge_RepBout_WinnerMemberIDFollowsTheWinnersName(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, repPicksName))) + require.NoError(t, h.write(repWrite(h, mmT1, won, repBoutGroup, repPickBName))) row := h.load(t).SubResults[0] assert.Equal(t, wrTeamA, row.Winner) @@ -213,7 +223,7 @@ func TestMerge_RepBout_WinnerMemberIDAloneIsAnEcho(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, repPicksName))) + 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") @@ -246,19 +256,34 @@ func TestMerge_RepBout_WinnerMemberIDAloneIsAnEcho(t *testing.T) { func TestMerge_RepPick_OnARemovedBoutIsHeld(t *testing.T) { h := mmTeam(t, true) seedPick(t, h) - remove := mmRunning(h, mmT2, repBoutGroup, repPicksName) + 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(repPicksName), "the removal dates the picks too") + 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), repPicksName) + late := repWrite(h, mmT3, repRow("dana", "", nil, nil), repPickAName) err := h.write(late) require.ErrorIs(t, err, ErrMatchSuperseded) - assert.Equal(t, []string{repPicksName}, HeldGroupsOf(err)) + 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 @@ -268,23 +293,62 @@ func TestMerge_RepPicks_LegacyStampFallsBackToTheBout(t *testing.T) { bracket, err := h.store.LoadBracket(h.compID) require.NoError(t, err) bm := &bracket.Rounds[0][0] - bm.SubResults = []state.SubMatchResult{repRow("carol", "", nil, nil)} + 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)) - err = h.write(repWrite(h, mmT1, repRow("dana", "", nil, nil), repPicksName)) + // 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 stamp map from this branch's earlier commits dated both picks as one group, +// "repPicks". It reads as BOTH sides' stamp and is converted, and dropped, by the +// first write that materializes the map (it errs toward holding a pick, which +// is kept in the history). +func TestMerge_RepPicks_DevStampOfBothPicksIsReadForEachSide(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 = mmT3 + bm.GroupStamps = map[string]int64{repBoutGroup: mmT3, "repPicks": mmT2} + require.NoError(t, h.store.SaveBracket(h.compID, bracket)) + + 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) + assert.Equal(t, []string{repPickAName}, HeldGroupsOf(err)) - require.NoError(t, h.write(repWrite(h, mmT3, repRow("dana", "", nil, nil), repPicksName))) - assert.Equal(t, "dana", h.load(t).SubResults[0].SideAMemberID) + require.NoError(t, h.write(repWrite(h, mmT4, repRow("dana", "gus", nil, nil), repPickAName))) + got := h.load(t) + assert.Equal(t, "dana", got.SubResults[0].SideAMemberID) + assert.Equal(t, mmT4, got.GroupStamp(repPickAName)) + assert.Equal(t, mmT2, got.GroupStamp(repPickBName), "side B keeps the legacy date") + assert.NotContains(t, got.GroupStamps, "repPicks", "converted, not left to linger") } // A legacy match (no stamp map) that holds no representative bout row is not -// given a repPicks stamp by an ordinary write: the materialization that runs -// before the first group is stamped adds the key only for a match that has the -// row to date. Individual matches never have one. +// 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) @@ -295,6 +359,69 @@ func TestMerge_LegacyMatchWithoutARepBoutGainsNoRepPicksStamp(t *testing.T) { 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, repPicksName) + 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)) }) } diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index c198a035d..caaa851ec 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -642,11 +642,12 @@ 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 - // RepPickCleared is set when the same write that reopened the match gave - // one of its sides another team, which took that side's representative - // with it (seatBracketSide). Its reopen history line then names the - // repPicks group beside the verdict's. - RepPickCleared bool + // 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. + RepPicksCleared []string // priorPickA and priorPickB are the representatives the match held when it // was reopened (or, for a downstream match a winner is re-propagated into, // before that write began), for markRepPicksCleared to compare. @@ -2862,7 +2863,7 @@ func winnerActuallyChanged(priorWinner, priorWinnerID string, bm *state.BracketM // preceded this call (the score, override and engi doors); the reopen door, // which retracts afterwards, passes nil and judges the picks itself. Each // reopened match whose representative went with a re-seated side comes back -// with RepPickCleared set. +// with RepPicksCleared naming that side's group. func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correctedID string, before downstreamRepPicks) []ReopenedMatch { d := propagatedDownstreamOf(bracket, rIdx, mIdx) var reopened []ReopenedMatch @@ -2975,9 +2976,9 @@ func (e *Engine) restoreForceReopened(h state.StoreTx, compID string, reopened [ // The reopen changed the match outside the merge, so it records its // own history entry (bc-mrgc). groups := reopenedBracketGroups - if reopened[i].RepPickCleared { + if len(reopened[i].RepPicksCleared) > 0 { // A copy: reopenedBracketGroups is shared. - groups = append(append([]string(nil), groups...), state.GroupRepPicks) + groups = append(append([]string(nil), groups...), reopened[i].RepPicksCleared...) } e.recordDirectHistory(h, compID, reopened[i].ID, doorDownstreamReopen, serverNowMs(), groups...) } @@ -3122,7 +3123,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) { @@ -3177,7 +3178,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) { diff --git a/internal/mobileapp/handlers_daihyosen.go b/internal/mobileapp/handlers_daihyosen.go index 063dbc1f1..fa0865084 100644 --- a/internal/mobileapp/handlers_daihyosen.go +++ b/internal/mobileapp/handlers_daihyosen.go @@ -268,10 +268,11 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da // What the remove changes (bc-mrgc): the representative bout (its // stamp stays as a tombstone, so an older write still carrying the // row cannot bring it back), its representatives (they go with the - // row, and their stamp dates the removal so a pick made before it - // is held, not applied onto nothing), the verdict, the overtime, and - // the scoreline when clearing the verdict took a hantei mark out of it. - u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult, state.GroupEncho, state.GroupRepPicks) + // row, and each side's stamp dates the removal so a pick made before + // it is held, not applied onto nothing), the verdict, the overtime, + // and the scoreline when clearing the verdict took a hantei mark out + // of it. + u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult, state.GroupEncho, state.GroupRepPickA, state.GroupRepPickB) u.WriteDoor = engine.DoorDaihyosenDel if _, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, mid, &u); err != nil { if errors.Is(err, engine.ErrMatchSuperseded) { @@ -474,13 +475,13 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da // What the add changes (bc-mrgc): the new representative bout and // the verdict, and the scoreline when clearing the verdict took a // hantei mark out of it. The new row is built with no - // representatives, and the add names state.GroupRepPicks all the - // same, as the remove does: it dates the (empty) picks of THIS - // bout, so a pick made on the previous representative bout, stamped - // between the remove and the add, is older than the add and is held - // rather than landing on the new bout through the remove's - // tombstone stamp. - u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult, state.GroupRepPicks) + // representatives, and the add names both side groups + // (state.GroupRepPickA, state.GroupRepPickB) all the same, as the + // remove does: it dates the (empty) picks of THIS bout, so a pick + // made on the previous representative bout, stamped between the + // remove and the add, is older than the add and is held rather than + // landing on the new bout through the remove's tombstone stamp. + u.Changed = daihyosenChangedGroups(match, &u, state.GroupResult, state.GroupRepPickA, state.GroupRepPickB) u.WriteDoor = engine.DoorDaihyosenAdd if _, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, mid, &u); err != nil { if errors.Is(err, engine.ErrMatchSuperseded) { diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 35cca2f83..0d6be010b 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2111,16 +2111,19 @@ var errRepMemberNotInTeam = &selfRunRefusal{ } // repRowRefusal judges what a participant's representative bout row would -// change, as the merge will order it (bc-mrgc): the two representatives -// (state.GroupRepPicks). A change the write does not name, or one the merge -// holds because its stamp is strictly older than the stored change's -// (domain.ApplyByTimestamp: an equal stamp applies), is never written, so it -// is not judged: the picks are held and kept in the match's history, as any -// stale change is. The rest of the write is ordered by its own stamps, so the -// write is applied and answered with heldGroups ["repPicks"]; it is answered -// superseded only when every group it changes is held. Judging the picks would -// refuse a stale queued pick the merge was going to hold anyway. An unnamed -// write (an older client) changes every group its row carries. +// change, as the merge will order it (bc-mrgc): each side's representative +// (state.GroupRepPickA, state.GroupRepPickB), side by side. A side's change the +// write does not name, or one the merge holds because its stamp is strictly +// older than the stored change's (domain.ApplyByTimestamp: an equal stamp +// applies), is never written, so it is not judged: that pick is held and kept +// in the match's history, as any stale change is. The rest of the write is +// ordered by its own stamps, so the write is applied and answered with the held +// side's group in heldGroups (e.g. ["repPickA"]); it is answered superseded only +// when every group it changes is held. Judging a pick would refuse a stale +// queued pick the merge was going to hold anyway, and a side the write does not +// change is never judged against the team it has been given since (a write +// naming one side while echoing the other's id, which a re-seat has cleared). +// An unnamed write (an older client) changes every group its row carries. // // The bout's winner id is NOT judged: it belongs to the bout group and the // merge keeps it consistent with the representatives that stand @@ -2128,11 +2131,15 @@ var errRepMemberNotInTeam = &selfRunRefusal{ // of a pick another device has since replaced is applied and its winner id // re-derived, rather than refused and lost. func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result *state.MatchResult, row state.SubMatchResult, stored *state.SubMatchResult) error { - named := result.Changed == nil || slices.Contains(result.Changed, state.GroupRepPicks) - if !named || !domain.ApplyByTimestamp(result.ModifiedAt, snap.RepPicksStamp) { + judges := func(side domain.MatchSide, stored int64) bool { + named := result.Changed == nil || slices.Contains(result.Changed, state.RepPickGroup(side)) + return named && domain.ApplyByTimestamp(result.ModifiedAt, stored) + } + judgeA, judgeB := judges(domain.MatchSideA, snap.RepPickAStamp), judges(domain.MatchSideB, snap.RepPickBStamp) + if !judgeA && !judgeB { return nil } - return repMembersOutsideTeams(stx, compID, snap.Pairing, row, stored) + return repMembersOutsideTeams(stx, compID, snap.Pairing, row, stored, judgeA, judgeB) } // repMembersOutsideTeams judges the member ids a representative bout row names @@ -2142,8 +2149,9 @@ func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result // organiser's write is not judged here (only the self-run path calls this). // // A write answers for what it introduces, not for what it inherited (the write -// guard's rule): a side is judged only when the write names a member for it that -// the stored row does not already hold, and only against a stored side that +// guard's rule): a side is judged only when the caller says the merge will +// apply that side's pick (judgeA, judgeB), when the write names a member for it +// that the stored row does not already hold, and only against a stored side that // carries a team id. A side with no team id has nothing to judge the pick // against, so it is not refused. The winner id is the bout's, not the picks', // and is not judged at all (repRowRefusal). @@ -2151,7 +2159,7 @@ func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result // stored is the match's representative bout and is never nil: the caller answers // a row the match does not have before it gets here (dropped or refused), so this // only judges a row the match already has. -func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult) error { +func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult, judgeA, judgeB bool) error { storedA, storedB := stored.SideAMemberID, stored.SideBMemberID type judged struct{ teamID, memberID string } var picks []judged @@ -2161,8 +2169,12 @@ func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.Win } picks = append(picks, judged{teamID: teamID, memberID: memberID}) } - add(pairing.SideAID, row.SideAMemberID, storedA) - add(pairing.SideBID, row.SideBMemberID, storedB) + if judgeA { + add(pairing.SideAID, row.SideAMemberID, storedA) + } + if judgeB { + add(pairing.SideBID, row.SideBMemberID, storedB) + } if len(picks) == 0 { return nil } @@ -2239,12 +2251,12 @@ type matchSnapshot struct { // verdict was last changed), which the correction-reason check reads to // tell a stale replay of a finish from a correction (bc-mrgc review). ResultStamp int64 - // RepPicksStamp is the stored stamp of the representatives - // (state.GroupRepPicks, read through GroupStamp, so a legacy match reads its - // ModifiedAt), a change of their own. The self-run judge orders a pick - // against it: the merge holds a pick stamped before it, so such a pick is - // not judged. - RepPicksStamp int64 + // RepPickAStamp and RepPickBStamp are the stored stamps of each side's + // representative (state.RepPickGroup, read through GroupStamp, so a legacy + // match reads its ModifiedAt), a change of its own. The self-run judge + // orders a side's pick against that side's stamp: the merge holds a pick + // stamped before it, so such a pick is not judged. + RepPickAStamp, RepPickBStamp int64 } // repBoutOf returns a copy of the first representative-bout row in subs, nil @@ -2297,7 +2309,8 @@ func lookupMatchSnapshot(s matchStores, compID, matchID string) (matchSnapshot, RepBout: repBoutOf(poolMatches[i].SubResults), Pairing: poolMatches[i].Attribution(), ResultStamp: poolMatches[i].GroupStamp(state.GroupResult), - RepPicksStamp: poolMatches[i].GroupStamp(state.GroupRepPicks), + RepPickAStamp: poolMatches[i].GroupStamp(state.GroupRepPickA), + RepPickBStamp: poolMatches[i].GroupStamp(state.GroupRepPickB), }, true, loadErr } } @@ -2332,7 +2345,8 @@ func bracketMatchSnapshot(bm *state.BracketMatch) matchSnapshot { RepBout: repBoutOf(bm.SubResults), Pairing: bm.Attribution(), ResultStamp: bm.GroupStamp(state.GroupResult), - RepPicksStamp: bm.GroupStamp(state.GroupRepPicks), + RepPickAStamp: bm.GroupStamp(state.GroupRepPickA), + RepPickBStamp: bm.GroupStamp(state.GroupRepPickB), } } diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index 0f37ce24a..cede353a6 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -990,9 +990,9 @@ func TestSelfRun_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "the organiser's pick is not judged on membership: %s", w.Body.String()) } -// The merge holds a pick whose stamp is strictly older than the stored -// representatives' own stamp (state.GroupRepPicks, a change dated apart from the -// bout row), so such a pick is never written. Judging it would refuse a stale +// The merge holds a pick whose stamp is strictly older than the stored stamp of +// that side's pick (state.GroupRepPickA here, a change dated apart from the +// other side's and from the bout row), so such a pick is never written. Judging it would refuse a stale // queued write for a pick the merge was going to hold anyway (and keep in the // history). An EQUAL stamp applies under the merge (domain.ApplyByTimestamp), so // an equal-stamped pick is still judged. @@ -1030,12 +1030,12 @@ func TestSelfRun_AStalePickTheMergeHoldsIsNotRefused(t *testing.T) { assert.Equal(t, before, *f.storedRepBout(t), "a refused pick writes nothing") } -// A pick is judged against the representatives' OWN stamp, not the bout row's -// and not the match's ModifiedAt (the newest stamp of any group). A point struck -// on the representative bout moves the bout's stamp past the picks' while the -// picks keep theirs, and a pick stamped between the two applies to the picks -// under the merge, so it is judged. The write that struck the point names only -// the bout and so is never judged for picks. +// A pick is judged against that side's OWN stamp, not the bout row's and not the +// match's ModifiedAt (the newest stamp of any group). A point struck on the +// representative bout moves the bout's stamp past the picks' while the picks +// keep theirs, and a pick stamped between the two applies to the picks under the +// merge, so it is judged. The write that struck the point names only the bout +// and so is never judged for picks. func TestSelfRun_APickIsJudgedAgainstThePicksOwnStamp(t *testing.T) { f := newRepBoutFixture(t, true) f.addRepBout(t) @@ -1049,6 +1049,8 @@ func TestSelfRun_APickIsJudgedAgainstThePicksOwnStamp(t *testing.T) { valid["sideAMemberId"] = membersA[0].ID w := f.score("", state.MatchStatusRunning, "", f.now, valid) require.Equal(t, http.StatusOK, w.Code, "a valid pick: %s", w.Body.String()) + pickedMatch := storedB1(t, f.store, "c1") + beforeB := pickedMatch.GroupStamp(state.GroupRepPickB) // A point on the representative bout from a board that never saw the pick: // it names the bout only, so its row (with no ids, and so no pick of its own) @@ -1059,7 +1061,8 @@ func TestSelfRun_APickIsJudgedAgainstThePicksOwnStamp(t *testing.T) { require.Equal(t, http.StatusOK, w.Code, "the point: %s", w.Body.String()) stored := storedB1(t, f.store, "c1") require.Equal(t, f.now+100, stored.GroupStamp(state.BoutGroup(state.DaihyosenSubPosition)), "the bout moved on") - require.Equal(t, f.now, stored.GroupStamp(state.GroupRepPicks), "the picks kept their stamp") + require.Equal(t, f.now, stored.GroupStamp(state.GroupRepPickA), "the pick kept its stamp") + require.Equal(t, beforeB, stored.GroupStamp(state.GroupRepPickB), "and the point did not date the other side's either") assert.Equal(t, membersA[0].ID, f.storedRepBout(t).SideAMemberID, "the point did not erase the pick") bad := repBoutRow([]string{}, []string{}, "") @@ -1068,10 +1071,43 @@ func TestSelfRun_APickIsJudgedAgainstThePicksOwnStamp(t *testing.T) { requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") } -// Removing the representative bout dates the representatives with the removal -// (it names state.GroupRepPicks beside the bout), so a pick a board made before -// it, on a sheet that still showed the row, is held and kept in the history -// rather than applied onto nothing. +// Each side's pick is judged only when the write names that side's group (and +// the merge will apply it). A write that names side A's pick while merely +// echoing an id on side B the stored row does not hold (as a re-seat that has +// cleared it would leave) is not refused for side B against the team it holds +// now, and writes nothing for it; naming side B's pick as well judges it. +func TestSelfRun_ASideTheWriteDoesNotNameIsNotJudged(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + membersA := squads[repBoutTeamAID] + require.GreaterOrEqual(t, len(membersA), 2, "team A is seeded with members") + + // Side A's own member; on side B, a member of team A (not team B's). + row := repBoutRow([]string{}, []string{}, "") + row["sideAMemberId"] = membersA[0].ID + row["sideBMemberId"] = membersA[1].ID + + named := scoreSheet(state.MatchStatusRunning, "", f.now+300, row) + named["changed"] = []string{state.GroupRepPickA} + w := f.send(http.MethodPut, repBoutMatchPath+"/score", "", named) + require.Equal(t, http.StatusOK, w.Code, "side B is not named, so it is not judged: %s", w.Body.String()) + stored := f.storedRepBout(t) + assert.Equal(t, membersA[0].ID, stored.SideAMemberID, "the named side applies") + assert.Empty(t, stored.SideBMemberID, "and the side it did not name is not written") + + both := scoreSheet(state.MatchStatusRunning, "", f.now+400, row) + both["changed"] = []string{state.GroupRepPickA, state.GroupRepPickB} + w = f.send(http.MethodPut, repBoutMatchPath+"/score", "", both) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") + assert.Empty(t, f.storedRepBout(t).SideBMemberID, "a refused pick writes nothing") +} + +// Removing the representative bout dates both sides' representatives with the +// removal (it names state.GroupRepPickA and state.GroupRepPickB beside the +// bout), so a pick a board made before it, on a sheet that still showed the row, +// is held and kept in the history rather than applied onto nothing. func TestDaihyosenRemove_DatesTheRepresentativesToo(t *testing.T) { f := newRepBoutFixture(t, true) f.addRepBout(t) @@ -1089,20 +1125,21 @@ func TestDaihyosenRemove_DatesTheRepresentativesToo(t *testing.T) { require.Equal(t, http.StatusOK, w.Code, w.Body.String()) stored := storedB1(t, f.store, "c1") require.False(t, carriesDaihyosenRow(stored.SubResults), "the bout is gone") - assert.Equal(t, f.now+50, stored.GroupStamp(state.GroupRepPicks), "the removal dates the representatives") + assert.Equal(t, f.now+50, stored.GroupStamp(state.GroupRepPickA), "the removal dates side A's representative") + assert.Equal(t, f.now+50, stored.GroupStamp(state.GroupRepPickB), "and side B's") // A pick made after the removal on a sheet that still showed the row names - // the picks alone: it is newer, but the bout it sits on is gone. + // the pick alone: it is newer, but the bout it sits on is gone. sheet := scoreSheet(state.MatchStatusRunning, "", f.now+60, pick) - sheet["changed"] = []string{state.GroupRepPicks} + sheet["changed"] = []string{state.GroupRepPickA} late := f.send(http.MethodPut, repBoutMatchPath+"/score", "main-pw", sheet) require.Equal(t, http.StatusOK, late.Code, late.Body.String()) assert.Contains(t, late.Body.String(), `"superseded"`, "a pick has no bout to land on: it is held") assert.False(t, carriesDaihyosenRow(storedB1(t, f.store, "c1").SubResults), "nothing is resurrected") } -// Adding the representative bout dates the representatives with the add, as the -// remove does. Without it the new row inherits the stamp of the earlier removal's +// Adding the representative bout dates both sides' representatives with the add, +// as the remove does. Without it the new row inherits the stamp of the earlier removal's // tombstone, and a pick made on the PREVIOUS representative bout, stamped between // the remove and the add, lands on the new bout. func TestDaihyosenAdd_DatesTheRepresentativesToo(t *testing.T) { @@ -1118,13 +1155,14 @@ func TestDaihyosenAdd_DatesTheRepresentativesToo(t *testing.T) { w = f.send(http.MethodPost, repBoutMatchPath+"/daihyosen", "main-pw", map[string]any{"modifiedAt": f.now + 200}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) added := storedB1(t, f.store, "c1") - require.Equal(t, f.now+200, added.GroupStamp(state.GroupRepPicks), "the add dates the representatives") + require.Equal(t, f.now+200, added.GroupStamp(state.GroupRepPickA), "the add dates side A's representative") + require.Equal(t, f.now+200, added.GroupStamp(state.GroupRepPickB), "and side B's") // A pick made on the previous bout, between the remove and the add, arrives late. pick := repBoutRow([]string{}, []string{}, "") pick["sideAMemberId"] = membersA[0].ID sheet := scoreSheet(state.MatchStatusRunning, "", f.now+150, pick) - sheet["changed"] = []string{state.GroupRepPicks} + sheet["changed"] = []string{state.GroupRepPickA} late := f.send(http.MethodPut, repBoutMatchPath+"/score", "main-pw", sheet) require.Equal(t, http.StatusOK, late.Code, late.Body.String()) assert.Contains(t, late.Body.String(), `"superseded"`, "a pick older than the add is held") @@ -1151,7 +1189,7 @@ func TestSelfRun_AWinningPointCarryingAReplacedPicksIdIsAcceptedAndReconciled(t row := repBoutRow([]string{}, []string{}, "") row["sideAMemberId"] = memberID sheet := scoreSheet(state.MatchStatusRunning, "", at, row) - sheet["changed"] = []string{state.GroupRepPicks} + sheet["changed"] = []string{state.GroupRepPickA} w := f.send(http.MethodPut, repBoutMatchPath+"/score", "main-pw", sheet) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } diff --git a/internal/state/match_groups.go b/internal/state/match_groups.go index 4c98bbd74..11eb0610b 100644 --- a/internal/state/match_groups.go +++ b/internal/state/match_groups.go @@ -25,6 +25,8 @@ import ( "sort" "strconv" "strings" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" ) // The scalar groups. A team match additionally has one group per bout row, @@ -43,24 +45,52 @@ const ( GroupFlags = "flags" // GroupRep holds the pool daihyosen/tiebreaker representative players. GroupRep = "rep" - // GroupRepPicks holds the two representatives a team match's - // representative bout is fought by: SideAMemberID and SideBMemberID of the - // row at DaihyosenSubPosition. They are a change of their own, dated and - // ordered apart from the bout row they sit on (bout:-1), which keeps the - // bout's score and result: a point struck on the bout never alters who was - // picked, and a pick never alters the score. The row's WinnerMemberID + // GroupRepPickA and GroupRepPickB hold ONE side's representative for a team + // match's representative bout each: SideAMemberID (side A, Aka) and + // SideBMemberID (side B, Shiro) of the row at DaihyosenSubPosition. Each + // side's pick is a change of its own, dated and ordered apart from the other + // side's pick and from the bout row they sit on (bout:-1), which keeps the + // bout's score and result: two captains picking at about the same moment on + // their own phones both land, a point struck on the bout never alters who + // was picked, and a pick never alters the score. The row's WinnerMemberID // moves with the bout (CopyGroup copies the row), but it is derived state: - // neither group's comparison includes it, and the merge works it out again - // from the winner's name and the stored picks after every write + // no group's comparison includes it, and the merge works it out again from + // the winner's name and the stored picks after every write // (SubMatchResult.ReconcileWinnerMemberID). Not to be confused with - // GroupRep, the pool daihyosen/tiebreaker players. - GroupRepPicks = "repPicks" + // GroupRep, the pool daihyosen/tiebreaker players. RepPickGroup names the + // group of a side. + GroupRepPickA = "repPickA" + GroupRepPickB = "repPickB" + + // legacyRepPicksGroup is the one group both picks were dated under before + // each side had its own. Nothing is written under it any more; a stamp map + // from the commits that introduced the picks may still hold it, and is read + // as BOTH sides' stamp until the first materialization converts it + // (groupStampOf, MaterializedGroupStamps). + legacyRepPicksGroup = "repPicks" boutGroupPrefix = "bout:" ) // ScalarGroups lists the scalar groups in their fixed order. -var ScalarGroups = []string{GroupPoints, GroupResult, GroupEncho, GroupFlags, GroupRep, GroupRepPicks} +var ScalarGroups = []string{GroupPoints, GroupResult, GroupEncho, GroupFlags, GroupRep, GroupRepPickA, GroupRepPickB} + +// RepPickGroup names the group of side's representative pick, "" for a value +// that is neither side. +func RepPickGroup(side domain.MatchSide) string { + switch side { + case domain.MatchSideA: + return GroupRepPickA + case domain.MatchSideB: + return GroupRepPickB + } + return "" +} + +// isRepPickGroup reports whether group is one side's representative pick. +func isRepPickGroup(group string) bool { + return group == GroupRepPickA || group == GroupRepPickB +} // BoutGroup names the group of the bout row at position (1..n, or // DaihyosenSubPosition for the representative bout). @@ -136,20 +166,27 @@ func repPicksOf(subs []SubMatchResult) (sideA, sideB string) { return "", "" } -// withRepPicks returns a copy of subs whose row at DaihyosenSubPosition names -// the two representatives, and whether there was such a row. The list is -// copied, never edited in place, so a row another copy of the match shares is -// never written through. -func withRepPicks(subs []SubMatchResult, sideA, sideB string) ([]SubMatchResult, bool) { +// withRepPick returns a copy of subs whose row at DaihyosenSubPosition names id +// as side's representative, and subs itself when there is no such row or side +// is neither side. The list is copied, never edited in place, so a row another +// copy of the match shares is never written through. +func withRepPick(subs []SubMatchResult, side domain.MatchSide, id string) []SubMatchResult { + if side != domain.MatchSideA && side != domain.MatchSideB { + return subs + } for i := range subs { if subs[i].Position != DaihyosenSubPosition { continue } out := append([]SubMatchResult(nil), subs...) - out[i].SideAMemberID, out[i].SideBMemberID = sideA, sideB - return out, true + if side == domain.MatchSideA { + out[i].SideAMemberID = id + } else { + out[i].SideBMemberID = id + } + return out } - return subs, false + return subs } // RepPicks returns the two representatives the row at DaihyosenSubPosition @@ -158,11 +195,11 @@ func (m *MatchResult) RepPicks() (sideA, sideB string) { return repPicksOf(m.SubResults) } -// SetRepPicks puts the two representatives on the row at DaihyosenSubPosition. -// It does nothing when the match has no such row: a pick has no bout to land -// on. The list is replaced, not edited in place (withRepPicks). -func (m *MatchResult) SetRepPicks(sideA, sideB string) { - m.SubResults, _ = withRepPicks(m.SubResults, sideA, sideB) +// SetRepPick puts id on the row at DaihyosenSubPosition as side's +// representative. It does nothing when the match has no such row: a pick has no +// bout to land on. The list is replaced, not edited in place (withRepPick). +func (m *MatchResult) SetRepPick(side domain.MatchSide, id string) { + m.SubResults = withRepPick(m.SubResults, side, id) } // RepPicks is MatchResult.RepPicks for a bracket match. @@ -170,9 +207,9 @@ func (bm *BracketMatch) RepPicks() (sideA, sideB string) { return repPicksOf(bm.SubResults) } -// SetRepPicks is MatchResult.SetRepPicks for a bracket match. -func (bm *BracketMatch) SetRepPicks(sideA, sideB string) { - bm.SubResults, _ = withRepPicks(bm.SubResults, sideA, sideB) +// SetRepPick is MatchResult.SetRepPick for a bracket match. +func (bm *BracketMatch) SetRepPick(side domain.MatchSide, id string) { + bm.SubResults = withRepPick(bm.SubResults, side, id) } // boutOrderKey sorts bout rows the way every writer appends them: numbered @@ -236,14 +273,19 @@ func CopyGroup(dst, src *MatchResult, group string) { dst.FlagsA, dst.FlagsB = src.FlagsA, src.FlagsB case GroupRep: dst.RepPlayerA, dst.RepPlayerB = src.RepPlayerA, src.RepPlayerB - case GroupRepPicks: - dst.SetRepPicks(src.RepPicks()) + case GroupRepPickA: + a, _ := src.RepPicks() + dst.SetRepPick(domain.MatchSideA, a) + case GroupRepPickB: + _, b := src.RepPicks() + dst.SetRepPick(domain.MatchSideB, b) default: if pos, ok := ParseBoutGroup(group); ok { row := subAt(src.SubResults, pos) // The representatives are not the bout's: a copy of the bout keeps the - // picks the destination row already holds (GroupRepPicks moves them), - // so the two copies give the same result in either order. + // picks the destination row already holds (GroupRepPickA and + // GroupRepPickB move them), so the copies give the same result in + // any order. if dstRow := subAt(dst.SubResults, pos); pos == DaihyosenSubPosition && row != nil && dstRow != nil { kept := *row kept.SideAMemberID, kept.SideBMemberID = dstRow.SideAMemberID, dstRow.SideBMemberID @@ -293,8 +335,13 @@ type flagsGroup struct { FlagsB int `json:"flagsB"` } -type repPicksGroup struct { +// repPickAGroup and repPickBGroup hold ONE key each, named as the row's field, +// so a pick kept in the history reads as the field it was. +type repPickAGroup struct { SideAMemberID string `json:"sideAMemberId"` +} + +type repPickBGroup struct { SideBMemberID string `json:"sideBMemberId"` } @@ -324,9 +371,12 @@ func groupProjection(m *MatchResult, group string) any { return flagsGroup{FlagsA: m.FlagsA, FlagsB: m.FlagsB} case GroupRep: return repGroup{RepPlayerA: m.RepPlayerA, RepPlayerB: m.RepPlayerB} - case GroupRepPicks: - a, b := m.RepPicks() - return repPicksGroup{SideAMemberID: a, SideBMemberID: b} + case GroupRepPickA: + a, _ := m.RepPicks() + return repPickAGroup{SideAMemberID: a} + case GroupRepPickB: + _, b := m.RepPicks() + return repPickBGroup{SideBMemberID: b} } if pos, ok := ParseBoutGroup(group); ok { row := subAt(m.SubResults, pos) @@ -336,12 +386,13 @@ func groupProjection(m *MatchResult, group string) any { c := CloneSubResults([]SubMatchResult{*row})[0] c.IpponsA, c.IpponsB = nonNilStrings(c.IpponsA), nonNilStrings(c.IpponsB) if pos == DaihyosenSubPosition { - // The representatives are GroupRepPicks', not the bout's. The row's - // WinnerMemberID is derived from the winner's name and those picks - // after every merge (SubMatchResult.ReconcileWinnerMemberID), and the - // client's bout comparison leaves it out, so it is no change of the - // bout's: a write that echoes the row with another id (or none) has - // changed nothing. + // The representatives are GroupRepPickA's and GroupRepPickB's, not + // the bout's. The row's WinnerMemberID is derived from the winner's + // name and those picks after every merge + // (SubMatchResult.ReconcileWinnerMemberID), and the client's bout + // comparison leaves it out, so it is no change of the bout's: a + // write that echoes the row with another id (or none) has changed + // nothing. c.SideAMemberID, c.SideBMemberID, c.WinnerMemberID = "", "", "" } return &c @@ -383,7 +434,15 @@ func groupStampOf(stamps map[string]int64, modifiedAt int64, group string) int64 if stamps == nil { return modifiedAt } - if v, ok := stamps[group]; ok || group != GroupRepPicks { + if v, ok := stamps[group]; ok || !isRepPickGroup(group) { + return v + } + // A side's pick with no stamp of its own yet. A map from the commits that + // dated both picks as one group holds that group's stamp, which is BOTH + // sides' stamp until the first materialization converts it: it errs toward + // holding a pick, the recoverable direction, since a held pick is in the + // history. + if v, ok := stamps[legacyRepPicksGroup]; ok { return v } // A map written before the representatives had a group of their own dated @@ -409,11 +468,21 @@ func (bm *BracketMatch) GroupStamp(group string) int64 { func MaterializedGroupStamps(stamps map[string]int64, modifiedAt int64, positions []int) map[string]int64 { if stamps != nil { out := CloneGroupStamps(stamps) - // Fix the fallback groupStampOf reads, or the picks' date would follow - // the bout row's every later stamp. - if _, ok := out[GroupRepPicks]; !ok { - if v, ok := out[BoutGroup(DaihyosenSubPosition)]; ok { - out[GroupRepPicks] = v + // Fix the fallback groupStampOf reads, or a side's pick date would + // follow the bout row's every later stamp. A side without a key of its + // own takes the legacy group's stamp (both sides', from before each had + // its own), else the bout row's; the legacy key is dropped so it cannot + // linger in the newest-stamp scans. + legacy, hasLegacy := out[legacyRepPicksGroup] + delete(out, legacyRepPicksGroup) + for _, g := range []string{GroupRepPickA, GroupRepPickB} { + if _, ok := out[g]; ok { + continue + } + if hasLegacy { + out[g] = legacy + } else if v, ok := out[BoutGroup(DaihyosenSubPosition)]; ok { + out[g] = v } } return out @@ -424,11 +493,11 @@ func MaterializedGroupStamps(stamps map[string]int64, modifiedAt int64, position } hasRepBout := slices.Contains(positions, DaihyosenSubPosition) for _, g := range ScalarGroups { - // The representatives' group lives on the representative bout's row: + // The representatives' groups live on the representative bout's row: // a match without that row has nothing to date, so a legacy match is // not given a key it never held (a stamp a write NAMED is another // matter: stampGroups adds it itself). - if g == GroupRepPicks && !hasRepBout { + if isRepPickGroup(g) && !hasRepBout { continue } out[g] = modifiedAt diff --git a/internal/state/match_groups_table_test.go b/internal/state/match_groups_table_test.go index 7bc0950a3..262e8b152 100644 --- a/internal/state/match_groups_table_test.go +++ b/internal/state/match_groups_table_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" ) // TestMatchGroups_SharedTable is the Go half of the shared Go/JS table of @@ -149,11 +151,11 @@ func TestMergeReport_HeldDecisionRequiresTheDefaultWinStandsReason(t *testing.T) assert.Empty(t, (*MergeReport)(nil).HeldDecision(), "a nil report names no decision") } -// The two representatives of the representative bout are a change of their own -// (GroupRepPicks), apart from the bout row they sit on (bout:-1): a pick added -// or cleared differs in the picks and not in the bout, a point differs in the -// bout and not in the picks, and each copy moves only its own fields, in -// either order. +// Each side's representative of the representative bout is a change of its own +// (GroupRepPickA, GroupRepPickB), apart from the other side's and from the bout +// row they sit on (bout:-1): a pick added or cleared differs in its side's pick +// and not in the bout or the other side's, a point differs in the bout and not +// in the picks, and each copy moves only its own fields, in any order. func TestRepPicks_AreAChangeApartFromTheBout(t *testing.T) { bout := BoutGroup(DaihyosenSubPosition) row := func(a, b string, ippons ...string) SubMatchResult { @@ -162,38 +164,57 @@ func TestRepPicks_AreAChangeApartFromTheBout(t *testing.T) { } match := func(r SubMatchResult) *MatchResult { return &MatchResult{SubResults: []SubMatchResult{r}} } + // Side B's pick is a change of side B's group and of nothing else. picked, cleared, scored := match(row("", "rep-b")), match(row("", "")), match(row("", "", "M")) - assert.True(t, GroupDiffers(picked, cleared, GroupRepPicks), "a pick cleared is a change of the picks") + assert.True(t, GroupDiffers(picked, cleared, GroupRepPickB), "a side B pick cleared is a change of side B's pick") + assert.False(t, GroupDiffers(picked, cleared, GroupRepPickA), "and not of side A's") assert.False(t, GroupDiffers(picked, cleared, bout), "and not of the bout") assert.True(t, GroupDiffers(cleared, scored, bout), "a point is a change of the bout") - assert.False(t, GroupDiffers(cleared, scored, GroupRepPicks), "and not of the picks") - assert.JSONEq(t, `{"sideAMemberId":"","sideBMemberId":"rep-b"}`, string(GroupValue(picked, GroupRepPicks))) + assert.False(t, GroupDiffers(cleared, scored, GroupRepPickA), "and not of either pick") + assert.False(t, GroupDiffers(cleared, scored, GroupRepPickB)) + assert.JSONEq(t, `{"sideBMemberId":"rep-b"}`, string(GroupValue(picked, GroupRepPickB)), "a side's value holds that side's id alone") + assert.JSONEq(t, `{"sideAMemberId":""}`, string(GroupValue(picked, GroupRepPickA))) assert.JSONEq(t, `null`, string(GroupValue(&MatchResult{}, bout)), "a removed bout has no value") - assert.JSONEq(t, `{"sideAMemberId":"","sideBMemberId":""}`, string(GroupValue(&MatchResult{}, GroupRepPicks)), "no bout, no picks") + assert.JSONEq(t, `{"sideAMemberId":""}`, string(GroupValue(&MatchResult{}, GroupRepPickA)), "no bout, no picks") + assert.JSONEq(t, `{"sideBMemberId":""}`, string(GroupValue(&MatchResult{}, GroupRepPickB)), "no bout, no picks") + + // And side A's pick of side A's group alone. + pickedA := match(row("rep-a", "")) + assert.True(t, GroupDiffers(pickedA, cleared, GroupRepPickA)) + assert.False(t, GroupDiffers(pickedA, cleared, GroupRepPickB)) // Picks from one match, the bout from another: each copy takes only its own - // fields, so the result is the same in either order. - for _, picksFirst := range []bool{true, false} { - dst, stored := match(row("carol", "", "K")), match(row("dana", "", "M")) - if picksFirst { - CopyGroup(dst, stored, GroupRepPicks) - CopyGroup(dst, stored, bout) - } else { - CopyGroup(dst, stored, bout) - CopyGroup(dst, stored, GroupRepPicks) + // fields, so the result is the same in any order. + orders := [][]string{ + {GroupRepPickA, GroupRepPickB, bout}, + {bout, GroupRepPickA, GroupRepPickB}, + {GroupRepPickB, bout, GroupRepPickA}, + } + for i, order := range orders { + dst, stored := match(row("carol", "gus", "K")), match(row("dana", "hal", "M")) + for _, g := range order { + CopyGroup(dst, stored, g) } - assert.Equal(t, []string{"M"}, dst.SubResults[0].IpponsA, "picksFirst=%v", picksFirst) - assert.Equal(t, "dana", dst.SubResults[0].SideAMemberID, "picksFirst=%v", picksFirst) + assert.Equal(t, []string{"M"}, dst.SubResults[0].IpponsA, "order %d", i) + assert.Equal(t, "dana", dst.SubResults[0].SideAMemberID, "order %d", i) + assert.Equal(t, "hal", dst.SubResults[0].SideBMemberID, "order %d", i) } + // One side's copy moves only that side. + dst := match(row("carol", "gus")) + CopyGroup(dst, match(row("dana", "hal")), GroupRepPickB) + assert.Equal(t, "carol", dst.SubResults[0].SideAMemberID, "side A was not copied") + assert.Equal(t, "hal", dst.SubResults[0].SideBMemberID) // And a bout copy alone leaves the picks where they are. - dst := match(row("carol", "", "K")) - CopyGroup(dst, match(row("dana", "", "M")), bout) + dst = match(row("carol", "gus", "K")) + CopyGroup(dst, match(row("dana", "hal", "M")), bout) assert.Equal(t, "carol", dst.SubResults[0].SideAMemberID, "the bout's copy leaves the picks alone") + assert.Equal(t, "gus", dst.SubResults[0].SideBMemberID) // A copy of the bout onto a match with no such row brings the row whole, - // picks included; a copy of the picks onto no row lands nowhere. + // picks included; a copy of a pick onto no row lands nowhere. empty := &MatchResult{} - CopyGroup(empty, picked, GroupRepPicks) + CopyGroup(empty, picked, GroupRepPickA) + CopyGroup(empty, picked, GroupRepPickB) assert.Empty(t, empty.SubResults, "a pick has no bout to land on") CopyGroup(empty, picked, bout) require.Len(t, empty.SubResults, 1) @@ -201,51 +222,95 @@ func TestRepPicks_AreAChangeApartFromTheBout(t *testing.T) { // A removal removes the row, picks and all. CopyGroup(empty, &MatchResult{}, bout) assert.Empty(t, empty.SubResults) + + // RepPickGroup names a side's group, and no other value has one. + assert.Equal(t, GroupRepPickA, RepPickGroup(domain.MatchSideA)) + assert.Equal(t, GroupRepPickB, RepPickGroup(domain.MatchSideB)) + assert.Empty(t, RepPickGroup(domain.MatchSideNone)) } -// A match written before the picks had a stamp of their own dates them with -// the bout row they sit on, and that date is fixed the first time the map is -// materialized, so a later stamp of the bout does not move it. +// A match written before the picks had a stamp of their own dates each side's +// pick with the bout row they sit on, and that date is fixed the first time the +// map is materialized, so a later stamp of the bout does not move it. func TestRepPicks_LegacyStampFollowsTheBoutOnceThenStandsAlone(t *testing.T) { bout := BoutGroup(DaihyosenSubPosition) m := &MatchResult{ModifiedAt: 50, GroupStamps: map[string]int64{bout: 40, GroupPoints: 50}} - assert.Equal(t, int64(40), m.GroupStamp(GroupRepPicks), "no entry yet: the bout row's date") - assert.Equal(t, int64(0), (&MatchResult{GroupStamps: map[string]int64{}}).GroupStamp(GroupRepPicks), "never written") + for _, g := range []string{GroupRepPickA, GroupRepPickB} { + assert.Equal(t, int64(40), m.GroupStamp(g), "%s: no entry yet: the bout row's date", g) + assert.Equal(t, int64(0), (&MatchResult{GroupStamps: map[string]int64{}}).GroupStamp(g), "%s: never written", g) + } m.StampGroups(90, bout) assert.Equal(t, int64(90), m.GroupStamp(bout)) - assert.Equal(t, int64(40), m.GroupStamp(GroupRepPicks), "the point did not date the picks") + for _, g := range []string{GroupRepPickA, GroupRepPickB} { + assert.Equal(t, int64(40), m.GroupStamp(g), "%s: the point did not date the picks", g) + } + + // Stamping one side's pick leaves the other on the bout's old date. + m.StampGroups(95, GroupRepPickA) + assert.Equal(t, int64(95), m.GroupStamp(GroupRepPickA)) + assert.Equal(t, int64(40), m.GroupStamp(GroupRepPickB), "the other side is not dated by it") legacy := &MatchResult{ModifiedAt: 30, SubResults: []SubMatchResult{{Position: DaihyosenSubPosition}}} - assert.Equal(t, int64(30), legacy.GroupStamp(GroupRepPicks), "a match with no map reads ModifiedAt") + for _, g := range []string{GroupRepPickA, GroupRepPickB} { + assert.Equal(t, int64(30), legacy.GroupStamp(g), "%s: a match with no map reads ModifiedAt", g) + } legacy.StampGroups(60, bout) - assert.Equal(t, int64(30), legacy.GroupStamp(GroupRepPicks), "materialized with every other group") + for _, g := range []string{GroupRepPickA, GroupRepPickB} { + assert.Equal(t, int64(30), legacy.GroupStamp(g), "%s: materialized with every other group", g) + } +} + +// A stamp map from this branch's earlier commits dated both picks as one group, +// "repPicks". It reads as BOTH sides' stamp (over the bout row's, which it +// outranks as the picks' own date), and the first materialization converts it to +// a key per side and drops it, so it cannot linger in the newest-stamp scans. +func TestRepPicks_DevStampOfBothPicksIsConvertedPerSide(t *testing.T) { + bout := BoutGroup(DaihyosenSubPosition) + m := &MatchResult{ModifiedAt: 90, GroupStamps: map[string]int64{"repPicks": 40, bout: 90}} + assert.Equal(t, int64(40), m.GroupStamp(GroupRepPickA), "both sides read the legacy stamp") + assert.Equal(t, int64(40), m.GroupStamp(GroupRepPickB)) + + m.StampGroups(100, GroupRepPickA) + assert.Equal(t, int64(100), m.GroupStamps[GroupRepPickA]) + assert.Equal(t, int64(40), m.GroupStamps[GroupRepPickB], "side B keeps the legacy date") + assert.NotContains(t, m.GroupStamps, "repPicks", "converted, not left to linger") + assert.Equal(t, int64(90), m.GroupStamps[bout]) + + // A side with a key of its own is not overridden by a legacy key beside it. + both := &MatchResult{GroupStamps: map[string]int64{"repPicks": 40, GroupRepPickB: 70}} + assert.Equal(t, int64(40), both.GroupStamp(GroupRepPickA)) + assert.Equal(t, int64(70), both.GroupStamp(GroupRepPickB)) + assert.NotContains(t, MaterializedGroupStamps(both.GroupStamps, 0, nil), "repPicks") } -// A legacy (map-less) match is given a repPicks entry when it materializes only +// A legacy (map-less) match is given the pick entries when it materializes only // if it holds a representative bout row: the picks live on that row, so a match // without one has nothing to date and gains no key it never held. func TestMaterializedGroupStamps_RepPicksOnlyWithARepresentativeRow(t *testing.T) { - t.Run("a match without the row gets every other scalar group, not repPicks", func(t *testing.T) { + t.Run("a match without the row gets every other scalar group, not the pick groups", func(t *testing.T) { got := MaterializedGroupStamps(nil, 70, []int{1, 2}) - assert.NotContains(t, got, GroupRepPicks) + assert.NotContains(t, got, GroupRepPickA) + assert.NotContains(t, got, GroupRepPickB) for _, g := range ScalarGroups { - if g != GroupRepPicks { + if g != GroupRepPickA && g != GroupRepPickB { assert.Equal(t, int64(70), got[g], g) } } assert.Equal(t, int64(70), got[BoutGroup(1)]) assert.Equal(t, int64(70), got[BoutGroup(2)]) }) - t.Run("a match with the row gets repPicks at its ModifiedAt", func(t *testing.T) { + t.Run("a match with the row gets both pick groups at its ModifiedAt", func(t *testing.T) { got := MaterializedGroupStamps(nil, 70, []int{1, DaihyosenSubPosition}) - assert.Equal(t, int64(70), got[GroupRepPicks]) + assert.Equal(t, int64(70), got[GroupRepPickA]) + assert.Equal(t, int64(70), got[GroupRepPickB]) assert.Equal(t, int64(70), got[BoutGroup(DaihyosenSubPosition)]) }) t.Run("a stamp a write named is kept on a match with no row", func(t *testing.T) { m := &MatchResult{ModifiedAt: 70} - m.StampGroups(90, GroupRepPicks) - assert.Equal(t, int64(90), m.GroupStamps[GroupRepPicks], "naming the group stamps it, row or not") - assert.Equal(t, int64(90), m.GroupStamp(GroupRepPicks)) + m.StampGroups(90, GroupRepPickA) + assert.Equal(t, int64(90), m.GroupStamps[GroupRepPickA], "naming the group stamps it, row or not") + assert.Equal(t, int64(90), m.GroupStamp(GroupRepPickA)) + assert.NotContains(t, m.GroupStamps, GroupRepPickB, "and only that side") }) } diff --git a/internal/state/models.go b/internal/state/models.go index e965293c8..f1e11769c 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1433,8 +1433,9 @@ func (s *SubMatchResult) ResolveMemberWinnerID() bool { // ReconcileWinnerMemberID keeps a representative-bout row's WinnerMemberID // consistent with the two representatives it names: WinnerMemberID belongs to -// the bout's result and the representatives to their own change -// (GroupRepPicks), so a merge can leave a winner id that disagrees with them. +// the bout's result and each representative to its own change +// (GroupRepPickA, GroupRepPickB), so a merge can leave a winner id that +// disagrees with them. // The id is always cleared and derived again by ResolveMemberWinnerID, from // the winner's NAME alone and the side's stored pick, so afterwards it is // exactly the pick of the side the Winner name names, or empty. It is empty diff --git a/internal/state/models_test.go b/internal/state/models_test.go index 1660779d4..f0c2021d7 100644 --- a/internal/state/models_test.go +++ b/internal/state/models_test.go @@ -221,7 +221,7 @@ func TestSubMatchResult_ResolveMemberWinnerID(t *testing.T) { } // TestSubMatchResult_ReconcileWinnerMemberID pins the representative-bout rule -// (bc-mrgc, GroupRepPicks): after a merge, WinnerMemberID is EXACTLY the stored +// (bc-mrgc, GroupRepPickA and GroupRepPickB): after a merge, WinnerMemberID is EXACTLY the stored // pick of the side the Winner NAME names, or empty. It is derived from the name // alone and never decided through the stored id (SubBoutWinnerSide and // Attribution read WinnerMemberID first, so a stale id would decide its own diff --git a/internal/state/testdata/match_groups.json b/internal/state/testdata/match_groups.json index 8d83e309b..34d8d4a94 100644 --- a/internal/state/testdata/match_groups.json +++ b/internal/state/testdata/match_groups.json @@ -1,11 +1,11 @@ { - "_comment": "Shared Go/JS table of the groups a match write is merged by (bc-mrgc). Go half: TestMatchGroups_SharedTable (internal/state/match_groups_table_test.go) against state.ScalarGroups (repPicks: the two representatives of the representative bout, a change of their own, dated apart from bout:-1), BoutGroup, ParseBoutGroup and ValidGroup. JS half: web-mobile/js/__tests__/match_groups_table.test.jsx against match_groups.jsx's SCALAR_GROUPS, boutGroup, parseBoutGroup and isValidGroup. A client write names its groups by these exact strings in `changed`, and the server refuses a name it does not know, so the two lists must never drift.", - "scalarGroups": ["points", "result", "encho", "flags", "rep", "repPicks"], + "_comment": "Shared Go/JS table of the groups a match write is merged by (bc-mrgc). Go half: TestMatchGroups_SharedTable (internal/state/match_groups_table_test.go) against state.ScalarGroups (repPickA and repPickB: each side's representative of the representative bout, a change of its own, dated apart from the other side's and from bout:-1), BoutGroup, ParseBoutGroup and ValidGroup. JS half: web-mobile/js/__tests__/match_groups_table.test.jsx against match_groups.jsx's SCALAR_GROUPS, boutGroup, parseBoutGroup and isValidGroup. A client write names its groups by these exact strings in `changed`, and the server refuses a name it does not know, so the two lists must never drift.", + "scalarGroups": ["points", "result", "encho", "flags", "rep", "repPickA", "repPickB"], "bouts": [ { "position": 1, "group": "bout:1" }, { "position": 2, "group": "bout:2" }, { "position": 9, "group": "bout:9" }, { "position": -1, "group": "bout:-1" } ], - "invalid": ["", "score", "points ", "Bout:1", "bout:", "bout:x", "bout:1.5"] + "invalid": ["", "score", "points ", "Bout:1", "bout:", "bout:x", "bout:1.5", "repPicks"] } diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 8fa5cc48e..e59d7e922 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -53,11 +53,12 @@ info: which may take its winner from that row and is refused with 409 `no_daihyosen`; a write listing two is refused with 400 `duplicate_daihyosen`, a representative pick that names a member the side's team does not hold is refused with 400 `team_member_not_in_team`. The row's winner member id - is not judged: the merge keeps it naming one of the two stored representatives. A write stamped - before the stored representatives is not judged for its picks: the merge holds the picks, which - are not written and are kept in the match's history. The rest of the write is ordered by its own - stamps, so it is applied and answered with `heldGroups: ["repPicks"]`; it is answered as - superseded only when every group it changes is held. Nor does it remove one: a write listing + is not judged: the merge keeps it naming one of the two stored representatives. A write + stamped before a stored representative is not judged for that side's pick: the merge holds + the pick, which is not written and is kept in the match's history. The rest of the write is + ordered by its own stamps, so it is applied and answered with `heldGroups` naming the held + side's group, e.g. `["repPickA"]`; it is answered as superseded only when every group it + changes is held. Nor does it remove one: a write listing the bouts without the representative bout the match has (a sheet a moment behind an add made elsewhere) keeps the stored row, scored or not, unless it finishes the match, which is refused with 409 `daihyosen_added`. It carries only what the competitors saw: a match @@ -2490,13 +2491,14 @@ paths: `message` is "The representative chosen is not on this team. Pick again from the list." The row's winner member id is not judged: the merge keeps it naming one of the two stored representatives, or empty. - A write whose stamp is strictly before the stored representatives is not - judged for its picks: the merge holds them, so the picks are not - written and are kept in the match's history (an equal stamp applies, + A write whose stamp is strictly before a stored representative is not + judged for that side's pick: the merge holds the pick, so it is not + written and is kept in the match's history (an equal stamp applies, so it is judged). The rest of the write is ordered by its own stamps, - so it is applied and answered with `heldGroups: ["repPicks"]`; it is - answered as superseded, with nothing written, only when every group - it changes is held. The organiser's picks are not checked. + so it is applied and answered with `heldGroups` naming the held side's + group, e.g. `["repPickA"]`; it is answered as superseded, with nothing + written, only when every group it changes is held. The organiser's + picks are not checked. A `completed` write whose `decision` is empty or `hikiwake` on a match whose stored result is a completed withdrawal (kiken or @@ -5400,17 +5402,18 @@ components: decision, decisionBy, decisionReason, and the correctionReason and resultSource that describe it); `encho` the match-level overtime; `flags` the engi flag counts; `rep` the pool representative players; - `repPicks` the two representatives of a team match's representative - bout (the member ids `sideAMemberId` and `sideBMemberId` on its `-1` - row), a change of their own that a point struck on the bout never - alters and that never alters the bout's score; and `bout:` + `repPickA` and `repPickB` the two representatives of a team match's + representative bout (the member ids `sideAMemberId` and `sideBMemberId` + on its `-1` row), one group per side: each is a change of its own, + dated apart from the other side's, that a point struck on the bout + never alters and that never alters the bout's score; and `bout:` one bout row (1..n, or -1 for the representative bout, without the two representatives or the winner's member id, which the server derives). The sides, court, schedule and round are not groups. A write names the groups it changes in `changed`; the match records the stamp of the last applied change to each in `groupStamps`. - pattern: '^(points|result|encho|flags|rep|repPicks|bout:-?[0-9]+)$' + pattern: '^(points|result|encho|flags|rep|repPickA|repPickB|bout:-?[0-9]+)$' DaihyosenWriteStamp: type: object diff --git a/web-mobile/js/__tests__/match_groups_table.test.jsx b/web-mobile/js/__tests__/match_groups_table.test.jsx index 4b1876400..ec4078137 100644 --- a/web-mobile/js/__tests__/match_groups_table.test.jsx +++ b/web-mobile/js/__tests__/match_groups_table.test.jsx @@ -114,7 +114,8 @@ describe('groupLabel', () => { ['encho', 'overtime'], ['flags', 'flags'], ['rep', 'the representative players'], - ['repPicks', 'the fighters picked for the representative bout'], + ['repPickA', 'Aka\'s pick for the representative bout'], + ['repPickB', 'Shiro\'s pick for the representative bout'], ['bout:2', 'bout 2'], ['bout:-1', 'the representative bout'], ])('%s reads "%s"', (g, words) => { @@ -155,27 +156,47 @@ describe('the serializer', () => { }); // The two representatives of the representative bout (the member ids on the -1 row) -// are a change of their own, `repPicks`, dated apart from the bout row they sit on. +// are a change of their own for each side, `repPickA` (Aka) and `repPickB` (Shiro), each +// dated apart from the other side's and from the bout row they sit on. // A pick never alters the score and a point never alters the pick, so a write names // the one it changed and not the other. Numbered rows keep the rule they always had. -describe('the representatives of the representative bout (repPicks)', () => { +describe('the representatives of the representative bout (repPickA, repPickB)', () => { const bout = (position, extra = {}) => ({ position, sideA: 'Team A', sideB: 'Team B', ipponsA: [], ipponsB: [], winner: '', decision: 'daihyosen', ...extra, }); - it('a representative pick cleared (key omitted) names repPicks and not bout:-1', () => { + it('a side B pick cleared (key omitted) names repPickB and not bout:-1 or repPickA', () => { const stored = { subResults: [bout(-1, { sideBMemberId: 'm1b' })] }; const next = { subResults: [bout(-1)] }; - expect(changedGroups(next, stored)).toEqual(['repPicks']); + expect(changedGroups(next, stored)).toEqual(['repPickB']); }); - it('a representative pick added names repPicks and not bout:-1', () => { + it('a side B pick added names repPickB and not bout:-1 or repPickA', () => { const stored = { subResults: [bout(-1)] }; const next = { subResults: [bout(-1, { sideBMemberId: 'm1b' })] }; - expect(changedGroups(next, stored)).toEqual(['repPicks']); + expect(changedGroups(next, stored)).toEqual(['repPickB']); }); - it('a point struck on the representative bout names bout:-1 and not repPicks, whatever ids the row carries', () => { + it('a side A pick names repPickA alone: a side B pick the write leaves out is not a change', () => { + // The two-captain race: this device never saw side B's pick, so its write carries side A only. + const stored = { subResults: [bout(-1)] }; + const next = { subResults: [bout(-1, { sideAMemberId: 'm1a' })] }; + expect(changedGroups(next, stored)).toEqual(['repPickA']); + }); + + it('a side A pick leaves a side B pick the editor kept unnamed', () => { + const stored = { subResults: [bout(-1, { sideBMemberId: 'm1b' })] }; + const next = { subResults: [bout(-1, { sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; + expect(changedGroups(next, stored)).toEqual(['repPickA']); + }); + + it('a side B pick cleared leaves a side A pick the editor kept unnamed', () => { + const stored = { subResults: [bout(-1, { sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; + const next = { subResults: [bout(-1, { sideAMemberId: 'm1a' })] }; + expect(changedGroups(next, stored)).toEqual(['repPickB']); + }); + + it('a point struck on the representative bout names bout:-1 and neither pick, whatever ids the row carries', () => { // A board that never saw the pick holds the row without it, and sends it so. expect(changedGroups({ subResults: [bout(-1, { ipponsA: ['M'] })] }, { subResults: [bout(-1)] })).toEqual(['bout:-1']); // A board that did see it holds it and sends it back. @@ -183,21 +204,21 @@ describe('the representatives of the representative bout (repPicks)', () => { expect(changedGroups({ subResults: [bout(-1, { ipponsA: ['M'], sideAMemberId: 'm1a' })] }, seen)).toEqual(['bout:-1']); }); - it('a pick and a point together name both', () => { + it('a side B pick and a point together name both', () => { const stored = { subResults: [bout(-1)] }; const next = { subResults: [bout(-1, { ipponsA: ['M'], sideBMemberId: 'm1b' })] }; - expect(changedGroups(next, stored)).toEqual(['repPicks', 'bout:-1']); + expect(changedGroups(next, stored)).toEqual(['repPickB', 'bout:-1']); }); - it('a decided representative bout whose pick is swapped names repPicks and not bout:-1', () => { + it('a decided representative bout whose side A pick is swapped names repPickA and not bout:-1 or repPickB', () => { // The server re-derives the winner's member id from the stored picks, so a swap that // moves it moves no bout: the score and the winner stand. const stored = { subResults: [bout(-1, { ipponsA: ['M'], winner: 'Team A', winnerMemberId: 'm1a', sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; const next = { subResults: [bout(-1, { ipponsA: ['M'], winner: 'Team A', winnerMemberId: 'm2a', sideAMemberId: 'm2a', sideBMemberId: 'm1b' })] }; - expect(changedGroups(next, stored)).toEqual(['repPicks']); + expect(changedGroups(next, stored)).toEqual(['repPickA']); }); - it('a decided representative bout whose winning side changes names bout:-1, and not repPicks when the picks stand', () => { + it('a decided representative bout whose winning side changes names bout:-1, and neither pick when the picks stand', () => { const stored = { subResults: [bout(-1, { winner: 'Team A', winnerMemberId: 'm1a', sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; const next = { subResults: [bout(-1, { winner: 'Team B', winnerMemberId: 'm1b', sideAMemberId: 'm1a', sideBMemberId: 'm1b' })] }; expect(changedGroups(next, stored)).toEqual(['bout:-1']); diff --git a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx index 547388903..7dd68811c 100644 --- a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx +++ b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx @@ -155,8 +155,12 @@ describe('heldValueText', () => { ['result', { status: 'completed', decision: 'fusensho', decisionBy: 'shiro' }, 'Fusensho against Shiro'], ['result', { status: 'completed', winner: 'Tanaka' }, 'Tanaka won'], ['bout:-1', null, 'no bout'], - ['repPicks', { sideAMemberId: 'm1a', sideBMemberId: '' }, 'Shiro not picked, Aka picked'], - ['repPicks', null, 'not picked'], + ['repPickA', { sideAMemberId: 'm1a' }, 'picked'], + ['repPickA', { sideAMemberId: '' }, 'not picked'], + ['repPickB', { sideBMemberId: 'm1b' }, 'picked'], + ['repPickB', { sideBMemberId: '' }, 'not picked'], + ['repPickA', null, 'not picked'], + ['repPickB', null, 'not picked'], ])('%s %j reads "%s"', (group, value, words) => { expect(view.heldValueText(group, value)).toBe(words); }); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 9282f8b9e..359b4a38c 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -150,7 +150,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dhInput('AKA').value).toBe(''); }); - it('R2: picking a member records its id on the -1 row, keeps the team names, and names repPicks', async () => { + it('R2: picking a member records its id on the -1 row, keeps the team names, and names repPickB', async () => { const match = makeMatch({ subResults: [DH_EMPTY] }); const { onSubmit } = await mount(match); await pickFromDh('SHIRO', 'Carol'); @@ -166,8 +166,9 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const wireDh = wire.subResults.find((s) => s.position === -1); expect(wireDh.sideBMemberId).toBe('m1b'); expect(wireDh.sideB).toBe('Team B'); - expect(wire.changed).toContain('repPicks'); + expect(wire.changed).toContain('repPickB'); expect(wire.changed, 'a pick is not a change of the bout').not.toContain('bout:-1'); + expect(wire.changed, 'Aka\'s pick was not touched here').not.toContain('repPickA'); // A pick is never a lineup write: "daihyosen" is not a lineup key. expect(window.API.putMatchLineup).not.toHaveBeenCalled(); }); @@ -194,7 +195,7 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dh.sideB).toBe('Team B'); }); - it('R4: clearing a stored pick omits the id and names repPicks on the next write', async () => { + it('R4: clearing a stored pick omits the id and names repPickB on the next write', async () => { const match = makeMatch({ subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b' }] }); const { onSubmit } = await mount(match); // The stored pick shows as the member's own name, never the team's. @@ -208,8 +209,9 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect('sideBMemberId' in dh).toBe(false); expect(dh.sideB).toBe('Team B'); const cleared = toBackendMatchResult(patch, match).changed; - expect(cleared).toContain('repPicks'); + expect(cleared).toContain('repPickB'); expect(cleared).not.toContain('bout:-1'); + expect(cleared, 'Aka\'s pick was never set here').not.toContain('repPickA'); }); it('R5: the representative roster includes a member already placed at a numbered position', async () => { @@ -615,7 +617,8 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(dh.ipponsA).toEqual(['M']); expect(dh.sideAMemberId).toBe('m1a'); expect(patch.changed).toContain('bout:-1'); - expect(patch.changed).toContain('repPicks'); + expect(patch.changed).toContain('repPickA'); + expect(patch.changed, 'Shiro\'s side was not picked here').not.toContain('repPickB'); }); it('R27: a pick adopted from another device and then cleared here is named in the next write', async () => { @@ -630,10 +633,11 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await clickFinishTwice(); const patch = finishPatchOf(onSubmit); expect('sideAMemberId' in dhEntryOf(patch)).toBe(false); - expect(toBackendMatchResult(patch, match).changed, 'the server keeps the other device\'s pick unless repPicks is named').toContain('repPicks'); + expect(toBackendMatchResult(patch, match).changed, 'the server keeps the other device\'s pick unless repPickA is named').toContain('repPickA'); + expect(toBackendMatchResult(patch, match).changed, 'Shiro\'s pick was not touched here').not.toContain('repPickB'); }); - it('R28: a pick adopted for one side does not hide the pick kept on the other: the next write still names repPicks and carries both', async () => { + it('R28: a pick adopted for one side does not hide the pick kept on the other: the next write names only the side picked here and carries both', async () => { const { onSubmit, rerenderWith } = await mount(makeMatch({ subResults: [DH_EMPTY] })); await pickFromDh('AKA', 'Alice'); // Another device picks Carol for Shiro; Alice has not landed. @@ -645,7 +649,8 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { const dh = dhEntryOf(patch); expect(dh.sideAMemberId).toBe('m1a'); expect(dh.sideBMemberId).toBe('m1b'); - expect(patch.changed).toContain('repPicks'); + expect(patch.changed).toContain('repPickA'); + expect(patch.changed, 'the side adopted from the server is not a change').not.toContain('repPickB'); }); it('R29: a pick made here and not yet written is dropped when its side is given another team: nothing is sent for it, and the sheet is not dirty', async () => { @@ -661,7 +666,8 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await pastDebounce(); for (const [patch] of onSubmit.mock.calls) { expect('sideAMemberId' in (dhEntryOf(patch) || {}), 'the dropped pick is not sent').toBe(false); - expect(patch.changed || [], 'the picks did not change').not.toContain('repPicks'); + expect(patch.changed || [], 'the picks did not change').not.toContain('repPickA'); + expect(patch.changed || [], 'the picks did not change').not.toContain('repPickB'); } await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); expect(window.confirmDialog).not.toHaveBeenCalled(); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 810affd32..6bb466791 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -51,7 +51,7 @@ import { isOlderRunningCopy } from './patch.jsx'; import { useDebouncedRunningWrite, SyncStatusPill, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx'; import { MatchHistoryDisclosure } from './match_history_view.jsx'; -import { GROUP_REP_PICKS } from './match_groups.jsx'; +import { GROUP_REP_PICK_A, GROUP_REP_PICK_B } from './match_groups.jsx'; import { serverNowMs } from './server_clock.jsx'; import { publishHeight } from './published_height.jsx'; import { SideLabel, sideWithColour } from './side_cell.jsx'; @@ -3255,14 +3255,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // 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 the picks' own group: the write - // carries that row, so statedGroups adds repPicks (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 the picks - // as their own group, so an id left off the row clears the stored one only - // when repPicks 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. + // 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; @@ -3706,9 +3706,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub }); // A pick taken from the server is one this editor now agrees with, so an edit // that puts it back to what the editor mounted with (clearing a pick another - // device made) is still a change of the picks and is named. A side kept is the - // operator's own edit and stays one; both sides kept says nothing. - if (repTakes && (repTakes.a || repTakes.b)) claimChanged.agree(GROUP_REP_PICKS); + // device made) is still a change of that side's pick and is named. A side kept + // is the operator's own edit and stays one, so each side is agreed on its own. + 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 diff --git a/web-mobile/js/match_groups.jsx b/web-mobile/js/match_groups.jsx index e288ce058..26e40bda4 100644 --- a/web-mobile/js/match_groups.jsx +++ b/web-mobile/js/match_groups.jsx @@ -3,7 +3,7 @@ // events must be ordered."). // // The server merges a score write group by group (internal/state/ -// match_groups.go): points, result, encho, flags, rep, repPicks, and one group +// 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 @@ -36,13 +36,14 @@ 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): a change of their own, dated apart 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_PICKS = 'repPicks'; +// 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, GROUP_REP_PICKS]; +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:'; @@ -86,7 +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_PICKS: return 'the fighters picked for the representative bout'; + 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 || ''); @@ -191,8 +193,9 @@ function statedGroups(next) { 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 them (an id left off the row is a pick cleared). - if (rowAt(next, -1)) out.push(GROUP_REP_PICKS); + // 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; @@ -229,12 +232,16 @@ function groupKey(wire, group, next) { has(next, 'repPlayerA') ? str(w.repPlayerA) : null, has(next, 'repPlayerB') ? str(w.repPlayerB) : null, ]); - case GROUP_REP_PICKS: { - // The server moves the two ids as one change and copies the row's - // picks exactly as sent, so an absent id reads as empty: a pick - // cleared leaves its key out, and must still be named. + 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 JSON.stringify([str(row.sideAMemberId), str(row.sideBMemberId)]); + return str(row.sideAMemberId); + } + case GROUP_REP_PICK_B: { + const row = rowAt(w, -1) || {}; + return str(row.sideBMemberId); } default: { const pos = parseBoutGroup(group); @@ -242,13 +249,13 @@ function groupKey(wire, group, next) { 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_PICKS' (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. Numbered rows keep - // the rule above. + // 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. Numbered rows keep the rule above. if (pos < 0) { keys.delete('sideAMemberId'); keys.delete('sideBMemberId'); diff --git a/web-mobile/js/match_history_view.jsx b/web-mobile/js/match_history_view.jsx index d59578aff..b78c5fcc8 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, GROUP_REP_PICKS } 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; @@ -112,9 +112,11 @@ 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_PICKS: - // The values are member ids, not names: say whether each side was picked, not who. - return v ? `Shiro ${v.sideBMemberId ? 'picked' : 'not picked'}, Aka ${v.sideAMemberId ? 'picked' : 'not picked'}` : 'not picked'; + case GROUP_REP_PICK_A: + // The value is a member id, not a name: say whether the side was picked, not who. + return v && v.sideAMemberId ? 'picked' : 'not picked'; + case GROUP_REP_PICK_B: + return v && v.sideBMemberId ? 'picked' : 'not picked'; default: if (parseBoutGroup(group) === null) return ''; return v ? `${scorelineText(v)}${foulsText(v)}` : 'no bout'; From 0329bdabeb689f7daf999baeaa92a40b2c683d70 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 03:33:52 +0100 Subject: [PATCH 25/38] chore: drop the dead repPicks stamp migration; correct the re-seat note The repPicks stamp conversion only ever read data this PR's own earlier commits wrote (no release or main carried the group), so it is removed with its two tests; the bout:-1 fallback, the real migration for maps written by v2.1.1 and v2.2.0, stays. The retired name stays in the shared table's invalid list, which pins a client naming it being refused. data-model.md: a side taken back to its placeholder loses its pick even when the same team is seated again; only a side whose team never changes keeps it. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/data-model.md | 6 ++-- internal/engine/match_merge_rep_picks_test.go | 29 ------------------- internal/state/match_groups.go | 25 ++-------------- internal/state/match_groups_table_test.go | 23 --------------- 4 files changed, 6 insertions(+), 77 deletions(-) diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 381f1fbe0..065b52822 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -396,8 +396,10 @@ is a member of the team seated on its side, so when a knockout match is given an 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 given the same team -again, or only renamed, keeps its pick. +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 diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index 3f6d449de..892bb694c 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -316,35 +316,6 @@ func TestMerge_RepPicks_LegacyStampFallsBackToTheBout(t *testing.T) { assert.Equal(t, mmT2, got.GroupStamp(repPickBName), "and keeps its date") } -// A stamp map from this branch's earlier commits dated both picks as one group, -// "repPicks". It reads as BOTH sides' stamp and is converted, and dropped, by the -// first write that materializes the map (it errs toward holding a pick, which -// is kept in the history). -func TestMerge_RepPicks_DevStampOfBothPicksIsReadForEachSide(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 = mmT3 - bm.GroupStamps = map[string]int64{repBoutGroup: mmT3, "repPicks": mmT2} - require.NoError(t, h.store.SaveBracket(h.compID, bracket)) - - 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) - assert.Equal(t, []string{repPickAName}, HeldGroupsOf(err)) - - require.NoError(t, h.write(repWrite(h, mmT4, repRow("dana", "gus", nil, nil), repPickAName))) - got := h.load(t) - assert.Equal(t, "dana", got.SubResults[0].SideAMemberID) - assert.Equal(t, mmT4, got.GroupStamp(repPickAName)) - assert.Equal(t, mmT2, got.GroupStamp(repPickBName), "side B keeps the legacy date") - assert.NotContains(t, got.GroupStamps, "repPicks", "converted, not left to linger") -} - // 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 diff --git a/internal/state/match_groups.go b/internal/state/match_groups.go index 11eb0610b..31b5d31f1 100644 --- a/internal/state/match_groups.go +++ b/internal/state/match_groups.go @@ -62,13 +62,6 @@ const ( GroupRepPickA = "repPickA" GroupRepPickB = "repPickB" - // legacyRepPicksGroup is the one group both picks were dated under before - // each side had its own. Nothing is written under it any more; a stamp map - // from the commits that introduced the picks may still hold it, and is read - // as BOTH sides' stamp until the first materialization converts it - // (groupStampOf, MaterializedGroupStamps). - legacyRepPicksGroup = "repPicks" - boutGroupPrefix = "bout:" ) @@ -437,14 +430,6 @@ func groupStampOf(stamps map[string]int64, modifiedAt int64, group string) int64 if v, ok := stamps[group]; ok || !isRepPickGroup(group) { return v } - // A side's pick with no stamp of its own yet. A map from the commits that - // dated both picks as one group holds that group's stamp, which is BOTH - // sides' stamp until the first materialization converts it: it errs toward - // holding a pick, the recoverable direction, since a held pick is in the - // history. - if v, ok := stamps[legacyRepPicksGroup]; ok { - return v - } // A map written before the representatives had a group of their own dated // them with the bout row they sit on: until they are stamped themselves, // that is their date. @@ -470,18 +455,12 @@ func MaterializedGroupStamps(stamps map[string]int64, modifiedAt int64, position out := CloneGroupStamps(stamps) // Fix the fallback groupStampOf reads, or a side's pick date would // follow the bout row's every later stamp. A side without a key of its - // own takes the legacy group's stamp (both sides', from before each had - // its own), else the bout row's; the legacy key is dropped so it cannot - // linger in the newest-stamp scans. - legacy, hasLegacy := out[legacyRepPicksGroup] - delete(out, legacyRepPicksGroup) + // own takes the bout row's stamp. for _, g := range []string{GroupRepPickA, GroupRepPickB} { if _, ok := out[g]; ok { continue } - if hasLegacy { - out[g] = legacy - } else if v, ok := out[BoutGroup(DaihyosenSubPosition)]; ok { + if v, ok := out[BoutGroup(DaihyosenSubPosition)]; ok { out[g] = v } } diff --git a/internal/state/match_groups_table_test.go b/internal/state/match_groups_table_test.go index 262e8b152..1f76e1e95 100644 --- a/internal/state/match_groups_table_test.go +++ b/internal/state/match_groups_table_test.go @@ -261,29 +261,6 @@ func TestRepPicks_LegacyStampFollowsTheBoutOnceThenStandsAlone(t *testing.T) { } } -// A stamp map from this branch's earlier commits dated both picks as one group, -// "repPicks". It reads as BOTH sides' stamp (over the bout row's, which it -// outranks as the picks' own date), and the first materialization converts it to -// a key per side and drops it, so it cannot linger in the newest-stamp scans. -func TestRepPicks_DevStampOfBothPicksIsConvertedPerSide(t *testing.T) { - bout := BoutGroup(DaihyosenSubPosition) - m := &MatchResult{ModifiedAt: 90, GroupStamps: map[string]int64{"repPicks": 40, bout: 90}} - assert.Equal(t, int64(40), m.GroupStamp(GroupRepPickA), "both sides read the legacy stamp") - assert.Equal(t, int64(40), m.GroupStamp(GroupRepPickB)) - - m.StampGroups(100, GroupRepPickA) - assert.Equal(t, int64(100), m.GroupStamps[GroupRepPickA]) - assert.Equal(t, int64(40), m.GroupStamps[GroupRepPickB], "side B keeps the legacy date") - assert.NotContains(t, m.GroupStamps, "repPicks", "converted, not left to linger") - assert.Equal(t, int64(90), m.GroupStamps[bout]) - - // A side with a key of its own is not overridden by a legacy key beside it. - both := &MatchResult{GroupStamps: map[string]int64{"repPicks": 40, GroupRepPickB: 70}} - assert.Equal(t, int64(40), both.GroupStamp(GroupRepPickA)) - assert.Equal(t, int64(70), both.GroupStamp(GroupRepPickB)) - assert.NotContains(t, MaterializedGroupStamps(both.GroupStamps, 0, nil), "repPicks") -} - // A legacy (map-less) match is given the pick entries when it materializes only // if it holds a representative bout row: the picks live on that row, so a match // without one has nothing to date and gains no key it never held. From 899e61487f8221ceaf8d563731c2621a034eb39c Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 04:07:20 +0100 Subject: [PATCH 26/38] fix: a representative pick no longer re-sends the bout's score Found in a real-browser test: picking a representative on a representative bout nobody had scored sent changed ["repPickB", "bout:-1"], so a device that had not yet seen a point struck elsewhere could erase it just by picking. Two causes: the sheet's silence check counted the pick as touching the bout (it now compares the row without the picks, withoutRepPicks), and the client's bout:-1 comparison read the row's side names, which the editor restates and the server stores empty when the bout is added (they are the match's sides, not the bout's, and are now left out like the member ids). A pick-only write now names its side's pick group alone and leaves the scoreline unstated; a point struck on the bout still names bout:-1. Co-Authored-By: Claude Opus 5.5 --- .../js/__tests__/match_groups_table.test.jsx | 14 ++++++++ ...m_daihyosen_representative.render.test.jsx | 35 +++++++++++++++++++ web-mobile/js/admin_scoring_team.jsx | 21 ++++++++--- web-mobile/js/match_groups.jsx | 9 ++++- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/web-mobile/js/__tests__/match_groups_table.test.jsx b/web-mobile/js/__tests__/match_groups_table.test.jsx index ec4078137..7a9449270 100644 --- a/web-mobile/js/__tests__/match_groups_table.test.jsx +++ b/web-mobile/js/__tests__/match_groups_table.test.jsx @@ -233,4 +233,18 @@ describe('the representatives of the representative bout (repPickA, repPickB)', const next = { subResults: [bout(1, { decision: '' })] }; expect(changedGroups(next, stored)).toEqual([]); }); + + // The server stores NO side names on the representative row (engine.AddDaihyosen builds + // {Position: -1, Decision: "daihyosen"}), while the editor restates the match's team names + // on it so the hantei mark can be placed. The names are the match's sides, never a change of + // the bout, so they must not name it. + it('the team names the editor states on the representative row, which the server stores empty, are not a change of the bout', () => { + const stored = { subResults: [bout(-1, { sideA: '', sideB: '' })] }; + expect(changedGroups({ subResults: [bout(-1)] }, stored)).toEqual([]); + }); + + it('a point struck on a representative row the server stores without side names still names bout:-1', () => { + const stored = { subResults: [bout(-1, { sideA: '', sideB: '' })] }; + expect(changedGroups({ subResults: [bout(-1, { ipponsA: ['M'] })] }, stored)).toEqual(['bout:-1']); + }); }); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 359b4a38c..138e610e5 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -73,6 +73,12 @@ const FOUGHT_BOUTS = [1, 2, 3].map((position) => ({ const DH_EMPTY = { position: -1, sideA: 'Team A', sideB: 'Team B', decision: 'daihyosen' }; +// The representative row AS THE SERVER SENDS IT right after an add: engine.AddDaihyosen +// (daihyosen.go) builds {Position: -1, Decision: "daihyosen"} and nothing fills the sides, +// so Go's JSON gives nil ippon arrays and empty side names. DH_EMPTY states the team names, +// which the server never stores on this row, and so cannot show what a pick does to it. +const DH_ADDED = { position: -1, sideA: '', sideB: '', ipponsA: null, ipponsB: null, hansokuA: 0, hansokuB: 0, winner: '', decision: 'daihyosen' }; + function makeMatch(overrides = {}, { fought = true } = {}) { const { subResults = [], ...rest } = overrides; return { @@ -688,4 +694,33 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(dhInput('AKA').value).toBe('Erin'); }); + + it('R31: a pick on a representative bout nobody has scored names the pick alone and leaves the bout\'s scoreline unstated', async () => { + // A pick is its own change group (repPickB). Naming bout:-1 as well would stamp the + // bout at the pick, so a device that had not yet seen a point struck elsewhere would + // send its stale scoreline under the newer stamp and erase that point. + const { onSubmit } = await mount(makeMatch({ subResults: [DH_ADDED] })); + await pickFromDh('SHIRO', 'Carol'); + await pastDebounce(); + const patch = lastSubmitted(onSubmit); + const dh = dhEntryOf(patch); + expect(patch.changed, 'the pick is the only change').toEqual(['repPickB']); + expect(dh.sideBMemberId).toBe('m1b'); + expect('ipponsA' in dh, 'the untouched bout states no scoreline').toBe(false); + expect('ipponsB' in dh, 'the untouched bout states no scoreline').toBe(false); + }); + + it('R32: a point struck here on the same bout is still named with the pick, and carries the scoreline', async () => { + const { onSubmit } = await mount(makeMatch({ subResults: [DH_ADDED] })); + await act(async () => { fireEvent.click(dhIppon('aka', 'M')); }); + await pickFromDh('SHIRO', 'Carol'); + await pastDebounce(); + const patch = lastSubmitted(onSubmit); + const dh = dhEntryOf(patch); + expect(patch.changed).toContain('bout:-1'); + expect(patch.changed).toContain('repPickB'); + expect(patch.changed, 'Aka\'s side was not picked here').not.toContain('repPickA'); + expect(dh.ipponsA).toEqual(['M']); + expect(dh.sideBMemberId).toBe('m1b'); + }); }); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 6bb466791..4ff8e5b83 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -1485,6 +1485,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 || @@ -3129,11 +3136,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 @@ -3147,7 +3160,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 && ( diff --git a/web-mobile/js/match_groups.jsx b/web-mobile/js/match_groups.jsx index 26e40bda4..58ebd582d 100644 --- a/web-mobile/js/match_groups.jsx +++ b/web-mobile/js/match_groups.jsx @@ -255,11 +255,18 @@ function groupKey(wire, group, next) { // 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. Numbered rows keep the rule above. + // names the bout. The row's side names are the MATCH's sides, which the + // editor restates on it so the hantei mark can be placed on the winner's + // side while the server stores none (engine.AddDaihyosen builds the row + // with empty sides): they too are never a change of the bout, 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. From f2d3c017beb6e2e2428c8d71d679c792beb6f1db Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 08:55:54 +0100 Subject: [PATCH 27/38] fix: close the detected gaps, wave 1 (PR #463) Server: - A representative bout is stored with the match's team names from the moment it is added, and seatBracketSide keeps the row's names equal to the match's on every seat. "Another team" is judged by id when both sides have one and by name otherwise, so a legacy id-less side re-seated to another team loses its pick and a side gaining its own id keeps it. - A representative pick made after its bout was removed is moved to the match's history with its member (both arrival orders converge), and a pick left with no row by a later hold is held with its value, never applied onto nothing; a removal held for a newer point holds its picks. Client: - Every score editor agrees what it adopts from another device, per group (useChangedGroups keeps both baselines per group; claim.agree moves both): an edit after an adopt is named, and an adopted value is never re-sent as this device's own. - The history view names the picked representative and words a displaced change without a winner reason neutrally. - One start helper (start_match.jsx) for the court console and the Scores tab: the Scores tab's auto-advance now has the one-start guard and shows a refused start on the refused match's row, with a toast, on both paths. Co-Authored-By: Claude Opus 5.5 --- internal/engine/bracket_seat.go | 97 +++++- internal/engine/bracket_seat_test.go | 138 ++++++++- internal/engine/daihyosen.go | 18 +- internal/engine/daihyosen_test.go | 4 +- internal/engine/match_merge.go | 144 +++++++-- internal/engine/match_merge_rep_picks_test.go | 231 +++++++++++++++ .../mobileapp/daihyosen_row_names_test.go | 71 +++++ internal/mobileapp/handlers_daihyosen.go | 7 +- web-mobile/check-imports.mjs | 3 + .../__tests__/displaced_groups_note.test.jsx | 47 +++ ..._scores_auto_start_refusal.render.test.jsx | 275 ++++++++++++++++++ ...hanged_groups_every_editor.render.test.jsx | 170 +++++++++++ .../render/match_history_view.render.test.jsx | 87 ++++++ ...m_daihyosen_representative.render.test.jsx | 40 +++ web-mobile/js/__tests__/start_match.test.jsx | 107 +++++++ .../js/__tests__/write_result_banner.test.jsx | 2 +- web-mobile/js/admin.jsx | 1 + web-mobile/js/admin_competition.jsx | 2 +- web-mobile/js/admin_schedule_score_editor.jsx | 168 ++++++++--- web-mobile/js/admin_scoring_autosave.jsx | 38 ++- web-mobile/js/admin_scoring_engi.jsx | 3 +- web-mobile/js/admin_scoring_individual.jsx | 20 +- web-mobile/js/admin_scoring_team.jsx | 105 +++++-- web-mobile/js/admin_shiaijo.jsx | 21 +- web-mobile/js/match_groups.jsx | 34 ++- web-mobile/js/match_history_view.jsx | 37 ++- web-mobile/js/start_match.jsx | 133 +++++++++ 27 files changed, 1828 insertions(+), 175 deletions(-) create mode 100644 internal/mobileapp/daihyosen_row_names_test.go create mode 100644 web-mobile/js/__tests__/displaced_groups_note.test.jsx create mode 100644 web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx create mode 100644 web-mobile/js/__tests__/start_match.test.jsx create mode 100644 web-mobile/js/start_match.jsx diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go index d64085aa4..7e07b3c08 100644 --- a/internal/engine/bracket_seat.go +++ b/internal/engine/bracket_seat.go @@ -18,18 +18,47 @@ import ( // (SubMatchResult.SideAMemberID/SideBMemberID, the state.GroupRepPickA and // state.GroupRepPickB groups), so a side given ANOTHER team takes its pick away. // -// - The id is unchanged (a rename, or a correction that stores the winner -// already recorded and so re-propagates the same team): the name is written -// and nothing else, the pick stays. -// - The id changes (team X to team Y, to "" when the slot goes back to a -// placeholder, or from "" when a placeholder is resolved): name and id are -// written, and a pick held for THAT side is cleared. The other side's pick -// is never touched. The rule is id-based, with no name fallback: an id-less -// side cannot carry a pick (the picker's roster and the member mint both -// need the team id). -// - No pick on that side, or no rep bout row at all: nothing else moves, no -// stamp, no ModifiedAt, so the draw-time callers produce exactly the -// bracket they always did. +// 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 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 @@ -57,17 +86,24 @@ func seatBracketSide(bm *state.BracketMatch, side domain.MatchSide, name, id str log.Printf("engine: BUG: bracket match %s: seatBracketSide called with side %q; nothing written", bm.ID, side) return } - idChanged := *idField != id + another := seatedAnotherTeam(*nameField, *idField, name, id) *nameField = name - if !idChanged { - return + // 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 } - *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) + if !another { + return + } pickA, pickB := bm.RepPicks() held := pickA if side == domain.MatchSideB { @@ -85,6 +121,35 @@ func seatBracketSide(bm *state.BracketMatch, side domain.MatchSide, name, id str 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. +func nameRepBoutRow(bm *state.BracketMatch, row int, side domain.MatchSide, name string) { + field := func(r *state.SubMatchResult) *string { + if side == domain.MatchSideA { + return &r.SideA + } + return &r.SideB + } + if *field(&bm.SubResults[row]) == name { + return + } + subs := slices.Clone(bm.SubResults) + *field(&subs[row]) = 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. diff --git a/internal/engine/bracket_seat_test.go b/internal/engine/bracket_seat_test.go index a1af096a7..497e2d063 100644 --- a/internal/engine/bracket_seat_test.go +++ b/internal/engine/bracket_seat_test.go @@ -48,7 +48,11 @@ func TestSeatBracketSide(t *testing.T) { 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) { @@ -56,6 +60,8 @@ func TestSeatBracketSide(t *testing.T) { 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") @@ -71,6 +77,8 @@ func TestSeatBracketSide(t *testing.T) { 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) @@ -84,10 +92,22 @@ func TestSeatBracketSide(t *testing.T) { 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 @@ -136,6 +156,103 @@ func TestSeatBracketSide(t *testing.T) { 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) }) } @@ -143,9 +260,10 @@ func TestSeatBracketSide(t *testing.T) { // 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(a, b string) []state.SubMatchResult { + row := func(nameA, nameB, a, b string) []state.SubMatchResult { return []state.SubMatchResult{{ - Position: state.DaihyosenSubPosition, SideAMemberID: a, SideBMemberID: b, + Position: state.DaihyosenSubPosition, SideA: nameA, SideB: nameB, + SideAMemberID: a, SideBMemberID: b, }} } bracket := &state.Bracket{ @@ -158,12 +276,12 @@ func TestPropagateBracketWinner_ReseatsTheNextAndTheBronzeSidesAndTheirPicks(t * }, { {ID: "final", SideA: "Tora", SideAID: "b", SideB: "Kuma", SideBID: "c", - Status: state.MatchStatusScheduled, SubResults: row("pb", "pk")}, + 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("pa", "ph"), + Status: state.MatchStatusScheduled, SubResults: row("Ryu", "Hawk", "pa", "ph"), }, } @@ -174,11 +292,15 @@ func TestPropagateBracketWinner_ReseatsTheNextAndTheBronzeSidesAndTheirPicks(t * 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 @@ -205,6 +327,8 @@ func TestReopenMatch_RetractionTakesThePickOfTheClearedSlot(t *testing.T) { 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) @@ -252,7 +376,8 @@ func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T 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, SideAMemberID: "pa", SideBMemberID: "pb", + Position: state.DaihyosenSubPosition, SideA: "Ryu", SideB: "Tora", + SideAMemberID: "pa", SideBMemberID: "pb", }}, }}}} } @@ -271,6 +396,8 @@ func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T 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") }) @@ -281,6 +408,7 @@ func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T 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) 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/match_merge.go b/internal/engine/match_merge.go index aa3299d61..48b9dcf35 100644 --- a/internal/engine/match_merge.go +++ b/internal/engine/match_merge.go @@ -309,6 +309,10 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic // 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) @@ -325,31 +329,6 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic } state.CopyGroup(incoming, stored, g) } - // A pick has no bout to land on once the representative bout is gone (a - // removal's tombstone, or a row the write carried that its stamp could not - // bring back): each side's pick is held, in the history, not applied onto - // nothing, and that side's stamp goes back to what it was. - if state.DaihyosenSubIndex(incoming.SubResults) < 0 { - for _, pick := range []struct { - side domain.MatchSide - id string - }{{domain.MatchSideA, payloadPickA}, {domain.MatchSideB, payloadPickB}} { - g := state.RepPickGroup(pick.side) - i := slices.Index(rep.Applied, g) - if i < 0 || pick.id == "" { - continue - } - rep.Applied = slices.Delete(rep.Applied, i, i+1) - ghost := &state.MatchResult{SubResults: []state.SubMatchResult{{Position: state.DaihyosenSubPosition}}} - ghost.SetRepPick(pick.side, pick.id) - reportHeld(rep, g, stored, ghost, nil) - if s, ok := storedStamps[g]; ok { - stamps[g] = s - } else { - delete(stamps, g) - } - } - } resultApplied := inChanged[state.GroupResult] && !hold[state.GroupResult] rep.ResultChanged = resultApplied mh := mergeHold{rep: rep, stored: stored, incoming: incoming, stamps: stamps, storedStamps: storedStamps} @@ -473,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) @@ -721,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 @@ -780,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 { @@ -824,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 index 892bb694c..6b42f4daa 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -396,3 +396,234 @@ func TestMerge_RepPicks_StaleSideHeldWhileTheOtherSideApplies(t *testing.T) { 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/mobileapp/daihyosen_row_names_test.go b/internal/mobileapp/daihyosen_row_names_test.go new file mode 100644 index 000000000..8188826a1 --- /dev/null +++ b/internal/mobileapp/daihyosen_row_names_test.go @@ -0,0 +1,71 @@ +package mobileapp + +// The representative bout's row keeps the team names, and it keeps them from the +// moment it exists. The hantei mark is placed on the winner's side by comparing +// the winner against THAT row's sideA/sideB, so a row that names nobody between +// the add and the first score write is a row nothing can attribute. The add +// stores the match's own names (engine.AddDaihyosen); the first write restates +// the same strings, so it lands as an echo, not as a side mismatch or a verdict +// the organiser alone may touch. + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDaihyosenAdd_TheRowCarriesTheMatchsTeamNames(t *testing.T) { + for _, tc := range []struct { + name string + selfRun bool + password string + }{ + {"the organiser's add", false, "main-pw"}, + {"a participant's add", true, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newRepBoutFixture(t, tc.selfRun) + w := f.send(http.MethodPost, repBoutMatchPath+"/daihyosen", tc.password, map[string]any{"modifiedAt": f.now}) + require.Equal(t, http.StatusOK, w.Code, "adding the representative bout: %s", w.Body.String()) + + bm := storedB1(t, f.store, "c1") + row := f.storedRepBout(t) + assert.Equal(t, bm.SideA, row.SideA, "the stored row names the match's side A the moment it exists") + assert.Equal(t, bm.SideB, row.SideB, "the stored row names the match's side B the moment it exists") + assert.Equal(t, "TeamA", row.SideA) + assert.Equal(t, "TeamB", row.SideB) + + var body struct { + SubResult state.SubMatchResult `json:"subResult"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "TeamA", body.SubResult.SideA, "the answer carries the same names") + assert.Equal(t, "TeamB", body.SubResult.SideB, "the answer carries the same names") + }) + } +} + +// The first write after the add restates the names the add stored. It lands +// whole: no side_mismatch, no hantei_organiser_only, the scoreline is written +// and the names stay what the match's are. +func TestSelfRun_TheFirstWriteRestatingTheStoredNamesLands(t *testing.T) { + f := newRepBoutFixture(t, true) + f.addRepBout(t) + stored := f.storedRepBout(t) + require.Equal(t, "TeamA", stored.SideA, "precondition: the add stored the names") + + w := f.score("", state.MatchStatusRunning, "", f.now+100, repBoutRow([]string{"M"}, []string{}, "TeamA")) + require.Equal(t, http.StatusOK, w.Code, "scoring the row that restates the names: %s", w.Body.String()) + assert.NotContains(t, w.Body.String(), `"applied":false`) + assert.NotContains(t, w.Body.String(), "side_mismatch") + assert.NotContains(t, w.Body.String(), "hantei_organiser_only") + + row := f.storedRepBout(t) + assert.Equal(t, []string{"M"}, row.IpponsA, "the scoreline is written") + assert.Equal(t, "TeamA", row.SideA) + assert.Equal(t, "TeamB", row.SideB) +} diff --git a/internal/mobileapp/handlers_daihyosen.go b/internal/mobileapp/handlers_daihyosen.go index fa0865084..daf940831 100644 --- a/internal/mobileapp/handlers_daihyosen.go +++ b/internal/mobileapp/handlers_daihyosen.go @@ -123,7 +123,7 @@ func respondIfValidationError(c *gin.Context, err error) bool { // variant so the read-modify-write runs under the same per-comp lock the // read used (see RegisterDaihyosenHandlers). type DaihyosenEngine interface { - AddDaihyosen(compID, matchID string, sideA, sideB engine.TeamSummary, isPool bool, sideAEligible, sideBEligible int) (*state.SubMatchResult, error) + AddDaihyosen(compID, matchID, nameA, nameB string, sideA, sideB engine.TeamSummary, isPool bool, sideAEligible, sideBEligible int) (*state.SubMatchResult, error) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, matchID string, result *state.MatchResult, opts ...engine.ForceOptions) (*domain.CompetitorStatus, error) MaybeAutoCompletePoolsAfterWrite(compID string, written ...state.MatchResult) (engine.AutoCompleteOutcome, error) } @@ -424,7 +424,10 @@ func RegisterDaihyosenHandlers(r *gin.RouterGroup, eng DaihyosenEngine, store Da return err } - sub, err := eng.AddDaihyosen(id, mid, sideASummary, sideBSummary, engine.IsPoolMatchID(mid), sideAEligible, sideBEligible) + // The row is stored with the match's own team names (nameA/nameB), + // for the organiser's add and the participant's alike, so it is + // never nameless between this add and the first score write. + sub, err := eng.AddDaihyosen(id, mid, match.SideA, match.SideB, sideASummary, sideBSummary, engine.IsPoolMatchID(mid), sideAEligible, sideBEligible) if err != nil { switch { case errors.Is(err, engine.ErrNotTied): diff --git a/web-mobile/check-imports.mjs b/web-mobile/check-imports.mjs index 6fc960ba0..176be447c 100644 --- a/web-mobile/check-imports.mjs +++ b/web-mobile/check-imports.mjs @@ -49,6 +49,9 @@ const CHECK_MODULES = [ 'admin_scoring_team.jsx', 'admin_scoring_shared.jsx', 'admin_scoring_autosave.jsx', + // The one owner of how a match start is judged (both the court console and + // the Scores tab import it). + 'start_match.jsx', 'bracket.jsx', 'match_scoreboard.jsx', 'admin_setup.jsx', diff --git a/web-mobile/js/__tests__/displaced_groups_note.test.jsx b/web-mobile/js/__tests__/displaced_groups_note.test.jsx new file mode 100644 index 000000000..be4dc8531 --- /dev/null +++ b/web-mobile/js/__tests__/displaced_groups_note.test.jsx @@ -0,0 +1,47 @@ +// The note for a write that was recorded and moved a later change of the match to its +// history (match_groups.jsx displacedGroupsNote, through keptInHistoryNote). The server +// gives two reasons for it, and the note must word each as it is: +// - heldReason "needs_winner": the later change would have left the finished match +// without a winner, so it was moved (a knockout tie, an engi count that is not valid); +// - no heldReason: a representative's pick stamped after the representative bout's +// removal, which leaves it nothing to stand on. The match lacks no winner, and the +// note must not say it does. +import { describe, it, expect } from 'vitest'; +import { displacedGroupsNote, keptInHistoryNote } from '../match_groups.jsx'; + +const WITH_A_WINNER = "Saved. A later change to points would have left the finished match without a winner, so it was moved to the match's history."; + +describe('displacedGroupsNote', () => { + it('words a change moved because the match needs a winner as it always did', () => { + expect(displacedGroupsNote(['points'], true)).toBe(WITH_A_WINNER); + }); + + it('words a change moved for any other reason without a winner the match does not lack', () => { + expect(displacedGroupsNote(['repPickB'], false)) + .toBe("Saved. A later change to Shiro's pick for the representative bout was moved to the match's history."); + expect(displacedGroupsNote(['repPickA', 'repPickB'])) + .toBe("Saved. A later change to Aka's pick for the representative bout and Shiro's pick for the representative bout was moved to the match's history."); + }); + + it('says nothing when nothing was moved', () => { + expect(displacedGroupsNote([], true)).toBeNull(); + expect(displacedGroupsNote([])).toBeNull(); + }); +}); + +describe('keptInHistoryNote, by the reason the answer gives', () => { + it('needs_winner keeps today\'s wording', () => { + expect(keptInHistoryNote({ id: 'm1', status: 'completed', displacedGroups: ['points'], heldReason: 'needs_winner' })).toBe(WITH_A_WINNER); + }); + + it('a displacement with no reason (the representative bout was removed) is worded plainly', () => { + expect(keptInHistoryNote({ id: 'm1', status: 'running', displacedGroups: ['repPickB'] })) + .toBe("Saved. A later change to Shiro's pick for the representative bout was moved to the match's history."); + }); + + it('a part held and a part moved for no reason say both', () => { + expect(keptInHistoryNote({ id: 'm1', status: 'completed', heldGroups: ['encho'], displacedGroups: ['bout:2'] })).toBe( + "Kept in the match's history, not applied: overtime. A newer change to the same thing was recorded first. " + + "Saved. A later change to bout 2 was moved to the match's history."); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx new file mode 100644 index 000000000..9ae69e9f7 --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx @@ -0,0 +1,275 @@ +// PR #463 round 13 (gap C, bc-aadv on the Scores tab): the Scores tab's own +// auto-advance (Finish + Start Next, and the start after a decision) used to +// swallow a refused start. A thrown 409 (eligibility, court_busy, +// already_ineligible) hit `catch (_startErr) {}`, a clock_skew refusal (HTTP 200 +// {applied:false}) was never read and was taken for a start that landed, and +// nothing stopped a second start while one was out. The court console had all +// three (admin_shiaijo.jsx startMatch). Both now ask start_match.jsx. +// +// What the operator sees on a refusal: a toast (the moment's signal), a +// persistent notice on the REFUSED match's list row (role=status, the +// full-width slot BarredMatchNotice uses), and the editor open on that match in +// pre-match so they see which match and why. +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { CLOCK_SKEW_REASON_TEXT, startWhileStartingMessage } from '../../write_result.jsx'; +import { scoreRowMatchName } from '../../pool_ids.jsx'; + +const side = (id, name) => ({ id, name }); + +const mk = (id, status, scheduledAt, a, b) => ({ + id, compId: 'c1', compName: 'Cup', status, court: 'A', scheduledAt, phase: 'pool', poolName: 'Pool A', + sideA: side(`${id}-a`, a), sideB: side(`${id}-b`, b), +}); +const M1 = mk('m-1', 'running', '09:00', 'Yamada', 'Tanaka'); +const M2 = mk('m-2', 'scheduled', '09:05', 'Alice', 'Bob'); +const M3 = mk('m-3', 'scheduled', '09:10', 'Carol', 'Dan'); + +// The matches the (stubbed) compMatches hands the page; a test swaps them to +// show a refused match leaving `scheduled`. +let matches; + +// Captures the LATEST ScoreEditorModal props so a test can drive +// onSubmitAndNext / onAfterDecision directly, as the court console's suite does. +const probe = { props: null }; + +const STUBS = { + ScoreEditorModal: (props) => { probe.props = props; return
; }, + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + CourtPicker: () => null, + getScoreBtnClass: () => 'test-score-open', + matchScoreStr: () => '', + filterMatchesByCourt: (m) => m, + tournamentMatches: () => [], + compMatches: () => matches, + confirmDialog: vi.fn().mockResolvedValue(true), + pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, + API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordDecision: vi.fn() }, +}; + +let restore, AdminScoreEditor, AdminScoreEditorPage; + +beforeAll(async () => { + window.scrollTo = vi.fn(); + restore = installWindowStubs(STUBS); + const mod = await import('../../admin_schedule_score_editor.jsx'); + AdminScoreEditor = mod.AdminScoreEditor; + AdminScoreEditorPage = mod.AdminScoreEditorPage; +}); +afterAll(() => restore()); +beforeEach(() => { matches = [M1, M2, M3]; probe.props = null; }); + +const tournament = () => ({ competitions: [{ id: 'c1', name: 'Cup' }] }); + +function ui(onEditScore, showToast) { + return ( + + ); +} + +async function mountAndOpenRunning(onEditScore, showToast) { + let utils; + await act(async () => { utils = render(ui(onEditScore, showToast)); }); + // Rows sort running first: the first Score button is m-1's. + const scoreBtns = [...utils.container.querySelectorAll('button.test-score-open')]; + await act(async () => { fireEvent.click(scoreBtns[0]); }); + expect(probe.props.match.id).toBe('m-1'); + return utils; +} + +const isStart = (call) => call[2] && call[2].startOnly === true; +const rowOf = (utils, name) => [...utils.container.querySelectorAll('.score-edit-row')].find((r) => r.textContent.includes(name)); +const noticeIn = (row) => row && row.querySelector('[data-testid="start-refusal-notice"]'); + +const WITHDREW = 'Alice withdrew in Pool A · Match 2 and cannot fight again. Record the fusensho for Bob.'; + +describe('Finish + Start Next on the Scores tab says why the next match did not start', () => { + it('a thrown refusal is noted on the refused match\'s row, toasted, and the editor lands on it in pre-match', async () => { + const onEditScore = vi.fn(async (_c, id, patch) => { + if (id === 'm-2' && patch.startOnly) throw new Error(WITHDREW); + return { status: 'ok' }; + }); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + + const note = noticeIn(rowOf(utils, 'Alice')); + expect(note, 'the refused match shows why on its own row').toBeTruthy(); + expect(note.textContent).toBe(WITHDREW); + expect(note.getAttribute('role')).toBe('status'); + // No other row carries it. + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(1); + expect(showToast).toHaveBeenCalledWith(WITHDREW, 'error'); + // The operator lands on the refused match, not started. + expect(probe.props.match.id).toBe('m-2'); + expect(probe.props.match.status).toBe('scheduled'); + }); + + it('a clock_skew refusal (HTTP 200 applied:false) is a refused start, not a landed one', async () => { + const onEditScore = vi.fn(async (_c, id, patch) => (id === 'm-2' && patch.startOnly + ? { applied: false, reason: 'clock_skew' } + : { status: 'ok' })); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + + const sentence = `Could not start: ${CLOCK_SKEW_REASON_TEXT}. The clock has been resynced; try again.`; + const note = noticeIn(rowOf(utils, 'Alice')); + expect(note, 'the clock refusal is shown, not treated as a start that landed').toBeTruthy(); + expect(note.textContent).toBe(sentence); + expect(showToast).toHaveBeenCalledWith(sentence, 'error'); + expect(probe.props.match.id).toBe('m-2'); + }); + + it('a start that lands raises no notice and no toast', async () => { + const onEditScore = vi.fn().mockResolvedValue({ status: 'ok' }); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-1', 'm-2']); + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); + expect(showToast).not.toHaveBeenCalled(); + expect(probe.props.match.id).toBe('m-2'); + }); + + it('a queued start lands on reconnect, so it is not a refusal', async () => { + const onEditScore = vi.fn(async (_c, id, patch) => (id === 'm-2' && patch.startOnly ? { queued: true } : { status: 'ok' })); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); + expect(showToast).not.toHaveBeenCalled(); + }); +}); + +describe('one start at a time on the Scores tab', () => { + it('a Finish + Start Next while another match is still being started is refused, and says so on the refused match', async () => { + let releaseStart; + const onEditScore = vi.fn((_c, id, patch) => { + if (id === 'm-2' && patch.startOnly) return new Promise((resolve) => { releaseStart = resolve; }); + return Promise.resolve({ status: 'ok' }); + }); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + // First advance: m-1 finishes, the editor lands on m-2 and its start hangs. + await act(async () => { probe.props.onSubmitAndNext({ status: 'completed' }); }); + expect(probe.props.match.id).toBe('m-2'); + expect(onEditScore.mock.calls.filter(isStart).map((c) => c[1])).toEqual(['m-2']); + + // Second advance, from the editor now on m-2: its finish goes out, but m-3's + // start is refused because m-2's is still out. + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + + const sentence = startWhileStartingMessage({ label: scoreRowMatchName(M2) }); + expect(onEditScore.mock.calls.filter(isStart).map((c) => c[1]), 'no second start went out').toEqual(['m-2']); + const note = noticeIn(rowOf(utils, 'Carol')); + expect(note, 'the refused match says why').toBeTruthy(); + expect(note.textContent).toBe(sentence); + expect(showToast).toHaveBeenCalledWith(sentence, 'error'); + expect(probe.props.match.id).toBe('m-3'); + + // That notice describes the start still out: it goes once that start lands. + await act(async () => { releaseStart({ status: 'ok' }); }); + expect(noticeIn(rowOf(utils, 'Carol'))).toBeFalsy(); + }); +}); + +describe('the start after a decision says why too', () => { + it('onAfterDecision lands on the refused match with the notice', async () => { + const onEditScore = vi.fn(async (_c, id, patch) => { + if (id === 'm-2' && patch.startOnly) throw new Error(WITHDREW); + return { status: 'ok' }; + }); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onAfterDecision({ winner: side('m-1-a', 'Yamada') }); }); + + expect(onEditScore).toHaveBeenCalledTimes(1); + const note = noticeIn(rowOf(utils, 'Alice')); + expect(note, 'the decision path shows the refusal too').toBeTruthy(); + expect(note.textContent).toBe(WITHDREW); + expect(showToast).toHaveBeenCalledWith(WITHDREW, 'error'); + // Today it left the operator where they were; the console lands them on the match. + expect(probe.props.match.id).toBe('m-2'); + }); + + it('a clock_skew refusal after a decision is a refusal as well', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: false, reason: 'clock_skew' }); + const utils = await mountAndOpenRunning(onEditScore, vi.fn()); + + await act(async () => { await probe.props.onAfterDecision({ winner: side('m-1-a', 'Yamada') }); }); + + expect(noticeIn(rowOf(utils, 'Alice')).textContent).toContain(CLOCK_SKEW_REASON_TEXT); + expect(probe.props.match.id).toBe('m-2'); + }); +}); + +describe('the refusal notice goes when it stops being true', () => { + async function refused() { + const onEditScore = vi.fn(async (_c, id, patch) => { + if (id === 'm-2' && patch.startOnly) throw new Error(WITHDREW); + return { status: 'ok' }; + }); + const utils = await mountAndOpenRunning(onEditScore, vi.fn()); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + expect(noticeIn(rowOf(utils, 'Alice'))).toBeTruthy(); + return { utils, onEditScore }; + } + + it('when the refused match leaves scheduled in the live data', async () => { + const { utils, onEditScore } = await refused(); + matches = [M1, { ...M2, status: 'running' }, M3]; + await act(async () => { utils.rerender(ui(onEditScore, vi.fn())); }); + expect(noticeIn(rowOf(utils, 'Alice'))).toBeFalsy(); + }); + + it('when the operator opens another match', async () => { + const { utils } = await refused(); + const carolScore = rowOf(utils, 'Carol').querySelector('button.test-score-open'); + await act(async () => { fireEvent.click(carolScore); }); + expect(probe.props.match.id).toBe('m-3'); + expect(noticeIn(rowOf(utils, 'Alice'))).toBeFalsy(); + }); + + it('but not when the editor is only closed: the row is where it stays visible', async () => { + const { utils } = await refused(); + await act(async () => { probe.props.onClose(); }); + expect(utils.container.querySelector('[data-testid="score-editor"]')).toBeNull(); + expect(noticeIn(rowOf(utils, 'Alice'))).toBeTruthy(); + }); +}); + +describe('the Scores page hands its toast down', () => { + // AdminScoreEditor took a showToast prop that no host passed, so the toast + // this fix adds would have reached nobody: the page must hand its own down. + it('AdminScoreEditorPage passes showToast to the editor', async () => { + const onEditScore = vi.fn(async (_c, id, patch) => { + if (id === 'm-2' && patch.startOnly) throw new Error(WITHDREW); + return { status: 'ok' }; + }); + const showToast = vi.fn(); + let utils; + await act(async () => { + utils = render( + {}} onEditScore={onEditScore} onMoveCourt={null} + onLogout={() => {}} onViewerMode={() => {}} password="pw" showToast={showToast} + />, + ); + }); + await act(async () => { fireEvent.click(utils.container.querySelector('button.test-score-open')); }); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + expect(showToast).toHaveBeenCalledWith(WITHDREW, 'error'); + }); +}); diff --git a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx index 5cc7fefee..809c27ade 100644 --- a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx +++ b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx @@ -394,6 +394,21 @@ describe('a write applied in part', () => { "Saved. A later change to points would have left the finished match without a winner, so it was moved to the match's history."); }); + // A later change moved for another reason (a representative's pick stamped after the + // representative bout's removal) carries no needs_winner reason: the match lacks no + // winner, and the note must not say it does. + it('a write that moved a later change to the history for no winner reason says only that', async () => { + window.API.recordScore.mockImplementation((_c, _m, patch) => { + writes.push(patch); + return Promise.resolve({ id: 'm-ind', status: 'running', displacedGroups: ['repPickB'] }); + }); + await mount(individual()); + await pointerTap(ipponBtn('aka', 'M')); + await settle(); + expect(note()?.textContent).toBe( + "Saved. A later change to Shiro's pick for the representative bout was moved to the match's history."); + }); + it('a superseded write shows the banner, not the note', async () => { window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); @@ -405,3 +420,158 @@ describe('a write applied in part', () => { expect(note()).toBeNull(); }); }); + +// A value the editor ADOPTED from the server is the value it agrees with, and it is the +// last value the editor "sent" for that group, so: +// - taking it back here is a change (B): the server keeps the other device's value +// unless the write names the group, and the editor's mount-time value is not the +// baseline any more; +// - an edit of something else never names it (F): it is not a change, and naming it +// would put the adopted value back under a newer stamp, over whatever was recorded +// meanwhile. +// A value the editor HELD over a newer server value is neither (the existing tests above). +describe('an adopted value is agreed, never re-sent', () => { + it('individual: a point adopted from another device and then taken back here is named (B)', async () => { + const { rerender } = await mount(individual()); + await act(async () => { rerender(editorFor(individual({ ipponsB: ['K'] }))); }); + expect(slotWith('Shiro', 'K'), 'the point recorded elsewhere is shown').toBeTruthy(); + await pointerTap(slotWith('Shiro', 'K')); + await settle(); + expect(lastWrite().ipponsB).toEqual([]); + expect(lastWrite().changed, 'the server keeps the other device\'s point unless the write names the points').toContain('points'); + }); + + it('individual: an unrelated edit after a point was adopted does not name the points (F)', async () => { + const { rerender } = await mount(individual()); + await pointerTap(ipponBtn('aka', 'M')); + await settle(); + // Its own write comes back, then another device scores Shiro's point. + await act(async () => { rerender(editorFor(individual({ ipponsA: ['M'] }))); }); + await act(async () => { rerender(editorFor(individual({ ipponsA: ['M'], ipponsB: ['K'] }))); }); + expect(slotWith('Shiro', 'K'), 'the point recorded elsewhere is shown').toBeTruthy(); + await startOvertime(); + await settle(); + expect(lastWrite().changed).toEqual(['encho']); + }); + + it('individual: an overtime count adopted and then switched off here is named (B)', async () => { + const { rerender } = await mount(individual()); + await act(async () => { rerender(editorFor(individual({ encho: { periodCount: 1 } }))); }); + await click(screen.getByTestId('scoring-modal-encho-checkbox')); + await settle(); + expect(lastWrite().encho).toBeUndefined(); + expect(lastWrite().changed).toContain('encho'); + }); + + it('individual: a hantei verdict adopted from another device and then cancelled here names the points that carried it (B)', async () => { + const tied = (over = {}) => individual({ ipponsA: ['M'], ipponsB: ['K'], ...over }); + const { rerender } = await mount(tied()); + // Another device records Yamada's win by hantei: the Ht mark rides in Aka's ippons. + await act(async () => { rerender(editorFor(tied({ decidedByHantei: true, winner: { id: 'p1', name: 'Yamada' } }))); }); + await click(screen.getByTestId('scoring-modal-hantei-cancel')); + await finish(); + expect(lastWrite().status).toBe('completed'); + expect(lastWrite().changed, 'the server keeps the Ht mark unless the write names the points it rides in').toContain('points'); + expect(lastWrite().changed).toContain('result'); + }); + + it('engi: flags adopted from another device and then set back here are named (B)', async () => { + const engiMatch = (over = {}) => ({ + id: 'm-engi', compId: 'comp1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + compEngi: true, + sideA: { id: 'p1', name: 'Ito - Abe' }, sideB: { id: 'p2', name: 'Ono - Sato' }, + ...over, + }); + const view = (m) => ; + let utils; + await act(async () => { utils = render(view(engiMatch())); }); + await act(async () => { utils.rerender(view(engiMatch({ flagsA: 1 }))); }); + expect(screen.getByTestId('engi-aka-count').textContent, 'the count recorded elsewhere is shown').toBe('1'); + await click(screen.getByTestId('engi-aka-dec')); + await settle(); + expect(lastWrite().flagsA).toBe(0); + expect(lastWrite().changed).toContain('flags'); + }); + + describe('team', () => { + const team = (over = {}) => ({ + id: 'm-team', compId: 'comp1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 3, + sideA: { id: 'team-kyoto', name: 'Kyoto' }, sideB: { id: 'team-osaka', name: 'Osaka' }, + ...over, + }); + const bout = (position, over = {}) => ({ position, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: '', ...over }); + const kyotoWins = (position) => bout(position, { ipponsA: ['M'], winner: 'Kyoto' }); + const osakaWins = (position) => bout(position, { ipponsB: ['K'], winner: 'Osaka' }); + const row = (i) => [...document.querySelectorAll('.team-sub-match')][i]; + const boutBtn = (i, side, letter) => [...row(i).querySelectorAll(`.team-sub-match__side--${side} button.ipt-btn`)].find((b) => b.textContent === letter); + const placed = (i, color) => [...row(i).querySelectorAll(`.tsm-center-pts--${color} button.editor-side__pt`)].map((b) => b.textContent.trim()); + const markBtn = (i, color, letter) => [...row(i).querySelectorAll(`.tsm-center-pts--${color} button.editor-side__pt`)].find((b) => b.textContent.trim() === letter); + const mountTeam = async (match) => { + let utils; + await act(async () => { utils = render(editorFor(match)); }); + return (m) => act(async () => { utils.rerender(editorFor(m)); }); + }; + + it('a bout adopted from another device and then cleared here is named (B)', async () => { + const rerenderWith = await mountTeam(team()); + await rerenderWith(team({ subResults: [osakaWins(2)] })); + expect(placed(1, 'shiro'), 'the bout recorded elsewhere is shown').toContain('K'); + await pointerTap(markBtn(1, 'shiro', 'K')); + await settle(); + expect(placed(1, 'shiro')).not.toContain('K'); + expect(lastWrite().changed, 'the server keeps the other device\'s bout unless the write names it').toContain('bout:2'); + }); + + it('scoring one bout after another was adopted names the scored bout alone (F)', async () => { + const rerenderWith = await mountTeam(team()); + await pointerTap(boutBtn(0, 'shiro', 'K')); + await settle(); + expect(lastWrite().changed).toEqual(['bout:1']); + // Its own write comes back, then another device scores bout 2. + await rerenderWith(team({ subResults: [osakaWins(1)] })); + await rerenderWith(team({ subResults: [osakaWins(1), kyotoWins(2)] })); + expect(placed(1, 'aka'), 'the bout recorded elsewhere is shown').toContain('M'); + await pointerTap(boutBtn(2, 'aka', 'D')); + await settle(); + expect(lastWrite().changed).toEqual(['bout:3']); + }); + + it('a bout the operator holds is named and a bout adopted beside it is not (held and agreed)', async () => { + const rerenderWith = await mountTeam(team()); + await pointerTap(boutBtn(0, 'shiro', 'K')); + await settle(); + // Another device moves bout 1 (the operator keeps the point struck here) and scores bout 2. + await rerenderWith(team({ subResults: [kyotoWins(1), kyotoWins(2)] })); + expect(placed(0, 'shiro'), 'the point struck here is still shown').toContain('K'); + expect(placed(1, 'aka'), 'the bout recorded elsewhere is shown').toContain('M'); + await startOvertime(); + await settle(); + expect(lastWrite().changed).toContain('bout:1'); + expect(lastWrite().changed, 'bout 2 is the other device\'s, adopted and not changed here').not.toContain('bout:2'); + }); + + it('a bout whose point was struck and taken back here stays the other device\'s (held, not agreed)', async () => { + const rerenderWith = await mountTeam(team()); + await pointerTap(boutBtn(0, 'shiro', 'K')); + await settle(); + await pointerTap(markBtn(0, 'shiro', 'K')); + await settle(); + // Another device scores bout 1 meanwhile: the row stays the operator's, which reads as it was. + await rerenderWith(team({ subResults: [kyotoWins(1)] })); + expect(placed(0, 'aka'), 'the row is still the operator\'s').not.toContain('M'); + await startOvertime(); + await settle(); + expect(lastWrite().changed, 'an edit held over a newer value is not put back').toEqual(['encho']); + }); + + it('an overtime count adopted from another device and then switched off here is named (B)', async () => { + const rerenderWith = await mountTeam(team()); + await rerenderWith(team({ encho: { periodCount: 1 } })); + await click(screen.getByTestId('scoring-modal-encho-checkbox')); + await settle(); + expect(lastWrite().encho).toBeUndefined(); + expect(lastWrite().changed).toContain('encho'); + }); + }); +}); diff --git a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx index 7dd68811c..a2d6f17e9 100644 --- a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx +++ b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx @@ -166,6 +166,93 @@ describe('heldValueText', () => { }); }); +// A representative pick is a member id. When the editor holds the team's members the +// history names the representative instead of saying only that a side was picked; the +// resolver is the editor's: (side, memberId) => a label, null for an id the team's +// members do not hold, undefined when it has no members to look in. +describe('a representative pick names the representative', () => { + const MEMBERS = { a: { m1a: 'Alice' }, b: { m1b: 'Bob', m2b: '' } }; + const resolve = (side, id) => { + const list = MEMBERS[side]; + return id in list ? (list[id] || 'T9.2') : null; + }; + + it.each([ + ['repPickA', { sideAMemberId: 'm1a' }, 'picked Alice'], + ['repPickB', { sideBMemberId: 'm1b' }, 'picked Bob'], + // A member with no name yet reads by its label. + ['repPickB', { sideBMemberId: 'm2b' }, 'picked T9.2'], + // An id the side's team no longer holds (a correction seated another team). + ['repPickA', { sideAMemberId: 'gone' }, 'picked (not on the team now)'], + // The id is looked up in its own side's team. + ['repPickA', { sideAMemberId: 'm1b' }, 'picked (not on the team now)'], + ['repPickA', { sideAMemberId: '' }, 'not picked'], + ['repPickB', null, 'not picked'], + ])('%s %j reads "%s"', (group, value, words) => { + expect(view.heldValueText(group, value, resolve)).toBe(words); + }); + + it('says only that a side was picked when it has no members to look in', () => { + expect(view.heldValueText('repPickA', { sideAMemberId: 'm1a' })).toBe('picked'); + expect(view.heldValueText('repPickA', { sideAMemberId: 'm1a' }, () => undefined)).toBe('picked'); + }); + + it('a pick moved to the history names the representative', () => { + const v = view.historyEntryView({ + matchId: 'm-ko', door: 'displaced', stamp: at(10, 5, 0), receivedAt: at(10, 6, 0), changed: ['repPickB'], + outcomes: { repPickB: 'held' }, held: { repPickB: { sideBMemberId: 'm1b' } }, + reason: 'the representative bout was removed', + }, resolve); + expect(v.reason).toBe('Moved to history: the representative bout was removed'); + expect(v.held.map((h) => h.text)).toEqual(["Moved to history: Shiro's pick for the representative bout, picked Bob"]); + }); + + it('a removal held for a newer point records the pick it kept, which reads sensibly empty', () => { + const v = view.historyEntryView({ + matchId: 'm-ko', door: 'daihyosen-remove', stamp: at(10, 5, 0), receivedAt: at(10, 6, 0), changed: ['bout:-1', 'repPickA'], + outcomes: { 'bout:-1': 'applied', repPickA: 'held' }, held: { repPickA: { sideAMemberId: '' } }, + }, resolve); + expect(v.held.map((h) => h.text)).toEqual(["Kept in history: Aka's pick for the representative bout, not picked"]); + }); + + it('the disclosure on the team editor lists it with the members the editor holds', async () => { + window.API.fetchMatchHistory.mockResolvedValue([{ + matchId: 'm-ko', door: 'displaced', stamp: at(10, 5, 0), receivedAt: at(10, 6, 0), changed: ['repPickA'], + outcomes: { repPickA: 'held' }, held: { repPickA: { sideAMemberId: 'm1a' } }, + reason: 'the representative bout was removed', + }]); + await act(async () => { + render(); + }); + await openHistory(); + expect(screen.getByTestId('match-history-held').textContent) + .toBe("Moved to history: Aka's pick for the representative bout, picked Alice"); + }); + + it('the disclosure on an editor that holds no members keeps today\'s words', async () => { + window.API.fetchMatchHistory.mockResolvedValue([{ + matchId: 'm-ko', door: 'score', stamp: at(10, 5, 0), receivedAt: at(10, 6, 0), changed: ['repPickA'], + outcomes: { repPickA: 'held' }, held: { repPickA: { sideAMemberId: 'm1a' } }, + }]); + await act(async () => { + render(); + }); + await openHistory(); + expect(screen.getByTestId('match-history-held').textContent) + .toBe("Kept in history: Aka's pick for the representative bout, picked"); + }); +}); + +describe('historyDoorWords', () => { + it('names the door of a side given another team by a correction to an earlier match', () => { + expect(view.historyDoorWords('reseat')).toBe('Side given another team by a correction to an earlier match'); + }); + + it('falls back for a door it does not know', () => { + expect(view.historyDoorWords('something-new')).toBe('Updated'); + }); +}); + // bc-mrgc phase 3: a write kept whole by one rule (the server's rev guard: // an older revision of this board) says why, in the server's own words. describe('historyEntryView: the reason a whole write was kept', () => { diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 138e610e5..74afbc686 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -35,6 +35,7 @@ const STUBBED_GLOBALS = { recordDecision: vi.fn(), addTeamMember: vi.fn(), renameTeamMember: vi.fn(), + fetchMatchHistory: vi.fn().mockResolvedValue([]), }, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], @@ -659,6 +660,45 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(patch.changed, 'the side adopted from the server is not a change').not.toContain('repPickB'); }); + it('R33: a pick adopted for one side is not named again by a point struck here afterwards', async () => { + const { onSubmit, rerenderWith } = await mount(makeMatch({ subResults: [DH_EMPTY] })); + // The editor's first write: a point struck on the representative bout. + await act(async () => { fireEvent.click(dhIppon('aka', 'M')); }); + await pastDebounce(); + expect(lastSubmitted(onSubmit).changed).toEqual(['bout:-1']); + // Another device picks Carol for Shiro; the point struck here has not come back yet. + await rerenderWith(makeMatch({ modifiedAt: Date.now() + 60000, subResults: [{ ...DH_EMPTY, sideBMemberId: 'm1b' }] })); + expect(dhInput('SHIRO').value, 'the pick made elsewhere is shown').toBe('Carol'); + // A second point struck here: the pick is the other device's, adopted and not changed here, so + // the write must not put it back under a newer stamp. + await act(async () => { fireEvent.click(dhIppon('shiro', 'K')); }); + await pastDebounce(); + const patch = lastSubmitted(onSubmit); + expect(dhEntryOf(patch).sideBMemberId, 'the pick rides the row as it is').toBe('m1b'); + expect(patch.changed).toEqual(['bout:-1']); + }); + + it('R34: the History names the representatives the sheet holds the members of, and an id the team no longer holds', async () => { + const at = Date.now(); + window.API.fetchMatchHistory.mockResolvedValue([ + { matchId: 'm-ko-1', door: 'daihyosen-remove', stamp: at, receivedAt: at, changed: ['repPickB'], outcomes: { repPickB: 'held' }, held: { repPickB: { sideBMemberId: 'm1b' } } }, + { matchId: 'm-ko-1', door: 'displaced', stamp: at, receivedAt: at, changed: ['repPickA'], outcomes: { repPickA: 'held' }, held: { repPickA: { sideAMemberId: 'm1a' } }, reason: 'the representative bout was removed' }, + // Aka's team never held m1b: Shiro's team did. + { matchId: 'm-ko-1', door: 'score', stamp: at, receivedAt: at, changed: ['repPickA'], outcomes: { repPickA: 'held' }, held: { repPickA: { sideAMemberId: 'm1b' } } }, + ]); + await mount(makeMatch({ subResults: [DH_EMPTY] })); + // The members are read when the sheet opens. + await waitFor(() => expect(window.API.fetchSquads).toHaveBeenCalled()); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await act(async () => { fireEvent.click(screen.getByTestId('match-history-toggle')); }); + const lines = (await screen.findAllByTestId('match-history-held')).map((el) => el.textContent); + expect(lines).toEqual([ + "Kept in history: Shiro's pick for the representative bout, picked Carol", + "Moved to history: Aka's pick for the representative bout, picked Alice", + "Kept in history: Aka's pick for the representative bout, picked (not on the team now)", + ]); + }); + it('R29: a pick made here and not yet written is dropped when its side is given another team: nothing is sent for it, and the sheet is not dirty', async () => { window.confirmDialog.mockClear(); const { onSubmit, onClose, rerenderWith } = await mount(makeMatch({ subResults: [DH_EMPTY] })); diff --git a/web-mobile/js/__tests__/start_match.test.jsx b/web-mobile/js/__tests__/start_match.test.jsx new file mode 100644 index 000000000..b82787fd6 --- /dev/null +++ b/web-mobile/js/__tests__/start_match.test.jsx @@ -0,0 +1,107 @@ +// bc-aadv / PR #463 round 13 (gap C): the ONE owner of "what did a Start write +// come back with" (start_match.jsx). The court console and the Scores tab both +// start a match, and before this leaf only the console read the answer: a +// clock_skew refusal (HTTP 200 {applied:false}) was taken for a start that +// landed on the Scores tab, a thrown 409 was swallowed, and nothing stopped a +// second start while one was out. + +import { describe, it, expect } from 'vitest'; +import { CLOCK_SKEW_REASON_TEXT } from '../write_result.jsx'; +import { + classifyStartOutcome, startFailureMessage, createStartGuard, startPatch, + START_CLOCK_SKEW_MESSAGE, START_FAILED_MESSAGE, +} from '../start_match.jsx'; + +describe('classifyStartOutcome', () => { + it('a clock_skew refusal is a start that did not happen, in the console\'s own words', () => { + const out = classifyStartOutcome({ applied: false, reason: 'clock_skew' }); + expect(out.ok).toBe(false); + expect(out.msg).toBe(`Could not start: ${CLOCK_SKEW_REASON_TEXT}. The clock has been resynced; try again.`); + expect(out.msg).toBe(START_CLOCK_SKEW_MESSAGE); + }); + + it('a queued start lands on reconnect, so it counts as started', () => { + expect(classifyStartOutcome({ queued: true })).toEqual({ ok: true }); + }); + + it('a landed start, an answer with no body and nothing at all are all started', () => { + expect(classifyStartOutcome({ status: 'ok' })).toEqual({ ok: true }); + expect(classifyStartOutcome({ applied: true })).toEqual({ ok: true }); + expect(classifyStartOutcome(undefined)).toEqual({ ok: true }); + expect(classifyStartOutcome(null)).toEqual({ ok: true }); + }); + + it('a superseded start is not a clock refusal and keeps the console\'s reading (started)', () => { + expect(classifyStartOutcome({ applied: false, reason: 'superseded' })).toEqual({ ok: true }); + }); +}); + +describe('startFailureMessage', () => { + it('is the thrown error\'s own sentence', () => { + expect(startFailureMessage(new Error('Alice withdrew in Pool A · Match 1 and cannot fight again.'))) + .toBe('Alice withdrew in Pool A · Match 1 and cannot fight again.'); + }); + + it('falls back to one sentence when the error says nothing', () => { + expect(startFailureMessage(new Error(''))).toBe(START_FAILED_MESSAGE); + expect(startFailureMessage(undefined)).toBe(START_FAILED_MESSAGE); + expect(START_FAILED_MESSAGE).toBe('Could not start the match: check eligibility and try again.'); + }); +}); + +describe('the start copy follows the house words', () => { + it('carries no em-dash and never says mat', () => { + const all = START_CLOCK_SKEW_MESSAGE + START_FAILED_MESSAGE; + expect(all).not.toMatch(/—/); + expect(all).not.toMatch(/\bmats?\b/i); + }); +}); + +describe('createStartGuard', () => { + it('starts nothing out until begin is called', () => { + expect(createStartGuard().inFlight()).toBeNull(); + }); + + it('takes one start at a time: another match is refused while one is out, naming it', () => { + const g = createStartGuard(); + const a = { id: 'a' }; + expect(g.begin('a', a)).toEqual({ started: true }); + const refused = g.begin('b', { id: 'b' }); + expect(refused.started).toBe(false); + expect(refused.repeat).toBe(false); + expect(refused.blocker).toEqual({ key: 'a', match: a }); + expect(g.inFlight()).toEqual({ key: 'a', match: a }); + }); + + it('a repeat for the match already being started is refused silently', () => { + const g = createStartGuard(); + g.begin('a', { id: 'a' }); + const again = g.begin('a', { id: 'a' }); + expect(again.started).toBe(false); + expect(again.repeat).toBe(true); + }); + + it('end frees it, and only the start that holds it can end it', () => { + const g = createStartGuard(); + g.begin('a', { id: 'a' }); + g.end('b'); + expect(g.inFlight().key).toBe('a'); + g.end('a'); + expect(g.inFlight()).toBeNull(); + expect(g.begin('b', { id: 'b' })).toEqual({ started: true }); + }); +}); + +describe('startPatch', () => { + it('is a result-only, start-only write (bc-sbq, bc-mrgc)', () => { + const p = startPatch(); + expect(p.startOnly).toBe(true); + expect(p.changed).toEqual(['result']); + expect(p.status).toBe('running'); + expect(p.winner).toBeNull(); + }); + + it('hands out a fresh object each call', () => { + expect(startPatch()).not.toBe(startPatch()); + }); +}); diff --git a/web-mobile/js/__tests__/write_result_banner.test.jsx b/web-mobile/js/__tests__/write_result_banner.test.jsx index 83106a86e..932895ddb 100644 --- a/web-mobile/js/__tests__/write_result_banner.test.jsx +++ b/web-mobile/js/__tests__/write_result_banner.test.jsx @@ -337,7 +337,7 @@ describe('a finish recorded that moved a later change to the history', () => { }); it('a write held in part and one that moved a later change says both', () => { - const both = { id: 'm1', status: 'completed', heldGroups: ['encho'], displacedGroups: ['bout:2'] }; + const both = { id: 'm1', status: 'completed', heldGroups: ['encho'], displacedGroups: ['bout:2'], heldReason: 'needs_winner' }; expect(keptInHistoryNote(both)).toBe( "Kept in the match's history, not applied: overtime. A newer change to the same thing was recorded first. " + "Saved. A later change to bout 2 would have left the finished match without a winner, so it was moved to the match's history."); diff --git a/web-mobile/js/admin.jsx b/web-mobile/js/admin.jsx index a982b3aea..9a1b470fb 100644 --- a/web-mobile/js/admin.jsx +++ b/web-mobile/js/admin.jsx @@ -774,6 +774,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan onLogout={onLogout} onViewerMode={onViewerMode} password={password} + showToast={showToast} />; } diff --git a/web-mobile/js/admin_competition.jsx b/web-mobile/js/admin_competition.jsx index a3aba9541..bafb4cf04 100644 --- a/web-mobile/js/admin_competition.jsx +++ b/web-mobile/js/admin_competition.jsx @@ -504,7 +504,7 @@ function AdminCompetition({ tournament, competition, pools, poolMatches, standin )} {section === "pools" && } {section === "bracket" && } - {section === "scores" && !isDrawReady && } + {section === "scores" && !isDrawReady && } {section === "export" && }
diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx index 58dca3ddf..cf38c6721 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -1,14 +1,15 @@ // Score editor components extracted from admin_schedule.jsx (mp-d7tl). -// startPatch, ScoreEditCourtBtn (local), AdminScoreEditor, AdminScoreEditorPage. +// startPatch (re-exported from start_match.jsx), ScoreEditCourtBtn (local), +// AdminScoreEditor, AdminScoreEditorPage. -import { writeDidNotLand, writeKeepsEditorOpen } from './write_result.jsx'; +import { writeDidNotLand, writeKeepsEditorOpen, startWhileStartingMessage } 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'; +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 +23,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 } from './start_match.jsx'; const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA } = React; @@ -49,7 +54,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 (
@@ -64,7 +69,7 @@ export function AdminScoreEditorPage({ tournament, onBack, onEditScore, onMoveCo
Update scores or correct past matches across the tournament. Changes propagate through the bracket.
- + ); @@ -84,24 +89,12 @@ 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 }, - }; -} +// startPatch now lives in start_match.jsx (the one owner of how a Start is +// written). Re-exported here, of the LOCAL binding imported above so the module +// is fetched once, because admin_schedule.jsx imports it from this file to +// publish `window.startPatch`, which admin_shiaijo.jsx reads, and the tests +// import it from here too. +export { startPatch }; export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCompId, password, showToast }) { const [filter, setFilter] = useStateA(""); @@ -152,6 +145,86 @@ 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 goes when that start lands. + 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 startRefusalStatus = startRefusalRow ? startRefusalRow.status : null; + // It is dropped when the refused match leaves `scheduled` (or the data), or + // when the operator opens a DIFFERENT match. 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 || startRefusalStatus !== "scheduled") setStartRefusal(null); + }, [openKey, startRefusalKey, startRefusalStatus]); + 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. + const refuseStart = (next, msg, waitingOn = null) => { + if (mountedRef.current) { + setOpenKey(scoreKeyOf(next)); + setStartRefusal({ key: scoreKeyOf(next), msg, waitingOn }); + } + // Single-slot toast (app.jsx): a thrown refusal, which editMatchScore has + // already toasted with the same words, replaces rather than stacks. + if (showToast) 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. 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)); + return false; + } finally { + guard.end(key); + // A "still being started" notice names THIS start; it goes when the start does. + if (mountedRef.current) setStartRefusal((prev) => (prev && prev.waitingOn === key ? null : prev)); + } + }; + const f = filter.trim().toLowerCase(); const filtered = allMatches.filter((m) => { if (restrictToCompId && m.compId !== restrictToCompId) return false; @@ -252,6 +325,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 (
@@ -337,6 +412,16 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom
)} + {/* 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 +478,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 +575,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 912a53478..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,30 +56,42 @@ 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; + // `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. 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 after an adopt with no build in between 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. The caller states - // which group it took, 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. + // 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) => { - stateRef.current.agreed[group] = matchWire(matchRef.current); + 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..775075552 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 @@ -172,7 +173,15 @@ 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. + 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 @@ -240,13 +249,13 @@ 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 || ""); claimChanged.agree(GROUP_REP); }, keepLocalEdits: true, isDirty: repPlayerA !== "" && repPlayerA !== (m.repPlayerA || ""), }); useAdoptFromServer({ signature: m.repPlayerB || "", - apply: () => setRepPlayerB(m.repPlayerB || ""), + apply: () => { setRepPlayerB(m.repPlayerB || ""); claimChanged.agree(GROUP_REP); }, keepLocalEdits: true, isDirty: repPlayerB !== "" && repPlayerB !== (m.repPlayerB || ""), }); @@ -630,6 +639,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). diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 4ff8e5b83..0bda0ef44 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -51,7 +51,7 @@ import { isOlderRunningCopy } from './patch.jsx'; import { useDebouncedRunningWrite, SyncStatusPill, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx'; import { MatchHistoryDisclosure } from './match_history_view.jsx'; -import { GROUP_REP_PICK_A, GROUP_REP_PICK_B } from './match_groups.jsx'; +import { GROUP_ENCHO, GROUP_RESULT, GROUP_REP_PICK_A, GROUP_REP_PICK_B, boutGroup } from './match_groups.jsx'; import { serverNowMs } from './server_clock.jsx'; import { publishHeight } from './published_height.jsx'; import { SideLabel, sideWithColour } from './side_cell.jsx'; @@ -709,6 +709,23 @@ function withoutRepPicks(row) { 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`, @@ -1226,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, }); @@ -1461,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, @@ -2214,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 @@ -2409,7 +2448,12 @@ 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. + for (const ss of serverSubs) claimChanged.agree(boutGroup(ss._pos)); + }, onReset: () => setFinishRefused(false), }); const withdrawalWinner = rulingShown ? ({ a: "b", b: "a" }[withdrawnKeyOf(m)] || null) : null; @@ -2910,6 +2954,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 || squadMemberLabel((side === "a" ? m.sideA : m.sideB)?.number || "", member.index); + }; + // 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): @@ -3698,29 +3757,25 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const local = localByPos.get(ss._pos); if (!local) return ss; 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, 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; + // 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; }); }); - // A pick taken from the server is one this editor now agrees with, so an edit - // that puts it back to what the editor mounted with (clearing a pick another - // device made) is still a change of that side's pick and is named. A side kept - // is the operator's own edit and stays one, so each side is agreed on its own. + // 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 @@ -4984,7 +5039,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. */} -
    {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/start_match.jsx b/web-mobile/js/start_match.jsx new file mode 100644 index 000000000..5ba274dbd --- /dev/null +++ b/web-mobile/js/start_match.jsx @@ -0,0 +1,133 @@ +// 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 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, CLOCK_SKEW_REASON_TEXT } 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 console reads it off `window.startPatch` (admin_schedule.jsx publishes +// it), the Scores tab imports it; both end up here. +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 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; the +// court console always has), or came back without a +// body. +// { ok: false, msg } the start was refused for the clock: nothing was +// stored and nothing will land later, so a host that +// called it started would pin a panel on a match that +// never started while the tap looked like it worked. +// Only a clock refusal is a refusal here. A superseded answer (applied:false, +// reason: superseded) reads as started, as the console has always read it: a +// newer change to the match's result is already stored, and the live data the +// host re-reads shows whatever that change made of the match. +// A start that THROWS (a 409) is not an answer at all; startFailureMessage +// words it. +export function classifyStartOutcome(res) { + if (writeWasRefusedForClock(res)) return { ok: false, msg: START_CLOCK_SKEW_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; +} + +// 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; + }, + }; +} From 94af3bd30b0cfc8bd5bc8b5c51d456bcc00b2cf5 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 09:27:12 +0100 Subject: [PATCH 28/38] fix: close the detected gaps, wave 2 (PR #463) Server: - Every representative pick a correction clears leaves a "reseat" line in that match's history (recordRepPickClears), from every bracket door that re-seats a side without reopening the match (score, decision, override, engi, reopen, requalification); a reopened match's own line names the pick instead, and a refused write's restore records nothing. - A withdrawal whose loser another match already barred is refused before anything is written: the pre-write probe reads the stored pairing's names, so the refusal can no longer re-seat and clear a downstream pick first. - The organiser's representative picks are judged as a participant's are (judgeRepPicksUnderTx): a member the side's team does not hold is refused 400 team_member_not_in_team, only for a pick the write introduces, never for the match's status; a row the write creates is judged too. Client: - A replayed write that moved a later change to the history is worded by its reason: only a needs_winner displacement says it would have left a match without a winner (writeDisplacedForWinner owns the check). openapi and docs: the representative bout's names at add, displacements without a reason, the reseat history door, and the organiser judge. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/network-architecture.md | 2 +- internal/engine/bracket_seat_audit.go | 141 +++++ internal/engine/bracket_seat_audit_test.go | 501 ++++++++++++++++++ internal/engine/engi.go | 6 + .../engine/k3_refused_before_reseat_test.go | 94 ++++ internal/engine/kachinuki.go | 7 + internal/engine/match_history.go | 7 + internal/engine/pool_requalify.go | 9 +- internal/engine/scoring.go | 63 ++- internal/engine/scoring_tx.go | 55 +- internal/mobileapp/handlers_match.go | 115 +++- .../organiser_rep_pick_judge_test.go | 298 +++++++++++ internal/mobileapp/self_run_daihyosen_test.go | 6 +- specs/openapi.yaml | 100 +++- web-mobile/check-write-result.mjs | 2 +- .../js/__tests__/queue_alert_message.test.jsx | 21 +- .../__tests__/queue_ordered_writes.test.jsx | 22 + web-mobile/js/__tests__/rule_gates.test.jsx | 1 + .../js/__tests__/write_result_banner.test.jsx | 56 +- web-mobile/js/admin.jsx | 4 +- web-mobile/js/api_client.jsx | 19 +- web-mobile/js/app.jsx | 8 +- web-mobile/js/match_groups.jsx | 12 +- web-mobile/js/match_history_view.jsx | 7 +- web-mobile/js/write_result.jsx | 42 +- 25 files changed, 1480 insertions(+), 118 deletions(-) create mode 100644 internal/engine/bracket_seat_audit.go create mode 100644 internal/engine/bracket_seat_audit_test.go create mode 100644 internal/engine/k3_refused_before_reseat_test.go create mode 100644 internal/mobileapp/organiser_rep_pick_judge_test.go 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/internal/engine/bracket_seat_audit.go b/internal/engine/bracket_seat_audit.go new file mode 100644 index 000000000..310dbb5bd --- /dev/null +++ b/internal/engine/bracket_seat_audit.go @@ -0,0 +1,141 @@ +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 a +// snapshot of the bracket's picks before it mutates the bracket +// (repPickSnapshot), diffs it afterwards (repPickClears) while it still holds the +// bracket, and records the difference beside the place that already records its +// reopen lines (recordRepPickClears), 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. + +// 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 +} + +// 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/engi.go b/internal/engine/engi.go index ebf80b220..77e722bd6 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)", @@ -320,12 +323,14 @@ func (e *Engine) recordEngiMatch( } priorWinner, priorWinnerID := propagatedWinnerOf(b, rIdx, mIdx, bm) priorPicks := snapshotDownstreamRepPicks(b, rIdx, mIdx) + 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, priorPicks) } + clears = repPickClears(before, b) return nil } } @@ -349,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/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 5bc68f1a4..cec14b6dc 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -1146,7 +1146,12 @@ 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 @@ -1158,6 +1163,7 @@ func (e *Engine) reopenUnderCourtLock(compID string, comp *state.Competition, ma return nil } markRepPicksCleared(h.BracketRoot, reopenedDownstream) + clears = repPickClears(before, h.BracketRoot) } prior := h.Bracket.Decision reopenBracketMatchKeepingTheFight(h.Bracket, reason, targetStatus) @@ -1166,6 +1172,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 } diff --git a/internal/engine/match_history.go b/internal/engine/match_history.go index b8b15f67e..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: diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go index e165a2b03..a383c0938 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] { @@ -314,12 +319,14 @@ func (e *Engine) applyRequalification(tx state.StoreTx, compID, reason string, p // The reopens above captured each match's picks; the repaint may have // given a side another team and taken its pick with it. markRepPicksCleared(b, reopened) + clears = repPickClears(before, b) 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/scoring.go b/internal/engine/scoring.go index caaa851ec..6202224bd 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -2403,18 +2403,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 } @@ -2656,9 +2662,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 { @@ -2672,7 +2678,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 @@ -2682,7 +2688,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, @@ -2694,7 +2700,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 @@ -2706,8 +2712,17 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // propagation below can re-seat a side and take one away, so the // reopen of a played one can say its pick went too. priorPicks := snapshotDownstreamRepPicks(bracket, rIdx, mIdx) + // The same, for every match the write leaves alone but re-seats + // (bracket_seat_audit.go): a downstream match it does not reopen + // has no reopen line to say its pick went, so the door records + // one. 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 @@ -2724,27 +2739,28 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, bm.ID, priorPicks) } } - return reopened, inherited, nil + return reopened, inherited, repPickClears(before, bracket), 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 @@ -2946,9 +2962,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 { @@ -3452,6 +3469,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 @@ -3489,6 +3509,7 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName // the next round. priorWinner, priorWinnerID := propagatedWinnerOf(bracket, rIdx, mIdx, m) priorPicks := snapshotDownstreamRepPicks(bracket, rIdx, mIdx) + before := repPickSnapshot(bracket, matchId) setBracketOverrideWinner(m, winnerName) m.IsOverridden = true m.Status = state.MatchStatusCompleted @@ -3506,12 +3527,14 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName if fo.Force && winnerActuallyChanged(priorWinner, priorWinnerID, m) { reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, m.ID, priorPicks) } + clears = repPickClears(before, bracket) 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) { @@ -3549,6 +3572,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_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/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 0d6be010b..87509aa75 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -1899,11 +1899,13 @@ func enforceSelfRunPolicy(c *gin.Context, tl TournamentLoader, verifier Password return "self-reported", true } -// selfRunRefusal is a refusal of an anonymous self-run write: the status of -// the response, its code (the body's error) and the sentence the public page -// shows as it is (the body's message). The sentence is written where the -// refusal is decided, so the handler that sends it never works the case out -// again. +// selfRunRefusal is a refusal of a score write the handler judged under the +// write's lock: an anonymous self-run write, or the organiser's representative +// pick (judgeRepPicksUnderTx, which answers the same refusal as a +// participant's). It carries the status of the response, its code (the body's +// error) and the sentence the page shows as it is (the body's message). The +// sentence is written where the refusal is decided, so the handler that sends +// it never works the case out again. type selfRunRefusal struct { status int code string @@ -2101,17 +2103,20 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res return nil } -// errRepMemberNotInTeam refuses a participant's representative pick that names a -// member the side's team does not hold (400 team_member_not_in_team). The lineup -// refusal (errLineupMemberNotInTeam) says the same thing about a fielded position. +// errRepMemberNotInTeam refuses a representative pick, a participant's or the +// organiser's (judgeRepPicksUnderTx), that names a member the side's team does +// not hold (400 team_member_not_in_team). The lineup refusal +// (errLineupMemberNotInTeam) says the same thing about a fielded position. var errRepMemberNotInTeam = &selfRunRefusal{ status: http.StatusBadRequest, code: codeTeamMemberNotInTeam, message: "The representative chosen is not on this team. Pick again from the list.", } -// repRowRefusal judges what a participant's representative bout row would -// change, as the merge will order it (bc-mrgc): each side's representative +// repRowRefusal judges what a representative bout row would change, a +// participant's (holdSelfReportedWriteUnderTx) or the organiser's +// (judgeRepPicksUnderTx), as the merge will order it (bc-mrgc): each side's +// representative // (state.GroupRepPickA, state.GroupRepPickB), side by side. A side's change the // write does not name, or one the merge holds because its stamp is strictly // older than the stored change's (domain.ApplyByTimestamp: an equal stamp @@ -2142,11 +2147,65 @@ func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result return repMembersOutsideTeams(stx, compID, snap.Pairing, row, stored, judgeA, judgeB) } +// judgeRepPicksUnderTx judges the representative picks of an organiser's score +// write exactly as a participant's are judged (repRowRefusal: the same code and +// the same refusal, 400 team_member_not_in_team), so the organiser and a +// participant never disagree about which pick is valid. +// +// The decision (the operator judges it): the lineup's organiser exemption +// (handlers_lineup.go, mp-q722) is about WHEN the organiser may write, after the +// match finished, and that URL names the team, so the organiser is explicitly +// writing that team's lineup. A representative pick names a SIDE, whose team a +// correction elsewhere can change under an open editor (seatBracketSide), and +// the bare member id is resolved later by every surface through the side's team +// (ReconcileWinnerMemberID, resolveBoutSideDisplayName, the export), where an id +// the team does not hold shows as nobody. The stamp order already holds a pick +// made before the re-seat cleared it; this is the pick stamped after the clear +// from an organiser editor that has not yet received the re-seat. +// +// What is judged is what the write introduces (repRowRefusal's scope): a side +// the write names whose pick the merge will apply, a member the stored row does +// not already hold, against a side that carries a team id. The winner id is not +// judged (the merge reconciles it). Only team membership is read, never the +// match's status, so the organiser can pick again at once, on a finished match +// too (isMatchFinalized is a participant's gate and is not asked here). +// +// A match with no representative bout is judged against an empty row: the +// organiser's write can create the row (the participant's never does), and every +// pick on it is then introduced. A write whose row names no member reads +// nothing, not even the stored match. Bulk-score and quick-score do not come +// through here: they are main-gated, server-built writes. +// +// Called under the write's lock, where the participant's judge is, before the +// correction audit. +func judgeRepPicksUnderTx(stx state.StoreTx, compID, matchID string, result *state.MatchResult) error { + i := state.DaihyosenSubIndex(result.SubResults) + if i < 0 { + return nil + } + row := result.SubResults[i] + if row.SideAMemberID == "" && row.SideBMemberID == "" { + return nil + } + snap, found, err := matchSnapshotOrErr(stx, compID, matchID, "representative pick") + if err != nil { + return err + } + if !found { + return nil // the write that follows under the same lock refuses an unknown match + } + stored := snap.RepBout + if stored == nil { + stored = &state.SubMatchResult{} + } + return repRowRefusal(stx, compID, snap, result, row, stored) +} + // repMembersOutsideTeams judges the member ids a representative bout row names // against the team that holds each side. Ids are bare UUIDs, so the team's own // squad is the only thing that says whose they are: a pick of another team's -// member, or of an id no team holds, is refused rather than written. The -// organiser's write is not judged here (only the self-run path calls this). +// member, or of an id no team holds, is refused rather than written, whoever +// sends it (the participant's judge and the organiser's both come here). // // A write answers for what it introduces, not for what it inherited (the write // guard's rule): a side is judged only when the caller says the merge will @@ -2156,9 +2215,10 @@ func repRowRefusal(stx state.StoreTx, compID string, snap matchSnapshot, result // against, so it is not refused. The winner id is the bout's, not the picks', // and is not judged at all (repRowRefusal). // -// stored is the match's representative bout and is never nil: the caller answers -// a row the match does not have before it gets here (dropped or refused), so this -// only judges a row the match already has. +// stored is the match's representative bout and is never nil: for a participant +// the caller answers a row the match does not have before it gets here (dropped +// or refused), and for the organiser, whose write can create the row, it is an +// empty row, so every pick on it is introduced. func repMembersOutsideTeams(stx state.StoreTx, compID string, pairing domain.WinnerAttribution, row state.SubMatchResult, stored *state.SubMatchResult, judgeA, judgeB bool) error { storedA, storedB := stored.SideAMemberID, stored.SideBMemberID type judged struct{ teamID, memberID string } @@ -2240,8 +2300,9 @@ type matchSnapshot struct { // RepBout is a copy of the stored representative-bout row (position -1), // nil when the match has none: the self-run judge compares a // participant's write against it, and writes back a deep copy of it once - // the organiser has decided it (holdSelfReportedWriteUnderTx). One row, - // not the bout log the note above keeps out. + // the organiser has decided it (holdSelfReportedWriteUnderTx); the + // organiser's picks are judged against it too (judgeRepPicksUnderTx). One + // row, not the bout log the note above keeps out. RepBout *state.SubMatchResult // Pairing is the stored match's attribution (its sides, their ids and its // winner), which the self-run judge reads a participant's winner against @@ -2253,9 +2314,9 @@ type matchSnapshot struct { ResultStamp int64 // RepPickAStamp and RepPickBStamp are the stored stamps of each side's // representative (state.RepPickGroup, read through GroupStamp, so a legacy - // match reads its ModifiedAt), a change of its own. The self-run judge - // orders a side's pick against that side's stamp: the merge holds a pick - // stamped before it, so such a pick is not judged. + // match reads its ModifiedAt), a change of its own. The pick judges + // (self-run and organiser) order a side's pick against that side's stamp: + // the merge holds a pick stamped before it, so such a pick is not judged. RepPickAStamp, RepPickBStamp int64 } @@ -2956,7 +3017,10 @@ func scoreResponseWithReopened(result *state.MatchResult, reopened []engine.Reop // a newer change to each is stored (or, with heldReason "needs_winner", // because applying them would have left the match without the winner it // needs, R4); displacedGroups, stored newer scoring this write moved to the -// history (S2 with R4); heldReason itself; and, when heldReason is +// history (S2 with R4, answered with heldReason "needs_winner"), or a +// representative pick stamped after the representative bout's removal that the +// removal moved (answered with NO heldReason); heldReason itself; and, when +// heldReason is // "default_win_stands", heldDecision naming the decision that closed the // match. The ONE owner of those four keys: the score answer, the // representative-bout add and remove (handlers_daihyosen.go) and each @@ -3291,6 +3355,15 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti engErr = err return nil } + } else if !body.StartOnly { + // The organiser's write is judged on the representative + // picks it introduces and on nothing else (judgeRepPicksUnderTx). + // A start keeps the stored score whatever it sends, so the + // row it carries is no pick. + if err := judgeRepPicksUnderTx(stx, id, mid, result); err != nil { + engErr = err + return nil + } } // Correction audit: a completed -> completed overwrite requires a // non-empty CorrectionReason for traceability. Ending a match diff --git a/internal/mobileapp/organiser_rep_pick_judge_test.go b/internal/mobileapp/organiser_rep_pick_judge_test.go new file mode 100644 index 000000000..95661cf0b --- /dev/null +++ b/internal/mobileapp/organiser_rep_pick_judge_test.go @@ -0,0 +1,298 @@ +package mobileapp + +// A representative pick is judged for every caller (PR #463, round 13, gap H). +// +// The pick is a bare member id on a side whose team a correction elsewhere can +// change under an open editor (the re-seat), and every surface resolves it later +// through the side's team, so an id that team does not hold shows as nobody. +// The organiser's write is therefore judged exactly as a participant's is +// (400 team_member_not_in_team), scoped the same way: only a side the merge will +// apply, only an id the stored row does not already hold, only against a side +// that carries a team id. The organiser's rights are untouched: they can pick +// again at once, on a finished match too (only team membership is read). +// +// The fixture is repBoutFixture (self_run_daihyosen_test.go); every request here +// carries the main password, in a self-run tournament and in an officiated one. + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// organiserPassword is the main password (handlers_lineup_changed_test.go). +const ( + // unheldMemberID is a well-formed id no team holds. + unheldMemberID = "99999999-9999-4999-8999-999999999999" + notOnTeamBody = "The representative chosen is not on this team. Pick again from the list." +) + +var organiserModes = []struct { + name string + selfRun bool +}{ + {"self-run tournament, organiser password", true}, + {"officiated tournament", false}, +} + +// organiserPickFixture is a repBoutFixture whose representative bout the +// organiser added, with each team's members read once. +type organiserPickFixture struct { + *repBoutFixture + membersA, membersB []string +} + +func newOrganiserPickFixture(t *testing.T, selfRun bool) *organiserPickFixture { + t.Helper() + f := newRepBoutFixture(t, selfRun) + w := f.send(http.MethodPost, repBoutMatchPath+"/daihyosen", organiserPassword, map[string]any{"modifiedAt": f.now}) + require.Equal(t, http.StatusOK, w.Code, "the organiser adds the representative bout: %s", w.Body.String()) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + ids := func(teamID string) []string { + var out []string + for _, m := range squads[teamID] { + out = append(out, m.ID) + } + return out + } + p := &organiserPickFixture{repBoutFixture: f, membersA: ids(repBoutTeamAID), membersB: ids(repBoutTeamBID)} + require.GreaterOrEqual(t, len(p.membersA), 2, "team A is seeded with members") + require.GreaterOrEqual(t, len(p.membersB), 2, "team B is seeded with members") + return p +} + +// sheetWith is the team sheet with the representative row naming a on side A +// and b on side B ("" names no one), stamped at, naming the groups in changed +// (nil names none, as an older client sends it). +func sheetWith(status state.MatchStatus, winner string, at int64, changed []string, row map[string]any, a, b string) map[string]any { + row["sideAMemberId"], row["sideBMemberId"] = a, b + sheet := scoreSheet(status, winner, at, row) + if changed != nil { + sheet["changed"] = changed + } + return sheet +} + +// pick sends a running sheet from the organiser. +func (p *organiserPickFixture) pick(at int64, changed []string, a, b string) *httptest.ResponseRecorder { + row := repBoutRow([]string{}, []string{}, "") + return p.send(http.MethodPut, repBoutMatchPath+"/score", organiserPassword, + sheetWith(state.MatchStatusRunning, "", at, changed, row, a, b)) +} + +func requireNotOnTeam(t *testing.T, w *httptest.ResponseRecorder) { + t.Helper() + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", notOnTeamBody) +} + +// An organiser's pick of a member the side's team does not hold is refused like +// a participant's, and nothing is written: not the id, not the pick's stamp. +func TestOrganiser_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { + for _, mode := range organiserModes { + t.Run(mode.name, func(t *testing.T) { + cases := []struct { + name string + changed []string + a, b func(p *organiserPickFixture) string + }{ + {"another team's member on side A", []string{state.GroupRepPickA}, + func(p *organiserPickFixture) string { return p.membersB[0] }, nil}, + {"a member no team holds on side A", []string{state.GroupRepPickA}, + func(*organiserPickFixture) string { return unheldMemberID }, nil}, + {"another team's member on side B", []string{state.GroupRepPickB}, nil, + func(p *organiserPickFixture) string { return p.membersA[0] }}, + {"an older client that names no groups", nil, + func(p *organiserPickFixture) string { return p.membersB[0] }, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := newOrganiserPickFixture(t, mode.selfRun) + var a, b string + if tc.a != nil { + a = tc.a(p) + } + if tc.b != nil { + b = tc.b(p) + } + before := storedB1(t, p.store, "c1") + + w := p.pick(p.now+100, tc.changed, a, b) + requireNotOnTeam(t, w) + after := storedB1(t, p.store, "c1") + assert.Equal(t, *p.storedRepBout(t), before.SubResults[state.DaihyosenSubIndex(before.SubResults)], + "a refused pick writes nothing") + assert.Equal(t, before.GroupStamp(state.GroupRepPickA), after.GroupStamp(state.GroupRepPickA)) + assert.Equal(t, before.GroupStamp(state.GroupRepPickB), after.GroupStamp(state.GroupRepPickB)) + assert.Empty(t, p.storedRepBout(t).SideAMemberID) + assert.Empty(t, p.storedRepBout(t).SideBMemberID) + + // The organiser's rights are untouched: their own team's + // members are accepted at once. + w = p.pick(p.now+200, tc.changed, p.membersA[0], p.membersB[0]) + require.Equal(t, http.StatusOK, w.Code, "each side's own member is accepted: %s", w.Body.String()) + stored := p.storedRepBout(t) + if tc.changed == nil || tc.changed[0] == state.GroupRepPickA { + assert.Equal(t, p.membersA[0], stored.SideAMemberID) + } + if tc.changed == nil || tc.changed[0] == state.GroupRepPickB { + assert.Equal(t, p.membersB[0], stored.SideBMemberID) + } + }) + } + }) + } +} + +// The merge holds a pick stamped before the stored pick's, and a write whose +// every group is held is answered superseded: the judge must not refuse what the +// merge was going to keep in the history anyway. +func TestOrganiser_AStalePickTheMergeHoldsIsNotRefused(t *testing.T) { + for _, mode := range organiserModes { + t.Run(mode.name, func(t *testing.T) { + p := newOrganiserPickFixture(t, mode.selfRun) + w := p.pick(p.now, nil, p.membersA[0], "") + require.Equal(t, http.StatusOK, w.Code, "a valid pick: %s", w.Body.String()) + before := *p.storedRepBout(t) + require.Equal(t, p.membersA[0], before.SideAMemberID) + + w = p.pick(p.now-100, nil, p.membersB[0], "") + require.Equal(t, http.StatusOK, w.Code, "a write stamped before the stored pick is held, not refused: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), `"superseded"`) + assert.Equal(t, before, *p.storedRepBout(t), "the held pick is not written") + + requireNotOnTeam(t, p.pick(p.now, nil, p.membersB[0], "")) + requireNotOnTeam(t, p.pick(p.now+100, nil, p.membersB[0], "")) + assert.Equal(t, before, *p.storedRepBout(t), "a refused pick writes nothing") + }) + } +} + +// A write answers for what it introduces, not for what it inherited: a stored +// id the side's team does not hold (a re-seat left it) is echoed back by an +// editor that has not seen the re-seat, and that is not a pick. +func TestOrganiser_AnInheritedPickIsNotJudged(t *testing.T) { + for _, mode := range organiserModes { + t.Run(mode.name, func(t *testing.T) { + p := newOrganiserPickFixture(t, mode.selfRun) + p.setB1(t, func(bm *state.BracketMatch) { + bm.SubResults[state.DaihyosenSubIndex(bm.SubResults)].SideAMemberID = p.membersB[0] + }) + + w := p.pick(p.now+200, nil, p.membersB[0], "") + require.Equal(t, http.StatusOK, w.Code, "the echo of the stored pick is kept: %s", w.Body.String()) + assert.Equal(t, p.membersB[0], p.storedRepBout(t).SideAMemberID) + + w = p.pick(p.now+250, nil, p.membersB[0], p.membersB[1]) + require.Equal(t, http.StatusOK, w.Code, "an echo beside a valid new pick: %s", w.Body.String()) + assert.Equal(t, p.membersB[0], p.storedRepBout(t).SideAMemberID) + assert.Equal(t, p.membersB[1], p.storedRepBout(t).SideBMemberID) + + requireNotOnTeam(t, p.pick(p.now+300, nil, p.membersB[1], p.membersB[1])) + assert.Equal(t, p.membersB[0], p.storedRepBout(t).SideAMemberID, "a refused pick writes nothing") + }) + } +} + +// Only a side the write names is judged, as for a participant: an echo of an id +// the side's team no longer holds beside the named side is not refused. +func TestOrganiser_ASideTheWriteDoesNotNameIsNotJudged(t *testing.T) { + p := newOrganiserPickFixture(t, true) + w := p.pick(p.now+300, []string{state.GroupRepPickA}, p.membersA[0], p.membersA[1]) + require.Equal(t, http.StatusOK, w.Code, "side B is not named, so it is not judged: %s", w.Body.String()) + assert.Equal(t, p.membersA[0], p.storedRepBout(t).SideAMemberID, "the named side applies") + assert.Empty(t, p.storedRepBout(t).SideBMemberID, "and the side it did not name is not written") + + requireNotOnTeam(t, p.pick(p.now+400, []string{state.GroupRepPickA, state.GroupRepPickB}, p.membersA[0], p.membersA[1])) +} + +// A pick on a side with no team id has nothing to be judged against. +func TestOrganiser_APickOnAnIdlessSideIsNotJudged(t *testing.T) { + p := newOrganiserPickFixture(t, true) + p.setB1(t, func(bm *state.BracketMatch) { bm.SideAID = "" }) + + w := p.pick(p.now+200, nil, p.membersB[0], "") + require.Equal(t, http.StatusOK, w.Code, "the pick on an id-less side is accepted: %s", w.Body.String()) + assert.Equal(t, p.membersB[0], p.storedRepBout(t).SideAMemberID) +} + +// A pick on a finished match is judged like any other, and an invalid one is +// refused for the pick, never for the finish (409 result_finalized is a +// participant's refusal; the organiser corrects a finished match). +func TestOrganiser_PickOnAFinishedMatchIsJudgedButNotRefusedForTheFinish(t *testing.T) { + for _, mode := range organiserModes { + t.Run(mode.name, func(t *testing.T) { + p := newOrganiserPickFixture(t, mode.selfRun) + finish := sheetWith(state.MatchStatusCompleted, "TeamA", p.now+100, nil, + repBoutRow([]string{"M"}, []string{}, "TeamA"), "", "") + w := p.send(http.MethodPut, repBoutMatchPath+"/score", organiserPassword, finish) + require.Equal(t, http.StatusOK, w.Code, "the organiser finishes the match: %s", w.Body.String()) + require.Equal(t, state.MatchStatusCompleted, storedB1(t, p.store, "c1").Status) + + correct := func(at int64, a, b string) *httptest.ResponseRecorder { + sheet := sheetWith(state.MatchStatusCompleted, "TeamA", at, []string{state.GroupRepPickA, state.GroupRepPickB}, + repBoutRow([]string{"M"}, []string{}, "TeamA"), a, b) + sheet["correctionReason"] = "the wrong fighter was picked" + return p.send(http.MethodPut, repBoutMatchPath+"/score", organiserPassword, sheet) + } + + w = correct(p.now+200, p.membersB[0], "") + requireNotOnTeam(t, w) + assert.Empty(t, p.storedRepBout(t).SideAMemberID, "a refused pick writes nothing") + + w = correct(p.now+300, p.membersA[0], p.membersB[0]) + require.Equal(t, http.StatusOK, w.Code, "the organiser corrects a finished match: %s", w.Body.String()) + assert.Equal(t, state.MatchStatusCompleted, storedB1(t, p.store, "c1").Status) + assert.Equal(t, p.membersA[0], p.storedRepBout(t).SideAMemberID) + assert.Equal(t, p.membersB[0], p.storedRepBout(t).SideBMemberID) + }) + } +} + +// A start keeps the stored score whatever it sends (startOnly), so the row it +// carries is not a pick and is not judged. +func TestOrganiser_StartOnlyIsNotJudged(t *testing.T) { + p := newOrganiserPickFixture(t, true) + sheet := sheetWith(state.MatchStatusRunning, "", p.now+100, nil, + repBoutRow([]string{}, []string{}, ""), p.membersB[0], "") + sheet["startOnly"] = true + + w := p.send(http.MethodPut, repBoutMatchPath+"/score", organiserPassword, sheet) + require.Equal(t, http.StatusOK, w.Code, "a start is not judged on the row it carries: %s", w.Body.String()) + assert.Empty(t, p.storedRepBout(t).SideAMemberID, "and the stored score is kept") +} + +// A write whose representative row names no member reads no team members: with +// the file unreadable it is still answered, while a pick fails closed. +func TestOrganiser_AWriteThatPicksNobodyReadsNoTeamMembers(t *testing.T) { + p := newOrganiserPickFixture(t, true) + require.NoError(t, os.WriteFile( + filepath.Join(p.store.GetFolder(), "competitions", "c1", "team-members.yaml"), + []byte("not: [valid yaml"), 0o600)) + + w := p.pick(p.now+100, nil, "", "") + require.Equal(t, http.StatusOK, w.Code, "no pick, nothing to judge: %s", w.Body.String()) + + w = p.pick(p.now+200, nil, p.membersA[0], "") + assert.Equal(t, http.StatusInternalServerError, w.Code, "a pick that cannot be judged fails closed: %s", w.Body.String()) + assert.Empty(t, p.storedRepBout(t).SideAMemberID) +} + +// The organiser's write can create the representative row (a participant's +// never does), and every pick on a row it creates is introduced: a member no +// team holds is refused there too, and no row is created. +func TestOrganiser_APickOnARowTheWriteCreatesIsJudged(t *testing.T) { + f := newRepBoutFixture(t, true) + sheet := sheetWith(state.MatchStatusRunning, "", f.now+100, nil, + repBoutRow([]string{}, []string{}, ""), unheldMemberID, "") + + w := f.send(http.MethodPut, repBoutMatchPath+"/score", organiserPassword, sheet) + requireNotOnTeam(t, w) + assert.False(t, carriesDaihyosenRow(storedB1(t, f.store, "c1").SubResults), "a refused write creates nothing") +} diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index cede353a6..36c6a8b36 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -986,8 +986,12 @@ func TestSelfRun_RepresentativeMembersMustBeOnTheirTeam(t *testing.T) { assert.Equal(t, membersA[0].ID, stored.SideAMemberID) assert.Equal(t, membersB[0].ID, stored.SideBMemberID) + // The organiser's pick is judged the same way (organiser_rep_pick_judge_test.go): + // another team's member is refused, the side's own is accepted at once. w = f.score("main-pw", state.MatchStatusRunning, "", f.now+300, pick(membersB[0].ID, membersB[0].ID)) - assert.Equal(t, http.StatusOK, w.Code, "the organiser's pick is not judged on membership: %s", w.Body.String()) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", "The representative chosen is not on this team. Pick again from the list.") + w = f.score("main-pw", state.MatchStatusRunning, "", f.now+400, pick(membersA[1].ID, membersB[1].ID)) + assert.Equal(t, http.StatusOK, w.Code, "the organiser's own-team pick is accepted: %s", w.Body.String()) } // The merge holds a pick whose stamp is strictly older than the stored stamp of diff --git a/specs/openapi.yaml b/specs/openapi.yaml index e59d7e922..0cb5c39d9 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -52,7 +52,8 @@ info: write and the rest is written as usual, unless the write finishes the match, which may take its winner from that row and is refused with 409 `no_daihyosen`; a write listing two is refused with 400 `duplicate_daihyosen`, a representative pick that names a member the - side's team does not hold is refused with 400 `team_member_not_in_team`. The row's winner member id + side's team does not hold is refused with 400 `team_member_not_in_team` (the organiser's + picks are judged the same way, see `PUT .../score`). The row's winner member id is not judged: the merge keeps it naming one of the two stored representatives. A write stamped before a stored representative is not judged for that side's pick: the merge holds the pick, which is not written and is kept in the match's history. The rest of the write is @@ -2405,8 +2406,15 @@ paths: tie, an engi count that is not valid), the finish is applied on its own scoreline, as in stamp order, and that newer scoring is moved to the match's history as held; the answer names it in - `displacedGroups`, with the same `heldReason`. A finish that carries - no scoreline of its own is held instead. A points change whose write does not apply + `displacedGroups`, with `heldReason: needs_winner`. A finish that carries + no scoreline of its own is held instead. A write that removes the + representative bout (a write that names it and carries no row, such + as the daihyosen DELETE) likewise moves a representative pick + (`repPickA`, `repPickB`) stamped AFTER the removal to the history, + since in stamp order the pick landed on a bout that no longer + existed; the answer names it in `displacedGroups` with NO + `heldReason` (it is no question of the winner), and the history + keeps it with the reason "the representative bout was removed". A points change whose write does not apply `result` keeps a recorded judges' decision (hantei mark). A scoring change made on a running board after a recorded kiken, fusenpai, or fusensho clears it (points scored mean the withdrawal was a mistake), and the competitor @@ -2431,7 +2439,11 @@ paths: An applied finish that moved stored, NEWER scoring into the match's history (it would have left the match without a winner, and in stamp order came after this finish) carries - `displacedGroups` naming those groups, with the same `heldReason`. + `displacedGroups` naming those groups, with `heldReason: + needs_winner`. An applied removal of the representative bout that + moved a representative pick stamped after it into the history + carries `displacedGroups` naming the pick's group (`repPickA` or + `repPickB`) with no `heldReason`. A running write the rev-guard finds older than one the same client session already sent (a lower rev, e.g. an out-of-order reconnect flush) is not applied and not dropped: the whole write is kept in @@ -2484,12 +2496,19 @@ paths: participant: "A team match has one representative bout, and this score lists more than one. Check the scores and try again." - The same request with an empty password is refused with `error: - team_member_not_in_team`, with a `message` to show, when its - representative bout (position `-1`) names a member the side's team - does not hold (a pick the stored row does not already hold). The - `message` is "The representative chosen is not on this team. Pick - again from the list." The row's winner member id is not judged: the + A request whose representative bout (position `-1`) names a member + the side's team does not hold (a pick the stored row does not + already hold) is refused with `error: team_member_not_in_team`, with + a `message` to show, whoever sends it: a participant (an empty + password) or the organiser, so the two never disagree about which + pick is valid. The `message` is "The representative chosen is not + on this team. Pick again from the list." The organiser is judged on + that pick and on nothing else of the row: the match's status is not + asked (a pick on a finished match is judged, never refused for the + finish, and the organiser can pick again at once), a start + (`startOnly`) is not judged, and a match with no representative bout + is judged against an empty row, since the organiser's write can + create it. The row's winner member id is not judged: the merge keeps it naming one of the two stored representatives, or empty. A write whose stamp is strictly before a stored representative is not judged for that side's pick: the merge holds the pick, so it is not @@ -2497,8 +2516,7 @@ paths: so it is judged). The rest of the write is ordered by its own stamps, so it is applied and answered with `heldGroups` naming the held side's group, e.g. `["repPickA"]`; it is answered as superseded, with nothing - written, only when every group it changes is held. The organiser's - picks are not checked. + written, only when every group it changes is held. A `completed` write whose `decision` is empty or `hikiwake` on a match whose stored result is a completed withdrawal (kiken or @@ -2534,12 +2552,12 @@ paths: error: type: string description: > - The reason the payload was refused, or for the self-run refusals - the machine-readable code (`duplicate_daihyosen`, - `team_member_not_in_team`). + The reason the payload was refused, or for the refusals that + carry a `message` the machine-readable code + (`duplicate_daihyosen`, `team_member_not_in_team`). message: type: string - description: The sentence the public page shows as it is, for the self-run refusals. + description: The sentence the page shows as it is, for those refusals. '401': description: > `invalid tournament password`: the password is missing or wrong in an @@ -3742,6 +3760,11 @@ paths: with no id (an unresolved feeder or a bye) has nothing to bar it. + The new representative-bout row is stored with the match's team names + as `sideA` and `sideB`, so it is never nameless between the add and the + first score write. + + The add competes on timestamps like a score write when it carries `modifiedAt`: the match it writes takes that stamp, so a copy of the match read before the add is older than the one it returns. @@ -3893,7 +3916,9 @@ paths: `clock_skew`); nothing is applied to the match and no SSE event is emitted. A superseded remove carries `heldGroups` and is kept in the match's history. The removed bout keeps its stamp, so an older - write still carrying it cannot bring it back. + write still carrying it cannot bring it back. An applied remove may + carry `displacedGroups` with no `heldReason`: a representative pick + made after the removal was moved to the match's history. content: application/json: schema: @@ -3911,6 +3936,14 @@ paths: Present when the remove applied in part: the groups held and kept in the match's history. As RefusedWrite.heldGroups. + displacedGroups: + type: array + items: + $ref: '#/components/schemas/MergeGroup' + description: > + Present when a representative pick stamped after the + removal was moved to the match's history, with no + `heldReason`. As MatchResult.displacedGroups. heldReason: type: string enum: [needs_winner, default_win_stands] @@ -5334,9 +5367,12 @@ components: door: type: string description: > - What made the write (`displaced`: a stored change a finish made - before it moved to the history, see MatchResult.displacedGroups): - score, bulk-score, quick-score, decision, + What made the write (`displaced`: a stored change that a finish or + a removal of the representative bout, arriving after it but stamped + earlier, moved to the history, see MatchResult.displacedGroups; + `reseat`: a side of the match was given another team by a + correction to an earlier match, and the side's representative pick + went with it): score, bulk-score, quick-score, decision, daihyosen-add, daihyosen-remove, reopen, requeue, override-winner, engi, kachinuki-advance, kachinuki-remove-bout, downstream-reopen, or engine for a write the server made itself. @@ -5385,9 +5421,11 @@ components: for a match one of those decisions already closed; the answer carries `heldReason: default_win_stands` and `heldDecision`), "a knockout match needs a winner" (a change that would leave a - finished knockout match tied), or "an engi result needs a valid + finished knockout match tied), "an engi result needs a valid flag count" (a recount that would leave a finished engi match with - none). Absent when each group was judged by its own stamp. + none), or "the representative bout was removed" (a representative + pick stamped after the bout's removal, moved here by the removal). + Absent when each group was judged by its own stamp. clearedWithdrawal: type: object description: The kiken, fusenpai, or fusensho a later scoring change cleared (the result group as it was), when one was. @@ -6284,6 +6322,9 @@ components: needs, or because a decision (a kiken or a fusenpai of this match, or a fusensho awarded for a bar recorded on a DIFFERENT match) already closed the match (see RefusedWrite.heldReason). + Absent when the groups were held by their stamps alone, and + absent beside a `displacedGroups` that names a representative + pick moved by a removal of the representative bout. heldDecision: type: string readOnly: true @@ -6295,11 +6336,16 @@ components: $ref: '#/components/schemas/MergeGroup' description: > On a write's response only: groups whose STORED values this - (applied) finish moved to the match's history, held. They were - changed after the finish was made and would have left the match - without the winner it needs, so in stamp order they could not - apply after it. The history keeps them, at their own stamp, with - door `displaced`. + (applied) write moved to the match's history, held. The history + keeps them, at their own stamp, with door `displaced`. Two cases. + A finish moves scoring changed after the finish was made that + would have left the match without the winner it needs, so in + stamp order it could not apply after it; the answer then carries + `heldReason: needs_winner`. A removal of the representative bout + moves a representative pick (`repPickA`, `repPickB`) stamped after + the removal, since in stamp order the pick landed on a bout that + no longer existed; the answer then carries NO `heldReason`, and + the history reason is "the representative bout was removed". sideA: type: string maxLength: 100 diff --git a/web-mobile/check-write-result.mjs b/web-mobile/check-write-result.mjs index 0218b10f5..0c1d86a1c 100644 --- a/web-mobile/check-write-result.mjs +++ b/web-mobile/check-write-result.mjs @@ -98,7 +98,7 @@ export const FORBIDDEN = [ // whole rule exists to prevent. A copy constant read off `window` is worse // still: it renders as the literal text "undefined" in an operator banner. // Import from write_result.jsx; do not re-add a mirror. - re: /window\.(writeDidNotLand|writeWasSuperseded|writeWasRefused|writeWasRefusedForClock|writeRetryable|notLandedBanner|SUPERSEDED_LEAD|SUPERSEDED_REASON|SUPERSEDED_ADVICE|supersededAlertText|writeHeldGroups|writePartlyHeld|CLOCK_SKEW_REASON_TEXT|CLOCK_SKEW_ADVICE|QUEUED_NOTICE|QUEUED_UNSAVED_NOTICE|queuedNotice|queuedWritesNoun|heldWritesText|writeNeedsWinner|writeDisplacedGroups|supersededBanner|displacedAlertText)\b/, + re: /window\.(writeDidNotLand|writeWasSuperseded|writeWasRefused|writeWasRefusedForClock|writeRetryable|notLandedBanner|SUPERSEDED_LEAD|SUPERSEDED_REASON|SUPERSEDED_ADVICE|supersededAlertText|writeHeldGroups|writePartlyHeld|CLOCK_SKEW_REASON_TEXT|CLOCK_SKEW_ADVICE|QUEUED_NOTICE|QUEUED_UNSAVED_NOTICE|queuedNotice|queuedWritesNoun|heldWritesText|writeNeedsWinner|writeDisplacedGroups|writeDisplacedForWinner|supersededBanner|displacedAlertText)\b/, why: 'reads an owned predicate/copy off window; those mirrors are deleted, import from write_result.jsx instead', }, { diff --git a/web-mobile/js/__tests__/queue_alert_message.test.jsx b/web-mobile/js/__tests__/queue_alert_message.test.jsx index c19f87c71..5e61cfea3 100644 --- a/web-mobile/js/__tests__/queue_alert_message.test.jsx +++ b/web-mobile/js/__tests__/queue_alert_message.test.jsx @@ -64,11 +64,30 @@ describe('queueAlertMessage', () => { // A held finish landed and moved a later change of its match to the // history: recorded, so information, not an error. it('a held finish that moved a later change aside: saved, as information', () => { - const alert = { kind: 'displaced', count: 1, terminalCount: 1 }; + const alert = { kind: 'displaced', count: 1, terminalCount: 1, forWinner: 1 }; expect(queueAlertMessage(alert)).toMatch(/^A held result was saved\./); expect(queueAlertToastType(alert)).toBe('info'); }); + // The reason is per replayed answer: the alert says "without a winner" + // only for the changes the server moved for one (forWinner), and plainly + // otherwise (a representative's pick stamped after the bout's removal). + it('names a winner only when the moved change was moved for one', () => { + const none = queueAlertMessage({ kind: 'displaced', count: 1, terminalCount: 1, forWinner: 0 }); + expect(none).toMatch(/^A held result was saved\./); + expect(none).not.toMatch(/without a winner/); + expect(queueAlertMessage({ kind: 'displaced', count: 1, terminalCount: 1, forWinner: 1 })).toMatch(/without a winner/); + // An alert without the count reads as none for a winner. + expect(queueAlertMessage({ kind: 'displaced', count: 1, terminalCount: 1 })).not.toMatch(/without a winner/); + const manyNone = queueAlertMessage({ kind: 'displaced', count: 2, terminalCount: 2, forWinner: 0 }); + expect(manyNone).toMatch(/^2 held results were saved\./); + expect(manyNone).not.toMatch(/without a winner/); + expect(queueAlertMessage({ kind: 'displaced', count: 2, terminalCount: 2, forWinner: 2 })).toMatch(/^2 held results were saved\. .*without a winner/); + expect(queueAlertMessage({ kind: 'displaced', count: 2, terminalCount: 2, forWinner: 1 })).toBe( + "2 held results were saved. Later changes to those matches were moved to each match's history. 1 of those changes would have left a match without a winner."); + expect(queueAlertToastType({ kind: 'displaced', count: 2, terminalCount: 2, forWinner: 0 })).toBe('info'); + }); + it('returns null for an unknown kind, so nothing is toasted', () => { expect(queueAlertMessage({ kind: 'not-a-kind', count: 1, terminalCount: 1 })).toBeNull(); }); diff --git a/web-mobile/js/__tests__/queue_ordered_writes.test.jsx b/web-mobile/js/__tests__/queue_ordered_writes.test.jsx index 30a07e40c..e540005d4 100644 --- a/web-mobile/js/__tests__/queue_ordered_writes.test.jsx +++ b/web-mobile/js/__tests__/queue_ordered_writes.test.jsx @@ -710,6 +710,28 @@ describe('a queued finish that moved a later change to the history', () => { unsubAlert(); expect(failed).toEqual([]); expect(alerts.map((a) => a.kind)).toEqual(['sent', 'displaced']); + expect(alerts.find((a) => a.kind === 'displaced')).toEqual(expect.objectContaining({ count: 1, forWinner: 1 })); + expect(localStorage.getItem('bc_write_queue')).toBeNull(); + }); + + // A change can also be moved for another reason (a representative's pick + // stamped after the representative bout's removal): the answer carries no + // heldReason, and the alert counts it as moved, not as moved for a winner. + it('a change moved with no reason is told as moved, not as moved for a winner', async () => { + const s = server({ answer: () => ({ id: 'm1', status: 'completed', displacedGroups: ['repPickB'] }) }); + await API.recordScore('c1', 'm1', { status: 'completed', winner: 'A', ipponsA: ['M'] }, 'pw', null); + await settleOfflinePass(); + const failed = []; + const alerts = []; + const unsubFail = mod.subscribeTerminalWriteFailed((i) => failed.push(i)); + const unsubAlert = mod.subscribeQueueAlert((a) => alerts.push(a)); + s.online(); + await tick(50); + unsubFail(); + unsubAlert(); + expect(failed).toEqual([]); + expect(alerts.map((a) => a.kind)).toEqual(['sent', 'displaced']); + expect(alerts.find((a) => a.kind === 'displaced')).toEqual(expect.objectContaining({ count: 1, forWinner: 0 })); expect(localStorage.getItem('bc_write_queue')).toBeNull(); }); }); diff --git a/web-mobile/js/__tests__/rule_gates.test.jsx b/web-mobile/js/__tests__/rule_gates.test.jsx index 08bc4fffb..2db3c1f10 100644 --- a/web-mobile/js/__tests__/rule_gates.test.jsx +++ b/web-mobile/js/__tests__/rule_gates.test.jsx @@ -127,6 +127,7 @@ describe('the write-result rule is importable without running the gate', () => { // the history are told apart by the owner alone. expect(trips('window.writeNeedsWinner(res)')).toBe(true); expect(trips('window.writeDisplacedGroups(res)')).toBe(true); + expect(trips('window.writeDisplacedForWinner(res)')).toBe(true); expect(trips('{QUEUED_NOTICE}')).toBe(false); expect(trips('if (writeDidNotLand(res)) return;')).toBe(false); }); diff --git a/web-mobile/js/__tests__/write_result_banner.test.jsx b/web-mobile/js/__tests__/write_result_banner.test.jsx index 932895ddb..2e3f1c0f9 100644 --- a/web-mobile/js/__tests__/write_result_banner.test.jsx +++ b/web-mobile/js/__tests__/write_result_banner.test.jsx @@ -34,6 +34,7 @@ import { DEFAULT_WIN_STANDS_ADVICE, supersededAlertText, writeDisplacedGroups, + writeDisplacedForWinner, displacedAlertText, } from '../write_result.jsx'; import { heldGroupsNote, keptInHistoryNote } from '../match_groups.jsx'; @@ -304,6 +305,8 @@ describe('a change held because a decision for a bar elsewhere stands', () => { const all = [ DEFAULT_WIN_STANDS_REASON('fusensho'), DEFAULT_WIN_STANDS_ADVICE('fusensho'), heldGroupsNote(['points'], false, true, 'fusensho'), supersededAlertText(2, false, false, true, 'fusensho'), + displacedAlertText(1, true, 1), displacedAlertText(2, false, 2), displacedAlertText(1, true, 0), + displacedAlertText(2, false, 0), displacedAlertText(3, false, 1), ].join(' '); expect(all).not.toMatch(/—/); expect(all).not.toMatch(/\bmats?\b/i); @@ -356,8 +359,57 @@ describe('a finish recorded that moved a later change to the history', () => { }); it('the queue alert says the held result was saved', () => { - expect(displacedAlertText(1, true)).toMatch(/^A held result was saved\. .*moved to the match's history\.$/); - expect(displacedAlertText(2, false)).toMatch(/^2 held results were saved\./); + expect(displacedAlertText(1, true, 1)).toMatch(/^A held result was saved\. .*moved to the match's history\.$/); + expect(displacedAlertText(2, false, 2)).toMatch(/^2 held results were saved\./); + }); + + // The reason is per ANSWER: only an answer whose heldReason is needs_winner + // moved its change for a winner. A change moved for another reason (a + // representative's pick stamped after the representative bout's removal) + // carries no reason, and a decision that stands (default_win_stands) is + // not a winner reason either. + it('writeDisplacedForWinner is true only for a displaced answer that carries needs_winner', () => { + expect(writeDisplacedForWinner(displaced)).toBe(true); + expect(writeDisplacedForWinner({ ...displaced, heldReason: undefined })).toBe(false); + expect(writeDisplacedForWinner({ id: 'm1', status: 'completed', displacedGroups: ['repPickB'] })).toBe(false); + expect(writeDisplacedForWinner({ ...displaced, heldReason: 'default_win_stands' })).toBe(false); + expect(writeDisplacedForWinner({ id: 'm1', status: 'completed', heldReason: 'needs_winner' })).toBe(false); + expect(writeDisplacedForWinner({ ...displaced, displacedGroups: [] })).toBe(false); + expect(writeDisplacedForWinner(null)).toBe(false); + expect(writeDisplacedForWinner(undefined)).toBe(false); + }); + + it('a change moved for another reason is worded plainly, never with a winner the match does not lack', () => { + const removed = { id: 'm1', status: 'completed', displacedGroups: ['repPickB'] }; + expect(keptInHistoryNote(removed)).not.toMatch(/without a winner/); + expect(keptInHistoryNote(removed)).toMatch(/^Saved\. A later change to .* was moved to the match's history\.$/); + expect(keptInHistoryNote({ ...removed, heldReason: 'default_win_stands' })).not.toMatch(/without a winner/); + }); + + // The queue alert follows the same rule over the replayed answers of one + // pass: all moved for a winner keeps the winner sentence, none says + // plainly that a later change was moved, and a mix says both. + it('the queue alert names a winner only for the changes that were moved for one', () => { + const FOR_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."; + const FOR_MANY = "2 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."; + expect(displacedAlertText(1, true, 1)).toBe(FOR_ONE); + expect(displacedAlertText(2, false, 2)).toBe(FOR_MANY); + + const NONE_ONE = "A held result was saved. A later change to that match was moved to the match's history."; + const NONE_MANY = "2 held results were saved. Later changes to those matches were moved to each match's history."; + expect(displacedAlertText(1, true, 0)).toBe(NONE_ONE); + expect(displacedAlertText(2, false, 0)).toBe(NONE_MANY); + expect(displacedAlertText(1, true, 0)).not.toMatch(/without a winner/); + expect(displacedAlertText(2, false, 0)).not.toMatch(/without a winner/); + // An alert that says nothing about a winner (an older caller, no + // third argument) reads as none for a winner. + expect(displacedAlertText(1, true)).toBe(NONE_ONE); + expect(displacedAlertText(2, false)).toBe(NONE_MANY); + + expect(displacedAlertText(3, false, 1)).toBe( + "3 held results were saved. Later changes to those matches were moved to each match's history. 1 of those changes would have left a match without a winner."); + expect(displacedAlertText(3, false, 2)).toBe( + "3 held results were saved. Later changes to those matches were moved to each match's history. 2 of those changes would have left a match without a winner."); }); }); diff --git a/web-mobile/js/admin.jsx b/web-mobile/js/admin.jsx index 9a1b470fb..10bbb4e14 100644 --- a/web-mobile/js/admin.jsx +++ b/web-mobile/js/admin.jsx @@ -17,8 +17,8 @@ const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; // closingHistoryToast (bc-mrgc): the toast editMatchScore shows for a write // that landed but kept something in the match's history (part of it held, or -// a later change it moved there because that change would have left the -// finished match without a winner), worded by keptInHistoryNote. Only when +// a later change it moved there, worded by the reason the answer gives, if +// any), through keptInHistoryNote. Only when // the write closes the editor: an editor that stays open says so itself // (useKeptInHistoryNote). A write that did not land keeps its editor open // too (writeKeepsEditorOpen), where its own banner reports it. null when diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index 44042b46e..d5f4a865a 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, 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/match_groups.jsx b/web-mobile/js/match_groups.jsx index 3a029be27..438e6c948 100644 --- a/web-mobile/js/match_groups.jsx +++ b/web-mobile/js/match_groups.jsx @@ -26,8 +26,8 @@ import { IPPON_PLACEHOLDER } from './result_slot.jsx'; import { - HELD_REASON_NEEDS_WINNER, NEEDS_WINNER_NOTE, DEFAULT_WIN_STANDS_NOTE, writeNeedsWinner, writeDefaultWinStands, writeHeldDecision, - writePartlyHeld, writeHeldGroups, writeDisplacedGroups, + NEEDS_WINNER_NOTE, DEFAULT_WIN_STANDS_NOTE, writeNeedsWinner, writeDefaultWinStands, writeHeldDecision, + writePartlyHeld, writeHeldGroups, writeDisplacedGroups, writeDisplacedForWinner, } from './write_result.jsx'; export const GROUP_POINTS = 'points'; @@ -119,9 +119,9 @@ 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 (write_result.jsx -// writeDisplacedGroups). `needsWinner` 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 +// 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. @@ -144,7 +144,7 @@ export function keptInHistoryNote(res) { if (writePartlyHeld(res) || writeNeedsWinner(res) || writeDefaultWinStands(res)) { parts.push(heldGroupsNote(writeHeldGroups(res), writeNeedsWinner(res), writeDefaultWinStands(res), writeHeldDecision(res))); } - parts.push(displacedGroupsNote(writeDisplacedGroups(res), !!res && res.heldReason === HELD_REASON_NEEDS_WINNER)); + parts.push(displacedGroupsNote(writeDisplacedGroups(res), writeDisplacedForWinner(res))); const text = parts.filter(Boolean).join(' '); return text || null; } diff --git a/web-mobile/js/match_history_view.jsx b/web-mobile/js/match_history_view.jsx index 1305ba731..d1e943a75 100644 --- a/web-mobile/js/match_history_view.jsx +++ b/web-mobile/js/match_history_view.jsx @@ -45,9 +45,10 @@ 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. diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index 427434144..e2e227697 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.'; From 7180e74b46e00b58856ebbbfbcfb4dbd0877bf94 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 09:51:33 +0100 Subject: [PATCH 29/38] refactor: one owner for the representative-pick snapshot; CLAUDE.md rules The reopen history line and the reseat recorder captured which picks a bracket door cleared through two separate snapshot/diff pairs. They now share repPickSnapshot and repPickClears (bracket_seat_audit.go), and markRepPicksCleared is a pure function over that diff, called once per door. No behaviour change. CLAUDE.md records the rules this PR added: the representative row keeps the team names on the server too (seatBracketSide); the reseat history line; a displacement without heldReason; claim.agree moving both per-group baselines; the organiser's representative picks judged; start_match.jsx as the one start helper for the console and the Scores tab. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 +++--- internal/engine/bracket_seat.go | 60 +-------------------------- internal/engine/bracket_seat_audit.go | 38 +++++++++++++---- internal/engine/engi.go | 4 +- internal/engine/kachinuki.go | 7 ++-- internal/engine/pool_requalify.go | 6 +-- internal/engine/scoring.go | 55 ++++++++++-------------- 7 files changed, 70 insertions(+), 112 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 57d278ebb..0e3c0c2cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,9 +174,9 @@ tournament-data/ - **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. 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 a toast); 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, 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. @@ -286,16 +286,16 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **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`+`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`). `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); 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 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; 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). @@ -332,7 +332,7 @@ Two distinctions that look like details and are not: 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. -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: `judgeRepPicksUnderTx` (handlers_match.go) judges a pick the organiser's write introduces through the same `repRowRefusal`, with the same 400 `team_member_not_in_team` and sentence (a pick names a SIDE, whose team a correction can change under an open editor), never asking the match's status. 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`. diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go index 7e07b3c08..91b16bcbc 100644 --- a/internal/engine/bracket_seat.go +++ b/internal/engine/bracket_seat.go @@ -74,7 +74,8 @@ import ( // 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); a scheduled one is logged here). +// (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 { @@ -159,60 +160,3 @@ func feedsSide(mIdx int) domain.MatchSide { } return domain.MatchSideB } - -// downstreamRepPicks is the representatives held, when it was taken, by each -// match a winner feeds that holds any (the next match past any byes, and the -// 3rd-place match), by match id. -type downstreamRepPicks map[string][2]string - -// snapshotDownstreamRepPicks takes it for the matches the winner of -// bracket.Rounds[rIdx][mIdx] is propagated into. A write that propagates takes -// it BEFORE propagateBracketWinner, so forceReopenDownstreamChain can tell -// which of the matches it reopens lost a pick to the re-seat. -func snapshotDownstreamRepPicks(bracket *state.Bracket, rIdx, mIdx int) downstreamRepPicks { - d := propagatedDownstreamOf(bracket, rIdx, mIdx) - out := downstreamRepPicks{} - for _, m := range []*state.BracketMatch{d.next, d.bronze} { - if m == nil { - continue - } - if a, b := m.RepPicks(); a != "" || b != "" { - out[m.ID] = [2]string{a, b} - } - } - return out -} - -// markRepPicksCleared records, on every reopened match that no longer holds a -// pick it held when its prior picks were captured (at its reopen, or from a -// snapshotDownstreamRepPicks taken before the re-seat), the group of each side -// whose pick went (RepPicksCleared). Call it once the callback's re-seats are -// done: the only writer of a pick inside these callbacks is seatBracketSide, -// which only clears. -func markRepPicksCleared(bracket *state.Bracket, reopened []ReopenedMatch) { - for i := range reopened { - m := bracket.MatchByID(reopened[i].ID) - if m == nil { - continue - } - a, b := m.RepPicks() - r := &reopened[i] - if r.priorPickA != "" && a != r.priorPickA { - r.RepPicksCleared = appendUnique(r.RepPicksCleared, state.RepPickGroup(domain.MatchSideA)) - } - if r.priorPickB != "" && b != r.priorPickB { - r.RepPicksCleared = appendUnique(r.RepPicksCleared, state.RepPickGroup(domain.MatchSideB)) - } - } -} - -// appendUnique appends s to list unless it is already there. markRepPicksCleared -// can judge the same reopened match twice (the reopen door marks the chain -// forceReopenDownstreamChain already marked, after its retraction), and a group -// must be named once. -func appendUnique(list []string, s string) []string { - if slices.Contains(list, s) { - return list - } - return append(list, s) -} diff --git a/internal/engine/bracket_seat_audit.go b/internal/engine/bracket_seat_audit.go index 310dbb5bd..299dcc93f 100644 --- a/internal/engine/bracket_seat_audit.go +++ b/internal/engine/bracket_seat_audit.go @@ -22,13 +22,17 @@ import ( // 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 a -// snapshot of the bracket's picks before it mutates the bracket -// (repPickSnapshot), diffs it afterwards (repPickClears) while it still holds the -// bracket, and records the difference beside the place that already records its -// reopen lines (recordRepPickClears), 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. +// 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. @@ -112,6 +116,26 @@ func repPickClears(before map[string][2]string, b *state.Bracket) []repPickClear 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 diff --git a/internal/engine/engi.go b/internal/engine/engi.go index 77e722bd6..d2f21fb8f 100644 --- a/internal/engine/engi.go +++ b/internal/engine/engi.go @@ -322,15 +322,15 @@ func (e *Engine) recordEngiMatch( return err } priorWinner, priorWinnerID := propagatedWinnerOf(b, rIdx, mIdx, bm) - priorPicks := snapshotDownstreamRepPicks(b, rIdx, mIdx) 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, priorPicks) + reopened = forceReopenDownstreamChain(b, rIdx, mIdx, bm.ID) } clears = repPickClears(before, b) + markRepPicksCleared(reopened, clears) return nil } } diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go index cec14b6dc..d5654ec02 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -1155,15 +1155,16 @@ func (e *Engine) reopenUnderCourtLock(compID string, comp *state.Competition, ma 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. - reopenedDownstream = forceReopenDownstreamChain(h.BracketRoot, h.RIdx, h.MIdx, matchID, nil) + // 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 } - markRepPicksCleared(h.BracketRoot, reopenedDownstream) clears = repPickClears(before, h.BracketRoot) + markRepPicksCleared(reopenedDownstream, clears) } prior := h.Bracket.Decision reopenBracketMatchKeepingTheFight(h.Bracket, reason, targetStatus) diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go index a383c0938..2e720117a 100644 --- a/internal/engine/pool_requalify.go +++ b/internal/engine/pool_requalify.go @@ -316,10 +316,10 @@ func (e *Engine) applyRequalification(tx state.StoreTx, compID, reason string, p } } e.resolveSlots(b, plan.resolver) - // The reopens above captured each match's picks; the repaint may have - // given a side another team and taken its pick with it. - markRepPicksCleared(b, reopened) + // 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 { diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 6202224bd..8d65df015 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -646,12 +646,9 @@ type ReopenedMatch struct { // 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. + // 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 - // priorPickA and priorPickB are the representatives the match held when it - // was reopened (or, for a downstream match a winner is re-propagated into, - // before that write began), for markRepPicksCleared to compare. - priorPickA, priorPickB string } // bracketMatchRef is the ONE way a bracket match becomes a ReopenedMatch, so @@ -2708,15 +2705,13 @@ 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) - // And the representatives the matches it feeds hold before the - // propagation below can re-seat a side and take one away, so the - // reopen of a played one can say its pick went too. - priorPicks := snapshotDownstreamRepPicks(bracket, rIdx, mIdx) - // The same, for every match the write leaves alone but re-seats - // (bracket_seat_audit.go): a downstream match it does not reopen - // has no reopen line to say its pick went, so the door records - // one. A restore (the K3 rollback replaying a prior) is not a door - // and records nothing, so it takes no snapshot. + // 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) @@ -2736,10 +2731,12 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st e.propagateBracketWinner(bracket, rIdx, mIdx) if force && policy == matchWriteForward && winnerActuallyChanged(priorWinner, priorWinnerID, bm) { - reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, bm.ID, priorPicks) + reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, bm.ID) } } - return reopened, inherited, repPickClears(before, bracket), nil + clears := repPickClears(before, bracket) + markRepPicksCleared(reopened, clears) + return reopened, inherited, clears, nil } } // The bronze (3rd-place) knockout lives in Bracket.ThirdPlaceMatch, NOT in @@ -2875,22 +2872,15 @@ func winnerActuallyChanged(priorWinner, priorWinnerID string, bm *state.BracketM // matches were already played"). It carries its own audit note rather than // owing one -- see downstreamReopenReason. // -// before is what snapshotDownstreamRepPicks took ahead of the propagation that -// preceded this call (the score, override and engi doors); the reopen door, -// which retracts afterwards, passes nil and judges the picks itself. Each -// reopened match whose representative went with a re-seated side comes back -// with RepPicksCleared naming that side's group. -func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correctedID string, before downstreamRepPicks) []ReopenedMatch { +// 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() { ref := reopenDisplacedBracketMatch(m, downstreamReopenReason(correctedID)) - // A caller that propagated before this reopen (the score, override - // and engi doors) took the picks the match held before the re-seat; - // the picks it holds now are the ones left after it. - if p, ok := before[m.ID]; ok { - ref.priorPickA, ref.priorPickB = p[0], p[1] - } 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 @@ -2900,7 +2890,6 @@ func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correcte retractIntoUntouched(bracket, d.feed.R+1, d.feed.M/2) } } - markRepPicksCleared(bracket, reopened) return reopened } @@ -2953,8 +2942,8 @@ 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 markRepPicksCleared against these. - ref.priorPickA, ref.priorPickB = m.RepPicks() + // does to them is judged by the door's repPickClears diff, which + // markRepPicksCleared copies onto the reopen line. return ref } @@ -3508,7 +3497,6 @@ 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) - priorPicks := snapshotDownstreamRepPicks(bracket, rIdx, mIdx) before := repPickSnapshot(bracket, matchId) setBracketOverrideWinner(m, winnerName) m.IsOverridden = true @@ -3525,9 +3513,10 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName // nothing downstream to unwind. displacedWinner is this // match's winner as it stood before setBracketOverrideWinner. if fo.Force && winnerActuallyChanged(priorWinner, priorWinnerID, m) { - reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, m.ID, priorPicks) + reopened = forceReopenDownstreamChain(bracket, rIdx, mIdx, m.ID) } clears = repPickClears(before, bracket) + markRepPicksCleared(reopened, clears) return nil } } From 9414c4ba489f4262d0a9a19d32539f06f3c8c54f Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 11:01:56 +0100 Subject: [PATCH 30/38] fix: close the session's remaining review items (PR #463) - Export: a "Representative Bouts" sheet in both engine workbooks for a fixed-order team competition whose draw holds a representative bout, naming each side's representative from its member id ("DH" row, "(DH)" centre), drawn by the Kachinuki Detail renderer in the same match order. - Self-run judge: the pick check asks the merge which picks it applies (engine.RepPicksApplied) instead of re-deriving its rule; a participant's numbered bout rows may seat only their own team's members and name one of the bout's two fighters as winner; an unreadable team-members.yaml is a terminal 409 (team_members_unreadable), not a 500 the queue retries. - Announcements: an id replaced by an identical announcement still withdraws the one that replaced it; the three undocumented announcement routes are in the spec. - Court console and Scores tab: a refused start stays on the refused match until it leaves scheduled or is started, and a refusal for a start still out becomes a past-tense notice when that start lands; a refusal that editMatchScore toasted is not toasted again; winnerSideLR, teamMatchMarks and startPatch are ES imports (side_marks.jsx leaf), window.startPatch is gone. - knockout: resolveSlots paints the fields of the side it names. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 17 +- .../court-operators/recording-decisions.md | 2 + docs/user-guide/organisers/run-tournament.md | 2 +- .../user-guide/organisers/team-tournaments.md | 2 +- internal/engine/bracket_result.go | 8 + internal/engine/export.go | 13 +- internal/engine/kachinuki_export.go | 140 ++++--- internal/engine/knockout.go | 41 +- internal/engine/knockout_test.go | 50 +++ internal/engine/match_merge_rep_picks_test.go | 137 +++++++ internal/engine/rep_pick_probe.go | 62 +++ internal/engine/representative_export.go | 110 ++++++ internal/engine/representative_export_test.go | 356 ++++++++++++++++++ internal/engine/workbook.go | 33 +- internal/export/builder.go | 13 +- internal/export/builder_test.go | 113 ++++++ internal/export/pipeline_parity_test.go | 18 +- internal/helper/constants.go | 12 + internal/helper/excel.go | 7 +- internal/helper/excel_kachinuki.go | 46 ++- internal/helper/excel_kachinuki_test.go | 120 ++++++ .../mobileapp/handlers_announcement_test.go | 76 ++++ internal/mobileapp/handlers_lineup.go | 24 +- internal/mobileapp/handlers_match.go | 291 ++++++++++---- .../organiser_rep_pick_judge_test.go | 65 +++- .../mobileapp/self_run_bout_members_test.go | 220 +++++++++++ internal/mobileapp/self_run_daihyosen_test.go | 42 +++ internal/state/announcement_store.go | 74 +++- internal/state/announcement_store_test.go | 197 ++++++++++ specs/openapi.yaml | 137 ++++++- web-mobile/check-imports.mjs | 8 + .../__tests__/admin_schedule_surface.test.jsx | 1 - web-mobile/js/__tests__/api.test.jsx | 2 +- ..._scores_auto_start_refusal.render.test.jsx | 94 ++++- ..._scores_row_name_mark_clip.render.test.jsx | 1 - .../render/admin_shiaijo.render.test.jsx | 352 +++++++++++++++-- ...shiaijo_superseded_advance.render.test.jsx | 1 - .../call_to_court_double_tap.render.test.jsx | 1 - ...hanged_groups_every_editor.render.test.jsx | 2 +- ...dit_match_score_toast_once.render.test.jsx | 94 +++++ .../render/lineup_panel_hosts.render.test.jsx | 1 - .../score_editor_barred_match.render.test.jsx | 1 - ...itor_filter_finds_a_number.render.test.jsx | 1 - .../score_editor_mount_sites.render.test.jsx | 1 - ...itor_team_default_win_mark.render.test.jsx | 1 - .../superseded_call_sites.render.test.jsx | 1 - .../js/__tests__/side_marks_leaf.test.jsx | 44 +++ .../js/__tests__/write_result_banner.test.jsx | 49 +++ web-mobile/js/admin.jsx | 8 +- web-mobile/js/admin_schedule.jsx | 3 +- web-mobile/js/admin_schedule_score_editor.jsx | 50 ++- web-mobile/js/admin_shiaijo.jsx | 160 ++++---- web-mobile/js/bracket.jsx | 115 +----- web-mobile/js/side_marks.jsx | 126 +++++++ web-mobile/js/start_match.jsx | 4 +- web-mobile/js/write_result.jsx | 35 ++ 56 files changed, 3140 insertions(+), 444 deletions(-) create mode 100644 internal/engine/rep_pick_probe.go create mode 100644 internal/engine/representative_export.go create mode 100644 internal/engine/representative_export_test.go create mode 100644 internal/mobileapp/self_run_bout_members_test.go create mode 100644 web-mobile/js/__tests__/render/edit_match_score_toast_once.render.test.jsx create mode 100644 web-mobile/js/__tests__/side_marks_leaf.test.jsx create mode 100644 web-mobile/js/side_marks.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 0e3c0c2cb..137f1c261 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,12 +169,13 @@ 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 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 a toast); 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: it stays while that match is `scheduled` on the court 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 lands, and the refused match is never started on its own; if the blocking start fails instead, its own refusal 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. 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, 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. @@ -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` @@ -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 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. It also judges the numbered bout rows a participant's write introduces against the stored row at the same position (`numberedRowsRefusal`; the organiser's write is not judged there): a fighter's member id must be on the side's team (400 `team_member_not_in_team`, `errBoutFighterNotInTeam`) and a winner member id must be one of the row's two fighters (400 `winner_member_not_in_bout`, `errWinnerMemberNotInBout`). Every member-id check of one write (those rows, the representative picks, and the lineup PUT's `memberIDsOutsideTeam`) goes through one `memberJudge` (`rowMembersOutsideTeams`), which reads team-members.yaml at most once and only when an id is introduced; a file it cannot read refuses the write with 409 `team_members_unreadable` (`errTeamMembersUnreadable`), never a 500 the offline queue would retry forever. -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: `judgeRepPicksUnderTx` (handlers_match.go) judges a pick the organiser's write introduces through the same `repRowRefusal`, with the same 400 `team_member_not_in_team` and sentence (a pick names a SIDE, whose team a correction can change under an open editor), never asking the match's status. 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: `judgeRepPicksUnderTx` (handlers_match.go) judges a pick the organiser's write introduces through the same `repRowRefusal` as a participant's, with the same 400 `team_member_not_in_team` and sentence. `repRowRefusal` judges a pick exactly when the merge would apply it, by asking the merge itself on copies (`engine.RepPicksApplied`): a pick the merge holds (its group not named, stamped older than the stored pick, on a row the write neither has nor creates, or in a write held whole) is kept in the history, never refused (a pick names a SIDE, whose team a correction can change under an open editor), never asking the match's status. 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`. @@ -444,7 +445,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 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/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index bb71c9b1c..2c98a61f2 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -79,7 +79,7 @@ Click **Announce** from the dashboard to broadcast a short message to every view 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. If that console was closed or reloaded meanwhile, the call stays up until its five minutes run out. +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 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/internal/engine/bracket_result.go b/internal/engine/bracket_result.go index 4bf79cb85..7bd3f3247 100644 --- a/internal/engine/bracket_result.go +++ b/internal/engine/bracket_result.go @@ -92,6 +92,14 @@ func bracketMatchAsResult(bm *state.BracketMatch) *state.MatchResult { } } +// BracketMatchAsResult is a bracket match as the pool-shaped record every +// write, merge and probe reads (bracketMatchAsResult), for callers outside the +// engine that ask the merge about a stored bracket match (RepPicksApplied). 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/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/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/knockout.go b/internal/engine/knockout.go index a07835211..e9fa70bcd 100644 --- a/internal/engine/knockout.go +++ b/internal/engine/knockout.go @@ -229,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 @@ -306,11 +328,14 @@ func (e *Engine) resolveSlots(bracket *state.Bracket, resolver map[string]resolv // (name only, id still "") gets its id backfilled the next time its // pool's placeholder is looked at. // - // side names which of the match's two sides name/id are, or is - // domain.MatchSideNone for the Winner paint (not a side): a side is - // written through seatBracketSide, which takes away the representative - // the team that leaves the slot held. - paint := func(label string, side domain.MatchSide, 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) { return @@ -329,10 +354,10 @@ func (e *Engine) resolveSlots(bracket *state.Bracket, resolver map[string]resolv } n++ } - paint(m.PlaceholderA, domain.MatchSideA, &m.SideA, &m.SideAID) - paint(m.PlaceholderB, domain.MatchSideB, &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, domain.MatchSideNone, &m.Winner, &m.WinnerID) + paint(m.PlaceholderWinner, domain.MatchSideNone) } for ri := range bracket.Rounds { for mi := range bracket.Rounds[ri] { diff --git a/internal/engine/knockout_test.go b/internal/engine/knockout_test.go index 8df9bf664..18e5f81ab 100644 --- a/internal/engine/knockout_test.go +++ b/internal/engine/knockout_test.go @@ -838,3 +838,53 @@ 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) + }) + } +} diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index 6b42f4daa..2f013466e 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -9,6 +9,8 @@ package engine // other side's pick, and an older change of any is kept in the match's history. import ( + "fmt" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -627,3 +629,138 @@ func TestMerge_RepPick_OnARowAHoldTookOut_IsHeldWithItsValue(t *testing.T) { assert.NotContains(t, got.GroupStamps, repPickAName, "the stored match never stamped the pick") }) } + +// The pick judges (mobileapp: the participant's and the organiser's) judge a +// representative pick for team membership only when the merge will APPLY it, +// and they ask RepPicksApplied, which runs the merge on copies, rather than +// re-deriving its rule. The table below pins the probe to the merge itself. + +const rpaStoredAt = int64(100_000) + +var rpaComp = &state.Competition{ + ID: "rpa", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed, Format: state.CompFormatKnockout, +} + +// rpaStored is a stored running team match; withRow adds the representative +// bout with side A's pick (carol) stamped at rpaStoredAt on all three groups. +func rpaStored(withRow bool) *state.MatchResult { + m := &state.MatchResult{ + ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusRunning, ModifiedAt: rpaStoredAt, + SubResults: []state.SubMatchResult{{Position: 1}}, + GroupStamps: map[string]int64{"bout:1": rpaStoredAt}, + } + if withRow { + m.SubResults = append(m.SubResults, repRow("carol", "", nil, nil)) + m.GroupStamps[repBoutGroup], m.GroupStamps[repPickAName], m.GroupStamps[repPickBName] = rpaStoredAt, rpaStoredAt, rpaStoredAt + } + return m +} + +// rpaWrite is a client's running score write that picks dave and erin. +func rpaWrite(at int64, changed []string) *state.MatchResult { + return &state.MatchResult{ + ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, + Status: state.MatchStatusRunning, ModifiedAt: at, Changed: changed, WriteDoor: DoorScore, + SubResults: []state.SubMatchResult{repRow("dave", "erin", nil, nil)}, + } +} + +// rpaCopy is a deep copy built without the probe's own cloning. +func rpaCopy(m *state.MatchResult) state.MatchResult { + c := *m + c.SubResults = state.CloneSubResults(m.SubResults) + c.GroupStamps = state.CloneGroupStamps(m.GroupStamps) + if m.Changed != nil { + c.Changed = append([]string{}, m.Changed...) + } + return c +} + +func TestRepPicksApplied_AgreesWithTheMerge(t *testing.T) { + changeds := []struct { + name string + changed []string + }{ + {"naming nothing", nil}, + {"naming both picks and the bout", []string{repBoutGroup, repPickAName, repPickBName}}, + {"naming both picks", []string{repPickAName, repPickBName}}, + {"naming side A's pick", []string{repPickAName}}, + {"naming side B's pick", []string{repPickBName}}, + {"naming the bout alone", []string{repBoutGroup}}, + } + stamps := []struct { + name string + at int64 + }{ + {"older", rpaStoredAt - 100}, {"equal", rpaStoredAt}, {"newer", rpaStoredAt + 100}, {"unstamped", 0}, + } + for _, knockout := range []bool{false, true} { + for _, storedRow := range []bool{true, false} { + for _, ch := range changeds { + for _, st := range stamps { + name := fmt.Sprintf("knockout=%v/storedRow=%v/%s/%s", knockout, storedRow, ch.name, st.name) + t.Run(name, func(t *testing.T) { + stored, incoming := rpaStored(storedRow), rpaWrite(st.at, ch.changed) + storedBefore, incomingBefore := rpaCopy(stored), rpaCopy(incoming) + + gotA, gotB := RepPicksApplied(stored, incoming, rpaComp, knockout) + + assert.Equal(t, storedBefore, *stored, "the stored match is not changed by the probe") + assert.Equal(t, incomingBefore, *incoming, "the write is not changed by the probe") + + // The merge, run on its own copies with the branch's context. + s, w := rpaCopy(stored), rpaCopy(incoming) + rep := mergeMatchWrite(&s, &w, matchWriteForward, mergeCtx{comp: rpaComp, knockout: knockout, nilSubsClear: !knockout}) + assert.Equal(t, slices.Contains(rep.Applied, repPickAName), gotA, "side A") + assert.Equal(t, slices.Contains(rep.Applied, repPickBName), gotB, "side B") + + // And the answer is the plain reading of the rule, so the + // equality above cannot hold only because both are wrong: + // the write names the pick, it is not older than the stored + // pick's stamp, and it lands on a row (the match's own, or + // one the write creates by naming the bout: a pick named + // onto a row nothing creates is held). + applies := func(group string) bool { + named := ch.changed == nil || slices.Contains(ch.changed, group) + onARow := storedRow || ch.changed == nil || slices.Contains(ch.changed, repBoutGroup) + return named && onARow && (st.at == 0 || !storedRow || st.at >= rpaStoredAt) + } + assert.Equal(t, applies(repPickAName), gotA, "side A by the rule") + assert.Equal(t, applies(repPickBName), gotB, "side B by the rule") + }) + } + } + } + } +} + +// A finish stamped before the stored verdict that names another winner is held +// WHOLE (HoldReasonFinishAtomic): its picks land nowhere, whatever their own +// stamps say. A finish that only repeats the stored verdict is not a hold, so +// its picks are ordered by their own stamps. +func TestRepPicksApplied_AStaleFinishLandsNoPick(t *testing.T) { + stored := func() *state.MatchResult { + m := rpaStored(true) + m.Status, m.Winner, m.WinnerID = state.MatchStatusCompleted, wrTeamA, wrTeamAID + m.GroupStamps[state.GroupResult] = rpaStoredAt + m.GroupStamps[repBoutGroup], m.GroupStamps[repPickAName], m.GroupStamps[repPickBName] = rpaStoredAt-40, rpaStoredAt-50, rpaStoredAt-50 + return m + } + finish := func(winner, winnerID string) *state.MatchResult { + w := rpaWrite(rpaStoredAt-20, nil) + w.Status, w.Winner, w.WinnerID = state.MatchStatusCompleted, winner, winnerID + return w + } + for _, knockout := range []bool{false, true} { + t.Run(fmt.Sprintf("knockout=%v", knockout), func(t *testing.T) { + a, b := RepPicksApplied(stored(), finish(wrTeamB, wrTeamBID), rpaComp, knockout) + assert.False(t, a, "another winner, older than the stored verdict: held whole, side A") + assert.False(t, b, "side B") + + a, b = RepPicksApplied(stored(), finish(wrTeamA, wrTeamAID), rpaComp, knockout) + assert.True(t, a, "the stored verdict repeated is no hold: the pick is newer than its stored stamp, side A") + assert.True(t, b, "side B") + }) + } +} diff --git a/internal/engine/rep_pick_probe.go b/internal/engine/rep_pick_probe.go new file mode 100644 index 000000000..702941dc8 --- /dev/null +++ b/internal/engine/rep_pick_probe.go @@ -0,0 +1,62 @@ +package engine + +import ( + "slices" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// RepPicksApplied says, for each side, whether the merge would APPLY the +// representative pick (state.GroupRepPickA / state.GroupRepPickB) that incoming +// carries onto stored. The pick judges (mobileapp: a participant's score write +// and the organiser's) judge a pick for team membership exactly when it lands, +// and ask this rather than re-deriving the merge's rule: whether the write names +// the side's pick, whether its stamp is older than the stored pick's, whether a +// hold takes the whole write (a stale finish naming another winner is held +// whole, HoldReasonFinishAtomic), and what becomes of a pick on a row that is +// gone or never was (settleOrphanPicks) all live in mergeMatchWrite, and run +// here. A pick that is held, displaced or sits on no row is kept in the match's +// history by the real write and is not the judge's to refuse. +// +// It is the probe shape decisionHeldByMerge and engiFinishProbe have: the merge +// runs on COPIES of both matches (it rewrites the write it is given), so neither +// argument is changed by the call. knockout says which branch the write will +// land on (a bracket match, true, or a pool/league match, false): the two pass +// the merge different contexts (mergeCtx.knockout; mergeCtx.nilSubsClear, which +// is what an omitted bout list means on the pool branch). stored is the match +// as the store holds it (a pool row, or BracketMatchAsResult of a bracket +// match); comp is the competition (nil reads as an individual one). +// +// The write is folded onto the stored pairing first (reconcileSides, as both +// branches do before they merge), because the merge works the verdict out from +// the sides. A write that names other competitors is refused by the engine +// (ErrMatchSideMismatch) and lands nothing, so it applies no pick. The bracket +// branch skips that fold for a match not playable yet (sidesBeforeMerge) and +// refuses the write itself; the probe folds unconditionally, which cannot +// disagree, since an empty stored side never mismatches. +func RepPicksApplied(stored, incoming *state.MatchResult, comp *state.Competition, knockout bool) (sideA, sideB bool) { + if stored == nil { + stored = &state.MatchResult{} // a match the store does not hold has no row and no stamps + } + prior, probe := cloneForProbe(stored), cloneForProbe(incoming) + if reconcileSides(&probe, storedSides{A: prior.SideA, B: prior.SideB, AID: prior.SideAID, BID: prior.SideBID}) { + return false, false + } + rep := mergeMatchWrite(&prior, &probe, matchWriteForward, mergeCtx{comp: comp, knockout: knockout, nilSubsClear: !knockout}) + return slices.Contains(rep.Applied, state.GroupRepPickA), slices.Contains(rep.Applied, state.GroupRepPickB) +} + +// cloneForProbe is a copy of m the merge can rewrite without reaching m: its +// bout rows, ippons, overtime, stamps and group names are cloned (nil stays +// nil, which the merge reads differently from empty), and no merge report rides +// along. +func cloneForProbe(m *state.MatchResult) state.MatchResult { + c := *m + c.IpponsA, c.IpponsB = cloneIppons(m.IpponsA), cloneIppons(m.IpponsB) + c.SubResults = state.CloneSubResults(m.SubResults) + c.GroupStamps = state.CloneGroupStamps(m.GroupStamps) + c.Changed = slices.Clone(m.Changed) + c.Encho = m.Encho.Clone() + c.Merge = nil + return c +} 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/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, "
    ; }, + confirmDialog: vi.fn(), + API: { + recordScore: vi.fn(), + subscribeToEvents: vi.fn(() => () => {}), + fetchCompetitions: vi.fn().mockResolvedValue([]), + }, +}; + +let restore; +let AdminApp; + +beforeAll(async () => { + restore = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin.jsx'); + AdminApp = window.AdminApp; +}); +afterAll(() => restore()); +beforeEach(() => { + probe.props = null; + window.API.recordScore.mockReset(); + window.confirmDialog.mockReset(); +}); + +async function mountScoresView() { + const showToast = vi.fn(); + await act(async () => { + render( + , + ); + }); + expect(probe.props?.onEditScore, 'the page was handed the real editMatchScore').toBeTypeOf('function'); + return { showToast, onEditScore: probe.props.onEditScore }; +} + +describe('editMatchScore marks every error it toasts', () => { + it('a thrown refusal is toasted once, by it, and carries the mark', async () => { + const { showToast, onEditScore } = await mountScoresView(); + const refusal = new Error('Kato is fighting on shiaijo B'); + window.API.recordScore.mockRejectedValue(refusal); + + let caught; + await act(async () => { + try { await onEditScore('c1', 'm1', { status: 'running' }, { id: 'm1' }); } catch (e) { caught = e; } + }); + + expect(caught).toBe(refusal); + expect(showToast.mock.calls).toEqual([['Kato is fighting on shiaijo B', 'error']]); + expect(wasToasted(caught)).toBe(true); + }); + + it('a declined downstream override is toasted once, with its own words, and carries the mark', async () => { + const { showToast, onEditScore } = await mountScoresView(); + const refusal = Object.assign(new Error('a later match was played'), { + downstreamKnockoutPlayed: { blockingMatchId: 'm9', blockingMatches: [], displaced: [] }, + }); + window.API.recordScore.mockRejectedValue(refusal); + window.confirmDialog.mockResolvedValue(false); + + let caught; + await act(async () => { + try { await onEditScore('c1', 'm1', { status: 'completed' }, { id: 'm1' }); } catch (e) { caught = e; } + }); + + expect(caught).toBe(refusal); + expect(showToast.mock.calls).toEqual([[DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED]]); + expect(wasToasted(caught)).toBe(true); + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_panel_hosts.render.test.jsx b/web-mobile/js/__tests__/render/lineup_panel_hosts.render.test.jsx index e1a1519da..8b17c717c 100644 --- a/web-mobile/js/__tests__/render/lineup_panel_hosts.render.test.jsx +++ b/web-mobile/js/__tests__/render/lineup_panel_hosts.render.test.jsx @@ -41,7 +41,6 @@ const STUBS = { filterMatchesByCourt: (all) => all, tournamentMatches: () => matches, compMatches: () => matches, - startPatch: () => ({ status: 'running', winner: null }), confirmDialog: vi.fn().mockResolvedValue(true), pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || `${s}s`)}`, API: { diff --git a/web-mobile/js/__tests__/render/score_editor_barred_match.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_barred_match.render.test.jsx index aeab5b5b4..5ee6c0c48 100644 --- a/web-mobile/js/__tests__/render/score_editor_barred_match.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_barred_match.render.test.jsx @@ -37,7 +37,6 @@ const STUBS = { filterMatchesByCourt: (matches) => matches, tournamentMatches: () => [], compMatches: () => [RUNNING, BARRED, OPEN], - startPatch: () => ({ status: 'running', winner: null }), confirmDialog: vi.fn().mockResolvedValue(true), pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, API: { diff --git a/web-mobile/js/__tests__/render/score_editor_filter_finds_a_number.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_filter_finds_a_number.render.test.jsx index 11d3ff501..d009ae925 100644 --- a/web-mobile/js/__tests__/render/score_editor_filter_finds_a_number.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_filter_finds_a_number.render.test.jsx @@ -44,7 +44,6 @@ const STUBS = { filterMatchesByCourt: (matches) => matches, tournamentMatches: () => [], compMatches: () => MATCHES, - startPatch: () => ({ status: 'running', winner: null }), confirmDialog: vi.fn().mockResolvedValue(true), pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null) }, diff --git a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx index 9570b7d2b..d4dd59d08 100644 --- a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx @@ -76,7 +76,6 @@ const STUBBED_GLOBALS = { filterMatchesByCourt: (matches) => matches, tournamentMatches: () => [], compMatches: () => [], - startPatch: () => ({ status: 'running', winner: null }), confirmDialog: vi.fn().mockResolvedValue(true), PoolsViewer: () => null, // per-test override drives pools open LeagueStandingsViewer: () => null, diff --git a/web-mobile/js/__tests__/render/score_editor_team_default_win_mark.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_team_default_win_mark.render.test.jsx index b5f1bb954..980221992 100644 --- a/web-mobile/js/__tests__/render/score_editor_team_default_win_mark.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_team_default_win_mark.render.test.jsx @@ -49,7 +49,6 @@ const STUBS = { filterMatchesByCourt: (matches) => matches, tournamentMatches: () => [], compMatches: () => MATCHES, - startPatch: () => ({ status: 'running', winner: null }), confirmDialog: vi.fn().mockResolvedValue(true), pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null) }, diff --git a/web-mobile/js/__tests__/render/superseded_call_sites.render.test.jsx b/web-mobile/js/__tests__/render/superseded_call_sites.render.test.jsx index 47d1a76ae..902e49684 100644 --- a/web-mobile/js/__tests__/render/superseded_call_sites.render.test.jsx +++ b/web-mobile/js/__tests__/render/superseded_call_sites.render.test.jsx @@ -59,7 +59,6 @@ const STUBBED_GLOBALS = { getScoreBtnClass: () => 'test-score-open', matchScoreStr: () => '', compMatches: () => [], - startPatch: () => ({ status: 'running', winner: null }), API: { recordScore: vi.fn(), }, diff --git a/web-mobile/js/__tests__/side_marks_leaf.test.jsx b/web-mobile/js/__tests__/side_marks_leaf.test.jsx new file mode 100644 index 000000000..9d740682a --- /dev/null +++ b/web-mobile/js/__tests__/side_marks_leaf.test.jsx @@ -0,0 +1,44 @@ +// side_marks.jsx is a LEAF: the court console (admin_shiaijo.jsx) imports the +// result-mark placement helpers from it, and the console must not reach +// 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 (see the header of admin_shiaijo.jsx). +// +// So the leaf's one import is competitor_identity.jsx, itself a leaf. This test +// reads the source rather than the module graph because a graph walk would need +// a bundler; the rule is small enough to state as "the only `import` lines in +// the file name competitor_identity.jsx", and a second import fails it loudly. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { stripComments } from '../../check-helpers.mjs'; + +const JS = resolve(__dirname, '..'); +const read = (name) => readFileSync(resolve(JS, name), 'utf8'); + +function importSpecifiers(src) { + const out = []; + const re = /^\s*import\s[^;]*?from\s+['"]([^'"]+)['"]|^\s*import\s+['"]([^'"]+)['"]/gm; + let m; + while ((m = re.exec(stripComments(src))) !== null) out.push(m[1] || m[2]); + return out; +} + +describe('side_marks.jsx stays a leaf', () => { + it('its only import is ./competitor_identity.jsx', () => { + expect(importSpecifiers(read('side_marks.jsx'))).toEqual(['./competitor_identity.jsx']); + }); + + it('competitor_identity.jsx, the one module it reaches, imports nothing', () => { + expect(importSpecifiers(read('competitor_identity.jsx'))).toEqual([]); + }); + + it('exports the six helpers the console and bracket.jsx read', () => { + const src = read('side_marks.jsx'); + for (const name of ['isKikenDecisionBC', 'joinSp', 'placeMarks', 'sideMarks', 'winnerSideLR', 'teamMatchMarks']) { + expect(src, name).toMatch(new RegExp(`export\\s+(?:function|const)\\s+${name}\\b|export\\s*\\{[^}]*\\b${name}\\b`)); + } + }); +}); diff --git a/web-mobile/js/__tests__/write_result_banner.test.jsx b/web-mobile/js/__tests__/write_result_banner.test.jsx index 2e3f1c0f9..9da8f1c4a 100644 --- a/web-mobile/js/__tests__/write_result_banner.test.jsx +++ b/web-mobile/js/__tests__/write_result_banner.test.jsx @@ -36,6 +36,10 @@ import { writeDisplacedGroups, writeDisplacedForWinner, displacedAlertText, + markToasted, + wasToasted, + startWhileStartingMessage, + startWasBlockedByStartMessage, } from '../write_result.jsx'; import { heldGroupsNote, keptInHistoryNote } from '../match_groups.jsx'; import { closingHistoryToast } from '../admin.jsx'; @@ -447,3 +451,48 @@ describe('writeHeldDecision', () => { expect(writeHeldDecision(null)).toBeNull(); }); }); + +// A start refused because another start was still out says so while that start +// is out (startWhileStartingMessage, present tense). Once that start has landed +// nothing else tells the operator the match they asked for was never started, so +// the refusal is kept, in the past tense, on the refused match +// (startWasBlockedByStartMessage). Both surfaces that refuse a start for this +// reason (the court console and the Scores tab) read the sentence from here. +describe('the sentence for a start that was blocked by another start', () => { + it('names the start that was out and says what to do, in the past tense', () => { + expect(startWasBlockedByStartMessage({ label: 'Pool A · Match 2' })).toBe( + 'Not started: Pool A · Match 2 was being started when this match was asked for. Start it when the court is free.', + ); + }); + it('is the same label as the present-tense sentence names, and a different sentence', () => { + const label = 'Match 3 (Final)'; + expect(startWhileStartingMessage({ label })).toContain(label); + expect(startWasBlockedByStartMessage({ label })).toContain(label); + expect(startWasBlockedByStartMessage({ label })).not.toBe(startWhileStartingMessage({ label })); + }); +}); + +// A thrown refusal is toasted ONCE. editMatchScore (admin.jsx) toasts what it +// throws; the two hosts that start a match catch the same error and used to toast +// the same sentence again, which replaced the single-slot toast and restarted its +// timer. editMatchScore marks the error it toasted, and a host asks before it does. +describe('markToasted / wasToasted', () => { + it('an error marked toasted reads as toasted, and markToasted hands the same error back', () => { + const err = new Error('x'); + expect(wasToasted(err)).toBe(false); + expect(markToasted(err)).toBe(err); + expect(wasToasted(err)).toBe(true); + }); + it('anything that is not an error object reads as not toasted and does not throw', () => { + expect(wasToasted(undefined)).toBe(false); + expect(wasToasted(null)).toBe(false); + expect(wasToasted('string')).toBe(false); + expect(wasToasted(7)).toBe(false); + expect(wasToasted({})).toBe(false); + }); + it('marking a non-object does not throw, and leaves it not toasted', () => { + expect(() => markToasted(undefined)).not.toThrow(); + expect(() => markToasted('string')).not.toThrow(); + expect(wasToasted(markToasted('string'))).toBe(false); + }); +}); diff --git a/web-mobile/js/admin.jsx b/web-mobile/js/admin.jsx index 10bbb4e14..83df45ae4 100644 --- a/web-mobile/js/admin.jsx +++ b/web-mobile/js/admin.jsx @@ -10,6 +10,7 @@ import { attemptScoreWrite, downstreamKnockoutReopenedNotice, writeKeepsEditorOpen, + markToasted, } from './write_result.jsx'; import { keptInHistoryNote } from './match_groups.jsx'; @@ -326,14 +327,17 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan compId, matchId, result, password, match, }); } catch (e) { + // Every error toasted here is marked, so a host that catches it to show + // the same sentence on a card or a row does not toast it a second time + // (markToasted, write_result.jsx). if (e.downstreamKnockoutPlayedCancelled) { // Declining the override leaves everything as it was: neither this // match nor the later one it depends on was written. showToast(DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED); - throw e; + throw markToasted(e); } showToast(e.message, "error"); - throw e; + throw markToasted(e); } // bc-kcdg: say what the confirmation actually did. The operator agreed to // reopen specific later matches; without this the only evidence is the 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 cf38c6721..8b19ed659 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -1,14 +1,16 @@ // Score editor components extracted from admin_schedule.jsx (mp-d7tl). -// startPatch (re-exported from start_match.jsx), ScoreEditCourtBtn (local), -// AdminScoreEditor, AdminScoreEditorPage. +// ScoreEditCourtBtn (local), AdminScoreEditor, AdminScoreEditorPage. -import { writeDidNotLand, writeKeepsEditorOpen, startWhileStartingMessage } 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'; +// 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. @@ -89,13 +91,6 @@ function ScoreEditCourtBtn({ m, courts, onMoveCourt }) { ); } -// startPatch now lives in start_match.jsx (the one owner of how a Start is -// written). Re-exported here, of the LOCAL binding imported above so the module -// is fetched once, because admin_schedule.jsx imports it from this file to -// publish `window.startPatch`, which admin_shiaijo.jsx reads, and the tests -// import it from here too. -export { startPatch }; - export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCompId, password, showToast }) { const [filter, setFilter] = useStateA(""); const [compFilter, setCompFilter] = useStateA(restrictToCompId || "all"); @@ -155,7 +150,8 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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 goes when that start lands. + // 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); @@ -183,14 +179,18 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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. - const refuseStart = (next, msg, waitingOn = null) => { + // + // `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 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 }); } - // Single-slot toast (app.jsx): a thrown refusal, which editMatchScore has - // already toasted with the same words, replaces rather than stacks. - if (showToast) showToast(msg, "error"); + if (showToast && !toasted) showToast(msg, "error"); }; // The ONE door both automatic starts go through: (a) refuses while another @@ -216,12 +216,22 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom refuseStart(next, outcome.msg); return false; } catch (e) { - refuseStart(next, startFailureMessage(e)); + refuseStart(next, startFailureMessage(e), null, wasToasted(e)); return false; } finally { guard.end(key); - // A "still being started" notice names THIS start; it goes when the start does. - if (mountedRef.current) setStartRefusal((prev) => (prev && prev.waitingOn === key ? null : prev)); + // 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. + if (mountedRef.current) { + setStartRefusal((prev) => (prev && prev.waitingOn === key + ? { key: prev.key, msg: startWasBlockedByStartMessage({ label: scoreRowMatchName(next) }), waitingOn: null } + : prev)); + } } }; @@ -304,11 +314,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 diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 21c5ae147..ec4908028 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -27,14 +27,16 @@ import { useDialogFocus } from './dialog_focus.jsx'; import { writeDidNotLand, writeKeepsEditorOpen, writeWasSuperseded, writeWasRefusedForClock, attemptScoreWrite, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, OVERRIDE_HELD_NOTICE, - startWhileCorrectingMessage, startWhileStartingMessage, 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). The state wiring stays here: +// 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 { classifyStartOutcome, startFailureMessage } from './start_match.jsx'; +import { startPatch, classifyStartOutcome, startFailureMessage } 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'; @@ -55,8 +57,13 @@ 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'; @@ -318,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" }; } @@ -627,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 @@ -830,9 +839,13 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const [correctingKey, setCorrectingKey] = useStateSh(null); // 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, why: "running" | "correcting" | "starting" }. + // { key, compId, why: "running" | "correcting" | "starting", against, label } + // (`against` is the blocker's key, `label` its name as the operator sees it). // 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 @@ -1081,12 +1094,21 @@ 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) }); 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 ? startError.msg : null); + 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 @@ -1178,25 +1200,28 @@ 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), the stored refusal (the server's or a - // thrown start's message in startError) 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). The one - // exception is a refused ADVANCE start (startMatch's advance flag, Finish + - // Start Next or the one after a decision): the refetch that follows moves Up - // next onto exactly that match, so its refusal stays while it is Up next. - // A refusal for a start still out (refuseTap, "starting") is derived instead - // and clears itself once that start lands. 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; the + // Scores tab keeps the same rule, 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 courtHoldsMatches = courtMatchesRaw.length > 0; useEffectSh(() => { - setStartError((prev) => (prev && prev.advance && prev.key === upNextKey ? prev : null)); - }, [upNextKey]); + if (!startErrorKey) return; + if (startErrorRow ? startErrorStatus !== "scheduled" : courtHoldsMatches) setStartError(null); + }, [startErrorKey, startErrorStatus, courtHoldsMatches]); // "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 @@ -1272,17 +1297,6 @@ 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(); - }; - // 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). @@ -1292,19 +1306,38 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const syncStartingKey = () => { const s = startingRef.current || pickingRef.current; if (mountedRef.current) setStartingKey(s ? s.key : null); - // With no start out, a "starting" refusal has no start left to name, so it - // goes: otherwise the next start of that match would bring it back. Other - // refusals are not about a start in flight and stay. - if (!s && mountedRef.current) setRefusedTap((prev) => (prev && prev.why === "starting" ? null : prev)); }; + // 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) is what the operator needs. It is stored for the blocker before this + // runs (startMatch's 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. + useEffectSh(() => { + if (!refusedTap || refusedTap.why !== "starting") return; + if (startingRef.current || pickingRef.current) return; + const blocked = { key: refusedTap.key, compId: refusedTap.compId, msg: startWasBlockedByStartMessage({ label: refusedTap.label }) }; + setStartError((prev) => prev ?? blocked); + setRefusedTap(null); + }, [refusedTap, startingKey]); // `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. `advance` - // marks a start an advance (Finish + Start Next, or the one after a decision) - // asked for: its refusal survives the Up next change the refetch makes (see - // the upNextKey effect). - const startMatch = async (m, { pick = null, advance = false } = {}) => { + // 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, advance }); + 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 @@ -1341,7 +1374,10 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, } catch (e) { const msg = startFailureMessage(e); if (mountedRef.current) setStartError(refusalFor(msg)); - if (showToast) showToast(msg, "error"); + // 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 { // Only the start that holds the ref clears it; the state follows the refs. @@ -2079,7 +2115,7 @@ 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") { - await startMatch(next, { advance: true }); + await startMatch(next); } } catch (_e) { /* keep panel */ } // The finish write's answer, as the editor needs it (a partial @@ -2102,7 +2138,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") { - await startMatch(next, { advance: true }); + await startMatch(next); } }} password={password} @@ -2356,11 +2392,9 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE 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 ? window.winnerSideLR?.(m) : null; + 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 } = window.teamMatchMarks - ? window.teamMatchMarks(m, isTeamMatch(m)) - : { shiro: "", aka: "" }; + 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. diff --git a/web-mobile/js/bracket.jsx b/web-mobile/js/bracket.jsx index 49cac2b26..8fb50d809 100644 --- a/web-mobile/js/bracket.jsx +++ b/web-mobile/js/bracket.jsx @@ -17,6 +17,12 @@ 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'; @@ -34,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 @@ -207,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). @@ -265,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". // @@ -1504,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/side_marks.jsx b/web-mobile/js/side_marks.jsx new file mode 100644 index 000000000..939b53351 --- /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 {} 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). +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 index 5ba274dbd..3cc8ae249 100644 --- a/web-mobile/js/start_match.jsx +++ b/web-mobile/js/start_match.jsx @@ -47,8 +47,8 @@ import { GROUP_RESULT } from './match_groups.jsx'; // group alone (match_groups.jsx): the server keeps every other group of the // match as stored, the score a send-back kept included. // -// The console reads it off `window.startPatch` (admin_schedule.jsx publishes -// it), the Scores tab imports it; both end up here. +// The court console and the Scores tab both import it from here; it is not +// published on `window`. export function startPatch() { return { startOnly: true, diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index e2e227697..3a7214608 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -866,6 +866,18 @@ 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.`; } @@ -1009,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; +} From 419331c61a23517e3b70dbf9e3f46700180b228a Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 12:15:24 +0100 Subject: [PATCH 31/38] fix: final review round on PR #463 (Claude and Antigravity) - A participant's numbered bout row is judged only when the merge applies its bout group (engine.AppliedGroups, asked once per write): a stale row is held and kept in the history, and the write's other groups land, instead of the whole write being refused. - The /score handler reads the stored match once per write for every guard (the self-run or pick judge and the correction-reason check). - A knockout slot whose pool standing carries no id converges: the same name with no resolver id no longer repaints and rewrites the bracket on every pool write (the id backfill still runs the other way). - One rule for how long a refused Start notice lives (startRefusalStands), asked by the court console and the Scores tab, so the Scores tab no longer drops it on an empty refetch. - The team editor's withdrawal-removal Undo agrees both representative picks with the board it restores. - Doc: seatBracketSide names the draw-ready rename as its one direct-write exception and why it is safe; historyMemberLabel reuses squadLabelFor; side_marks.jsx's teamMatchMarks doc states what it returns. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- internal/engine/bracket_result.go | 2 +- internal/engine/bracket_seat.go | 8 + internal/engine/knockout.go | 13 +- internal/engine/knockout_test.go | 41 ++++ internal/engine/match_merge_rep_picks_test.go | 25 ++- internal/engine/rep_pick_probe.go | 32 +-- internal/mobileapp/handlers_match.go | 204 ++++++++++++------ .../organiser_rep_pick_judge_test.go | 2 +- .../mobileapp/self_run_bout_members_test.go | 59 +++++ internal/mobileapp/self_run_daihyosen_test.go | 4 +- specs/openapi.yaml | 18 +- ..._scores_auto_start_refusal.render.test.jsx | 22 ++ ...m_daihyosen_representative.render.test.jsx | 56 +++++ web-mobile/js/__tests__/start_match.test.jsx | 26 ++- web-mobile/js/admin_schedule_score_editor.jsx | 19 +- web-mobile/js/admin_scoring_team.jsx | 10 +- web-mobile/js/admin_shiaijo.jsx | 13 +- web-mobile/js/side_marks.jsx | 6 +- web-mobile/js/start_match.jsx | 15 ++ 20 files changed, 454 insertions(+), 129 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 137f1c261..3eaacdc79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ tournament-data/ - **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. 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 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: it stays while that match is `scheduled` on the court 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 lands, and the refused match is never started on its own; if the blocking start fails instead, its own refusal 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. +- **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 lands, and the refused match is never started on its own; if the blocking start fails instead, its own refusal 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. 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, 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. @@ -296,7 +296,7 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **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 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; 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`. +- **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); 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). @@ -331,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. It also judges the numbered bout rows a participant's write introduces against the stored row at the same position (`numberedRowsRefusal`; the organiser's write is not judged there): a fighter's member id must be on the side's team (400 `team_member_not_in_team`, `errBoutFighterNotInTeam`) and a winner member id must be one of the row's two fighters (400 `winner_member_not_in_bout`, `errWinnerMemberNotInBout`). Every member-id check of one write (those rows, the representative picks, and the lineup PUT's `memberIDsOutsideTeam`) goes through one `memberJudge` (`rowMembersOutsideTeams`), which reads team-members.yaml at most once and only when an id is introduced; a file it cannot read refuses the write with 409 `team_members_unreadable` (`errTeamMembersUnreadable`), never a 500 the offline queue would retry forever. +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; the pick judge and `applyCorrectionReason` read the same one), and each refusal is a `selfRunRefusal` carrying its own status, code and sentence. It also judges the numbered bout rows a participant's write introduces against the stored row at the same position, and only when the merge applies the row's `bout:` group (`numberedRowsRefusal`, asking the same `memberJudge.applied` once per write; a row the merge holds is kept in the history, never refused; the organiser's write is not judged there): a fighter's member id must be on the side's team (400 `team_member_not_in_team`, `errBoutFighterNotInTeam`) and a winner member id must be one of the row's two fighters (400 `winner_member_not_in_bout`, `errWinnerMemberNotInBout`). Every member-id check of one write (those rows, the representative picks, and the lineup PUT's `memberIDsOutsideTeam`) goes through one `memberJudge` (`rowMembersOutsideTeams`), which reads team-members.yaml at most once and only when an id is introduced; a file it cannot read refuses the write with 409 `team_members_unreadable` (`errTeamMembersUnreadable`), never a 500 the offline queue would retry forever. -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: `judgeRepPicksUnderTx` (handlers_match.go) judges a pick the organiser's write introduces through the same `repRowRefusal` as a participant's, with the same 400 `team_member_not_in_team` and sentence. `repRowRefusal` judges a pick exactly when the merge would apply it, by asking the merge itself on copies (`engine.RepPicksApplied`): a pick the merge holds (its group not named, stamped older than the stored pick, on a row the write neither has nor creates, or in a write held whole) is kept in the history, never refused (a pick names a SIDE, whose team a correction can change under an open editor), never asking the match's status. 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: `judgeRepPicksUnderTx` (handlers_match.go) judges a pick the organiser's write introduces through the same `repRowRefusal` as a participant's, with the same 400 `team_member_not_in_team` and sentence. `repRowRefusal` judges a pick exactly when the merge would apply it, by asking the merge itself on copies (`engine.AppliedGroups`, through `memberJudge.applied`): a pick the merge holds (its group not named, stamped older than the stored pick, on a row the write neither has nor creates, or in a write held whole) is kept in the history, never refused (a pick names a SIDE, whose team a correction can change under an open editor), never asking the match's status. 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`. diff --git a/internal/engine/bracket_result.go b/internal/engine/bracket_result.go index 7bd3f3247..ed99b4836 100644 --- a/internal/engine/bracket_result.go +++ b/internal/engine/bracket_result.go @@ -94,7 +94,7 @@ func bracketMatchAsResult(bm *state.BracketMatch) *state.MatchResult { // BracketMatchAsResult is a bracket match as the pool-shaped record every // write, merge and probe reads (bracketMatchAsResult), for callers outside the -// engine that ask the merge about a stored bracket match (RepPicksApplied). The +// engine that ask the merge about a stored bracket match (AppliedGroups). 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) diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go index 91b16bcbc..c39e4ac53 100644 --- a/internal/engine/bracket_seat.go +++ b/internal/engine/bracket_seat.go @@ -18,6 +18,14 @@ import ( // (SubMatchResult.SideAMemberID/SideBMemberID, the state.GroupRepPickA and // state.GroupRepPickB groups), so a side given ANOTHER team takes its pick away. // +// The one exception is ReplaceParticipantInDraw, which writes side names +// directly (forEachBracketSideWithID) when a participant is replaced. That is +// safe: it runs only while the competition is draw-ready (checked again under +// the transaction lock; the participant edit is refused once it has started), +// and no representative bout can exist before the start (adding one moves its +// match to running, which starts a draw-ready competition), so there is no +// pick or row name for the direct write to leave behind. +// // 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 diff --git a/internal/engine/knockout.go b/internal/engine/knockout.go index e9fa70bcd..b01d6aa59 100644 --- a/internal/engine/knockout.go +++ b/internal/engine/knockout.go @@ -337,7 +337,18 @@ func (e *Engine) resolveSlots(bracket *state.Bracket, resolver map[string]resolv 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 side whose resolver entry has no id (a standings player of + // legacy data) keeps the id it holds, as seatBracketSide keeps it + // for an incoming "": it is already seated, and repainting it + // would count a change on every pool write that never converges. + // Only that direction: a slot with no id against an entry that + // has one is still painted, which is the id backfill above. The + // Winner is written directly, id included, so it keeps the exact + // compare and converges after one repaint. + if *name == rf.Name && (*id == rf.ID || (side != domain.MatchSideNone && rf.ID == "")) { return } if frozen[qualifierLabelPool(label)] != "" { diff --git a/internal/engine/knockout_test.go b/internal/engine/knockout_test.go index 18e5f81ab..9cd04ea28 100644 --- a/internal/engine/knockout_test.go +++ b/internal/engine/knockout_test.go @@ -888,3 +888,44 @@ func TestResolveSlots_PaintsTheSideItNames(t *testing.T) { }) } } + +// 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) + }) + } +} diff --git a/internal/engine/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index 2f013466e..e68a3a80e 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -630,10 +630,11 @@ func TestMerge_RepPick_OnARowAHoldTookOut_IsHeldWithItsValue(t *testing.T) { }) } -// The pick judges (mobileapp: the participant's and the organiser's) judge a -// representative pick for team membership only when the merge will APPLY it, -// and they ask RepPicksApplied, which runs the merge on copies, rather than -// re-deriving its rule. The table below pins the probe to the merge itself. +// The member judges (mobileapp: the participant's and the organiser's) judge a +// representative pick, and a participant's numbered bout row, for team +// membership only when the merge will APPLY its group, and they ask +// AppliedGroups, which runs the merge on copies, rather than re-deriving its +// rule. The table below pins the probe to the merge itself. const rpaStoredAt = int64(100_000) @@ -677,7 +678,7 @@ func rpaCopy(m *state.MatchResult) state.MatchResult { return c } -func TestRepPicksApplied_AgreesWithTheMerge(t *testing.T) { +func TestAppliedGroups_AgreesWithTheMerge(t *testing.T) { changeds := []struct { name string changed []string @@ -704,7 +705,8 @@ func TestRepPicksApplied_AgreesWithTheMerge(t *testing.T) { stored, incoming := rpaStored(storedRow), rpaWrite(st.at, ch.changed) storedBefore, incomingBefore := rpaCopy(stored), rpaCopy(incoming) - gotA, gotB := RepPicksApplied(stored, incoming, rpaComp, knockout) + got := AppliedGroups(stored, incoming, rpaComp, knockout) + gotA, gotB := slices.Contains(got, repPickAName), slices.Contains(got, repPickBName) assert.Equal(t, storedBefore, *stored, "the stored match is not changed by the probe") assert.Equal(t, incomingBefore, *incoming, "the write is not changed by the probe") @@ -712,6 +714,7 @@ func TestRepPicksApplied_AgreesWithTheMerge(t *testing.T) { // The merge, run on its own copies with the branch's context. s, w := rpaCopy(stored), rpaCopy(incoming) rep := mergeMatchWrite(&s, &w, matchWriteForward, mergeCtx{comp: rpaComp, knockout: knockout, nilSubsClear: !knockout}) + assert.Equal(t, rep.Applied, got, "every group the merge applies, not only the picks") assert.Equal(t, slices.Contains(rep.Applied, repPickAName), gotA, "side A") assert.Equal(t, slices.Contains(rep.Applied, repPickBName), gotB, "side B") @@ -739,7 +742,7 @@ func TestRepPicksApplied_AgreesWithTheMerge(t *testing.T) { // WHOLE (HoldReasonFinishAtomic): its picks land nowhere, whatever their own // stamps say. A finish that only repeats the stored verdict is not a hold, so // its picks are ordered by their own stamps. -func TestRepPicksApplied_AStaleFinishLandsNoPick(t *testing.T) { +func TestAppliedGroups_AStaleFinishLandsNoPick(t *testing.T) { stored := func() *state.MatchResult { m := rpaStored(true) m.Status, m.Winner, m.WinnerID = state.MatchStatusCompleted, wrTeamA, wrTeamAID @@ -754,11 +757,15 @@ func TestRepPicksApplied_AStaleFinishLandsNoPick(t *testing.T) { } for _, knockout := range []bool{false, true} { t.Run(fmt.Sprintf("knockout=%v", knockout), func(t *testing.T) { - a, b := RepPicksApplied(stored(), finish(wrTeamB, wrTeamBID), rpaComp, knockout) + picks := func(winner, winnerID string) (a, b bool) { + got := AppliedGroups(stored(), finish(winner, winnerID), rpaComp, knockout) + return slices.Contains(got, repPickAName), slices.Contains(got, repPickBName) + } + a, b := picks(wrTeamB, wrTeamBID) assert.False(t, a, "another winner, older than the stored verdict: held whole, side A") assert.False(t, b, "side B") - a, b = RepPicksApplied(stored(), finish(wrTeamA, wrTeamAID), rpaComp, knockout) + a, b = picks(wrTeamA, wrTeamAID) assert.True(t, a, "the stored verdict repeated is no hold: the pick is newer than its stored stamp, side A") assert.True(t, b, "side B") }) diff --git a/internal/engine/rep_pick_probe.go b/internal/engine/rep_pick_probe.go index 702941dc8..231c3990c 100644 --- a/internal/engine/rep_pick_probe.go +++ b/internal/engine/rep_pick_probe.go @@ -6,17 +6,18 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -// RepPicksApplied says, for each side, whether the merge would APPLY the -// representative pick (state.GroupRepPickA / state.GroupRepPickB) that incoming -// carries onto stored. The pick judges (mobileapp: a participant's score write -// and the organiser's) judge a pick for team membership exactly when it lands, -// and ask this rather than re-deriving the merge's rule: whether the write names -// the side's pick, whether its stamp is older than the stored pick's, whether a -// hold takes the whole write (a stale finish naming another winner is held -// whole, HoldReasonFinishAtomic), and what becomes of a pick on a row that is -// gone or never was (settleOrphanPicks) all live in mergeMatchWrite, and run -// here. A pick that is held, displaced or sits on no row is kept in the match's -// history by the real write and is not the judge's to refuse. +// AppliedGroups says which of the groups incoming changes the merge would APPLY +// onto stored (an echo of the stored value counts as applied, as in +// MergeReport.Applied). The judges of a score write (mobileapp: the +// representative picks, and a participant's numbered bout rows) judge a group's +// content exactly when it lands, and ask this rather than re-deriving the +// merge's rule: whether the write names the group, whether its stamp is older +// than the stored group's, whether a hold takes the whole write (a stale finish +// naming another winner is held whole, HoldReasonFinishAtomic), and what +// becomes of a pick on a row that is gone or never was (settleOrphanPicks) all +// live in mergeMatchWrite, and run here. A group that is held, displaced or +// sits on no row is kept in the match's history by the real write and is not +// the judge's to refuse. // // It is the probe shape decisionHeldByMerge and engiFinishProbe have: the merge // runs on COPIES of both matches (it rewrites the write it is given), so neither @@ -30,20 +31,19 @@ import ( // The write is folded onto the stored pairing first (reconcileSides, as both // branches do before they merge), because the merge works the verdict out from // the sides. A write that names other competitors is refused by the engine -// (ErrMatchSideMismatch) and lands nothing, so it applies no pick. The bracket +// (ErrMatchSideMismatch) and lands nothing, so it applies no group. The bracket // branch skips that fold for a match not playable yet (sidesBeforeMerge) and // refuses the write itself; the probe folds unconditionally, which cannot // disagree, since an empty stored side never mismatches. -func RepPicksApplied(stored, incoming *state.MatchResult, comp *state.Competition, knockout bool) (sideA, sideB bool) { +func AppliedGroups(stored, incoming *state.MatchResult, comp *state.Competition, knockout bool) []string { if stored == nil { stored = &state.MatchResult{} // a match the store does not hold has no row and no stamps } prior, probe := cloneForProbe(stored), cloneForProbe(incoming) if reconcileSides(&probe, storedSides{A: prior.SideA, B: prior.SideB, AID: prior.SideAID, BID: prior.SideBID}) { - return false, false + return nil } - rep := mergeMatchWrite(&prior, &probe, matchWriteForward, mergeCtx{comp: comp, knockout: knockout, nilSubsClear: !knockout}) - return slices.Contains(rep.Applied, state.GroupRepPickA), slices.Contains(rep.Applied, state.GroupRepPickB) + return mergeMatchWrite(&prior, &probe, matchWriteForward, mergeCtx{comp: comp, knockout: knockout, nilSubsClear: !knockout}).Applied } // cloneForProbe is a copy of m the merge can rewrite without reaching m: its diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index cfe64690d..17bbac3af 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -1980,7 +1980,8 @@ var errRepBoutAdded = &selfRunRefusal{ // holdSelfReportedWriteUnderTx is the one judge of an anonymous self-run score // write. It runs inside WithTransaction (under the per-comp lock), so the -// stored match it reads, once, cannot change before the write lands. It +// stored match it is handed (snap, found: read once for every guard of the +// write by the caller) cannot change before the write lands. It // refuses the write or lets it through, and it can rewrite it on the way: a // representative bout the organiser decided is written back exactly as // stored. In order: @@ -2003,8 +2004,9 @@ var errRepBoutAdded = &selfRunRefusal{ // kept, scored or not, and a finish is refused (errRepBoutAdded). // - A numbered bout row may seat only its own team's members and may name as // its winner only one of its two fighters, for what the write introduces -// against the stored row at that position (numberedRowsRefusal; -// errBoutFighterNotInTeam, errWinnerMemberNotInBout). The organiser's write +// against the stored row at that position, and only when the merge applies +// the row's bout group (numberedRowsRefusal; errBoutFighterNotInTeam, +// errWinnerMemberNotInBout). The organiser's write // is not judged there. A members file that cannot be read refuses a write // that needs it, terminally (errTeamMembersUnreadable). // - Once the organiser recorded a judges' decision on the representative @@ -2025,15 +2027,11 @@ var errRepBoutAdded = &selfRunRefusal{ // so its sub-results are not judged, and the engine derives its winner from // the bouts it keeps. // -// Fails closed: a load error rejects the request rather than allowing the write -// (matchSnapshotOrErr). A match in neither store passes on purpose: the write -// that follows under the same lock refuses it (errMatchNotFound), so nothing -// can land on it. -func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, result *state.MatchResult, startOnly bool) error { - snap, found, err := matchSnapshotOrErr(stx, compID, matchID, "self-run") - if err != nil { - return err - } +// Fails closed: the caller's read of the snapshot rejects the request on a load +// error rather than allowing the write (matchSnapshotOrErr). A match in neither +// store (found false) passes on purpose: the write that follows under the same +// lock refuses it (errMatchNotFound), so nothing can land on it. +func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, startOnly bool) error { if !found { return nil } @@ -2052,7 +2050,7 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, res // is one of its two fighters. Judged before the representative bout's // branches below, which return early on the rows they drop or keep. judge := &memberJudge{stx: stx, compID: compID} - if err := numberedRowsRefusal(judge, snap, subs); err != nil { + if err := numberedRowsRefusal(judge, snap, result, subs); err != nil { return err } stored := snap.RepBout @@ -2131,10 +2129,11 @@ var errRepMemberNotInTeam = &selfRunRefusal{ // (judgeRepPicksUnderTx), as the merge will order it (bc-mrgc): each side's // representative (state.GroupRepPickA, state.GroupRepPickB), side by side. A // pick is judged exactly when the merge APPLIES it, and the merge is asked -// (engine.RepPicksApplied runs it on copies) rather than re-derived here: a -// side's change the write does not name, one whose stamp is strictly older than -// the stored change's (domain.ApplyByTimestamp: an equal stamp applies), one on -// a row the write neither has nor creates, and every pick of a write the merge +// (memberJudge.applied: engine.AppliedGroups runs it on copies) rather than +// re-derived here: a side's change the write does not name, one whose stamp is +// strictly older than the stored change's (domain.ApplyByTimestamp: an equal +// stamp applies), one on a row the write neither has nor creates, and every +// pick of a write the merge // holds whole (a stale finish naming another winner) is never written, so it is // not judged. Such a pick is held and kept in the match's history, as any stale // change is, and the write is answered applied with the held side's group in @@ -2159,12 +2158,12 @@ func repRowRefusal(j *memberJudge, snap matchSnapshot, result *state.MatchResult if !introducesA && !introducesB { return nil } - comp, err := j.stx.LoadCompetition(j.compID) + applied, err := j.applied(snap, result) if err != nil { - return fmt.Errorf("representative pick guard: load competition %s: %w", j.compID, err) + return err } - appliedA, appliedB := engine.RepPicksApplied(snap.Stored, result, comp, snap.InBracket) - return rowMembersOutsideTeams(j, snap.Pairing, row, *stored, appliedA, appliedB, errRepMemberNotInTeam) + return rowMembersOutsideTeams(j, snap.Pairing, row, *stored, + slices.Contains(applied, state.GroupRepPickA), slices.Contains(applied, state.GroupRepPickB), errRepMemberNotInTeam) } // judgeRepPicksUnderTx judges the representative picks of an organiser's score @@ -2192,13 +2191,15 @@ func repRowRefusal(j *memberJudge, snap matchSnapshot, result *state.MatchResult // // A match with no representative bout is judged against an empty row: the // organiser's write can create the row (the participant's never does), and every -// pick on it is then introduced. A write whose row names no member reads -// nothing, not even the stored match. Bulk-score and quick-score do not come -// through here: they are main-gated, server-built writes. +// pick on it is then introduced. A write whose row names no member judges +// nothing, and reads neither the members nor the competition. Bulk-score and +// quick-score do not come through here: they are main-gated, server-built +// writes. // // Called under the write's lock, where the participant's judge is, before the -// correction audit. -func judgeRepPicksUnderTx(stx state.StoreTx, compID, matchID string, result *state.MatchResult) error { +// correction audit, over the one snapshot (snap, found) the write reads for +// both. +func judgeRepPicksUnderTx(stx state.StoreTx, compID string, snap matchSnapshot, found bool, result *state.MatchResult) error { i := state.DaihyosenSubIndex(result.SubResults) if i < 0 { return nil @@ -2207,10 +2208,6 @@ func judgeRepPicksUnderTx(stx state.StoreTx, compID, matchID string, result *sta if row.SideAMemberID == "" && row.SideBMemberID == "" { return nil } - snap, found, err := matchSnapshotOrErr(stx, compID, matchID, "representative pick") - if err != nil { - return err - } if !found { return nil // the write that follows under the same lock refuses an unknown match } @@ -2255,7 +2252,8 @@ var errTeamMembersUnreadable = &selfRunRefusal{ } // memberJudge is what the member checks of ONE score write share: the -// transaction they run in, and the competition's team members, read at most once +// transaction they run in, and the competition's team members and the +// competition itself (what the merge is asked about), each read at most once // and only when a row introduces a member id to judge. A write that introduces // none never reads the file, so a corrupt team-members.yaml blocks no ordinary // scoring; one that introduces several reads it once. @@ -2266,6 +2264,28 @@ type memberJudge struct { read bool squads map[string][]domain.TeamMember err error + + compRead bool + comp *state.Competition + compErr error +} + +// applied says which of the groups the write changes the merge would APPLY +// onto the stored match (engine.AppliedGroups, which runs the merge on copies). +// A member is judged exactly when the group it rides on lands: a group the merge +// holds is kept in the match's history by the real write, and refusing the write +// for it would lose the groups of the same write that do apply. +func (j *memberJudge) applied(snap matchSnapshot, result *state.MatchResult) ([]string, error) { + if !j.compRead { + j.compRead = true + if j.comp, j.compErr = j.stx.LoadCompetition(j.compID); j.compErr != nil { + j.compErr = fmt.Errorf("member guard: load competition %s: %w", j.compID, j.compErr) + } + } + if j.compErr != nil { + return nil, j.compErr + } + return engine.AppliedGroups(snap.Stored, result, j.comp, snap.InBracket), nil } // holds reports whether the team holds the member (teamMemberIDs, the one @@ -2335,10 +2355,10 @@ func rowMembersOutsideTeams(j *memberJudge, pairing domain.WinnerAttribution, ro // behind, and refusing them would refuse every later write of the match. Pure: // it reads no store. func winnerMemberOutsideBout(row, stored state.SubMatchResult) error { - winner := row.WinnerMemberID - if winner == "" || winner == stored.WinnerMemberID { + if !introducesWinnerMember(row, stored) { return nil } + winner := row.WinnerMemberID a, b := row.SideAMemberID, row.SideBMemberID if a == "" { a = stored.SideAMemberID @@ -2352,6 +2372,13 @@ func winnerMemberOutsideBout(row, stored state.SubMatchResult) error { return errWinnerMemberNotInBout } +// introducesWinnerMember reports whether a row's winner member id is one the +// write changes against the stored row at the same position, as +// introducesMember does for a side's fighter. +func introducesWinnerMember(row, stored state.SubMatchResult) bool { + return row.WinnerMemberID != "" && row.WinnerMemberID != stored.WinnerMemberID +} + // numberedRowsRefusal judges every numbered bout row of a participant's write // (the representative bout's row, position -1, is repRowRefusal's): a fighter it // introduces must be on the side's team (errBoutFighterNotInTeam), and a winner @@ -2359,7 +2386,16 @@ func winnerMemberOutsideBout(row, stored state.SubMatchResult) error { // The organiser's write is not judged here, as the lineup's is not (mp-q722: the // organiser is always editable). Rows are compared with the stored match's at // the same position (snap.Stored), a row it lacks counting as introduced. -func numberedRowsRefusal(j *memberJudge, snap matchSnapshot, subs []state.SubMatchResult) error { +// +// A row is judged exactly when the merge APPLIES its bout group +// (state.BoutGroup; memberJudge.applied, as repRowRefusal judges a pick): a +// row whose stored counterpart is newer, or that the write does not name, is +// held and kept in the match's history, and refusing the write for it would +// lose the groups of the same write that do apply. The merge is asked once per +// write, and only when some row introduces a member. +func numberedRowsRefusal(j *memberJudge, snap matchSnapshot, result *state.MatchResult, subs []state.SubMatchResult) error { + var applied []string + probed := false for _, row := range subs { if row.Position == state.DaihyosenSubPosition { continue @@ -2373,6 +2409,21 @@ func numberedRowsRefusal(j *memberJudge, snap matchSnapshot, subs []state.SubMat } } } + if !introducesWinnerMember(row, stored) && + !introducesMember(snap.Pairing.SideAID, row.SideAMemberID, stored.SideAMemberID) && + !introducesMember(snap.Pairing.SideBID, row.SideBMemberID, stored.SideBMemberID) { + continue // nothing to judge, so nothing is read + } + if !probed { + var err error + if applied, err = j.applied(snap, result); err != nil { + return err + } + probed = true + } + if !slices.Contains(applied, state.BoutGroup(row.Position)) { + continue // the merge holds this row, whose fighters are not written + } if err := winnerMemberOutsideBout(row, stored); err != nil { return err } @@ -2412,8 +2463,8 @@ type matchStores interface { // here deep-clones the stored sub-bouts (mp-gmcg review: that was a write-only // value, and an expensive one, under the per-comp write lock). Stored is the // one reader of the stored match's rows, and it SHARES the loaded copy rather -// than cloning it: the self-run judge (numberedRowsRefusal) and the pick probe -// (engine.RepPicksApplied) only read it, and the probe clones before it merges. +// than cloning it: the self-run judge (numberedRowsRefusal) and the merge probe +// (engine.AppliedGroups) only read it, and the probe clones before it merges. // // InBracket is the match's HOME rather than its content: the pool write is a // whole-struct overwrite that carries ReopenPending itself, while the bracket @@ -2450,8 +2501,8 @@ type matchSnapshot struct { // reads (a pool row as loaded; a bracket match through // engine.BracketMatchAsResult). It is what the judges ask about, and it // shares the loaded copy's rows, so a reader clones before it changes - // anything: the pick judges ask the merge on copies (engine.RepPicksApplied) - // which picks it would apply, and the participant's numbered-row judge reads + // anything: the member judges ask the merge on copies (engine.AppliedGroups) + // which groups it would apply, and the participant's numbered-row judge reads // the stored row at each position (numberedRowsRefusal). nil only on a // zero snapshot (no match found). Stored *state.MatchResult @@ -2810,10 +2861,10 @@ type correctionCheck struct { ClearBracketReopenPending bool } -// applyCorrectionReasonUnderTx applies the correction-reason audit policy to -// r and returns the match's STORED status. Runs inside WithTransaction -// (caller MUST hold the per-comp lock via the supplied StoreTx), so the -// is-completed read is race-free against a concurrent score write. +// applyCorrectionReason applies the correction-reason audit policy to r over +// the match's STORED snapshot and returns the verdict with the stored status. +// The snapshot is read inside WithTransaction (the per-comp lock is held), so +// the is-completed read is race-free against a concurrent score write. // // The policy is shared by the single-score and bulk-score paths so the audit // rule cannot drift between them (same reason applyKachinukiMerge is shared @@ -2851,20 +2902,15 @@ type correctionCheck struct { // what keeps the flag alive across a pool match's running writes, which would // otherwise blank it through the same whole-struct overwrite. // -// Returning the stored status keeps this the transaction's ONLY match lookup: +// Returning the stored status spares the caller a second match lookup: // StoreTx loads deliberately bypass the file cache (state.LoadPoolMatchesLocked -// / loadBracketLocked), so a second walk is a real os.Open plus a full CSV +// / loadBracketLocked), so a walk is a real os.Open plus a full CSV // parse (and, for a bracket match, an os.ReadFile plus a whole-bracket -// json.Unmarshal) while the per-comp WRITE lock is held. The caller's -// stale-after-complete guard reads the returned status instead. -func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult) (correctionCheck, error) { - // matchSnapshotOrErr fails CLOSED on a load error: the correction-reason - // gate below must not pass on an assumed-running status, silently - // overwriting a finalized result without the mandatory reason. - snap, _, err := matchSnapshotOrErr(stx, compID, matchID, "correction-reason") - if err != nil { - return correctionCheck{}, err - } +// json.Unmarshal) while the per-comp WRITE lock is held. The score handler +// reads the snapshot ONCE for every guard of the write (the self-run judge or +// the pick judge, then this), and the start gate after it reads the returned +// status instead. +func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionCheck { r.ReopenPending = snap.ReopenPending if r.Status == state.MatchStatusCompleted && snap.Status == state.MatchStatusCompleted { // A finish made BEFORE the stored result (a queued Finish replayed @@ -2877,12 +2923,12 @@ func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r * // exact replay, which applies, so it is still a correction to // justify. if r.ModifiedAt > 0 && r.ModifiedAt < snap.ResultStamp { - return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision}, nil + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} } if r.CorrectionReason == "" { - return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision, Reject: missingCorrectionReasonError()}, nil + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision, Reject: missingCorrectionReasonError()} } - return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision}, nil + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} } if r.Status == state.MatchStatusCompleted && snap.ReopenPending { // Ended again after a reason-less reopen: never refused, the @@ -2892,7 +2938,7 @@ func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r * StoredStatus: snap.Status, StoredDecision: snap.Decision, ClearBracketReopenPending: snap.InBracket, - }, nil + } } // Non-completing write: pin the reason to the STORED one. This is not just a // carry-forward — it also refuses a client-supplied reason on a running @@ -2906,7 +2952,23 @@ func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r * // without re-checking that a running write still cannot rewrite the note // (mp-gmcg review F5). r.CorrectionReason = snap.CorrectionReason - return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision}, nil + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} +} + +// applyCorrectionReasonUnderTx reads the match's snapshot itself and applies +// applyCorrectionReason to it: the entry point of a write that runs no other +// guard over the stored match (bulk-score, one read per entry). The score +// handler reads the snapshot once for all its guards and asks +// applyCorrectionReason directly. +func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult) (correctionCheck, error) { + // matchSnapshotOrErr fails CLOSED on a load error: the correction-reason + // gate must not pass on an assumed-running status, silently overwriting a + // finalized result without the mandatory reason. + snap, _, err := matchSnapshotOrErr(stx, compID, matchID, "correction-reason") + if err != nil { + return correctionCheck{}, err + } + return applyCorrectionReason(snap, r), nil } // missingCorrectionReasonError is the refusal for overwriting a completed @@ -3482,11 +3544,20 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // court, plus participant ineligibility. Withdrawal decisions bypass // so operators can record kiken on matches with ineligible participants. return tx.WithTransaction(id, func(stx state.StoreTx) error { + // The stored match is read ONCE, here, for every guard below: + // nothing writes it between them (the self-run judge rewrites + // result only), and each walk of it is a real file read while + // the per-comp write lock is held. Fails CLOSED on a load error + // (the correction-reason gate must not pass on an assumed status). + snap, found, snapErr := matchSnapshotOrErr(stx, id, mid, "score write") + if snapErr != nil { + return snapErr + } // mp-ba3, bc-dhas: the self-run judge runs under the per-comp // lock, so concurrent anonymous submissions cannot race it. It // may rewrite result's representative bout before the write. if resultSource == "self-reported" { - if err := holdSelfReportedWriteUnderTx(stx, id, mid, result, body.StartOnly); err != nil { + if err := holdSelfReportedWriteUnderTx(stx, id, mid, snap, found, result, body.StartOnly); err != nil { engErr = err return nil } @@ -3495,7 +3566,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // picks it introduces and on nothing else (judgeRepPicksUnderTx). // A start keeps the stored score whatever it sends, so the // row it carries is no pick. - if err := judgeRepPicksUnderTx(stx, id, mid, result); err != nil { + if err := judgeRepPicksUnderTx(stx, id, snap, found, result); err != nil { engErr = err return nil } @@ -3505,14 +3576,11 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // reopened without a reason is never refused (operator ruling // 2026-09-25). A genuine first finalization needs no reason but // must carry the STORED one forward. All of that rule lives in - // applyCorrectionReasonUnderTx, shared with the bulk-score path. It + // applyCorrectionReason, shared with the bulk-score path. It // runs inside the tx so the is-completed read is race-free (same - // lock), and the status it returns is reused by the stale-write - // guard below rather than looked up a second time. - check, snapErr := applyCorrectionReasonUnderTx(stx, id, mid, result) - if snapErr != nil { - return snapErr - } + // lock), over the snapshot read above, and the status it returns + // is reused by the start gate below. + check := applyCorrectionReason(snap, result) existingStatus := check.StoredStatus if check.Reject != nil { engErr = check.Reject diff --git a/internal/mobileapp/organiser_rep_pick_judge_test.go b/internal/mobileapp/organiser_rep_pick_judge_test.go index 96c59d62d..168336c1d 100644 --- a/internal/mobileapp/organiser_rep_pick_judge_test.go +++ b/internal/mobileapp/organiser_rep_pick_judge_test.go @@ -186,7 +186,7 @@ func TestOrganiser_AStalePickTheMergeHoldsIsNotRefused(t *testing.T) { // A finish stamped before the stored verdict that names another winner is held // WHOLE by the merge (HoldReasonFinishAtomic): its picks land nowhere, even when // their own stamps are newer than the stored picks'. The judge asks the merge -// (engine.RepPicksApplied), so such a write is answered superseded and kept in +// (engine.AppliedGroups), so such a write is answered superseded and kept in // the history, never refused for a pick that would not have been written. func TestOrganiser_AStaleFinishWithAForeignPickIsSupersededNotRefused(t *testing.T) { for _, mode := range organiserModes { diff --git a/internal/mobileapp/self_run_bout_members_test.go b/internal/mobileapp/self_run_bout_members_test.go index e1c8c874f..bb802edcd 100644 --- a/internal/mobileapp/self_run_bout_members_test.go +++ b/internal/mobileapp/self_run_bout_members_test.go @@ -218,3 +218,62 @@ func TestSelfRun_UnreadableTeamMembersRefusesTheLineupTerminally(t *testing.T) { _, ok := f.savedLineup(t, "PoolA-0") assert.False(t, ok, "a refused lineup writes nothing") } + +// A numbered row is judged exactly when the merge APPLIES its bout group, as a +// representative pick is (repRowRefusal): a row whose stored counterpart is +// NEWER is held and kept in the match's history, and judging it would refuse the +// whole write, terminally, for a fighter that would never have been written, +// losing the groups of the write that apply. Here the stored bout 1 names fighter +// Y, stamped T; the write names another team's member on it under T-1, beside +// bouts 2 and 3 and the scoreline, which apply. +func TestSelfRun_AHeldNumberedRowIsNotJudged(t *testing.T) { + f := newRepBoutFixture(t, true) + a, b := f.teamMembers(t) + f.setB1(t, func(bm *state.BracketMatch) { + bm.SubResults[0].SideAMemberID = a[0].ID + bm.ModifiedAt = f.now + bm.GroupStamps = map[string]int64{ + state.BoutGroup(1): f.now, + state.GroupPoints: f.now - 10_000, + state.BoutGroup(2): f.now - 10_000, + state.BoutGroup(3): f.now - 10_000, + state.GroupResult: f.now - 10_000, + state.GroupRepPickA: f.now - 10_000, + } + }) + + w := f.bouts("", f.now-1, map[int]boutIDs{1: {a: b[0].ID}}) + require.Equal(t, http.StatusOK, w.Code, "the held row is kept in the history, not refused: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), `"heldGroups":["bout:1"]`) + assert.Equal(t, a[0].ID, f.storedBout(t, 1).SideAMemberID, "the newer stored fighter stands") +} + +// The pin beside it: the same write stamped AFTER the stored row applies the row, +// so its fighter is judged and refused. +func TestSelfRun_ANewerNumberedRowIsStillJudged(t *testing.T) { + f := newRepBoutFixture(t, true) + a, b := f.teamMembers(t) + f.setB1(t, func(bm *state.BracketMatch) { + bm.SubResults[0].SideAMemberID = a[0].ID + bm.ModifiedAt = f.now + bm.GroupStamps = map[string]int64{state.BoutGroup(1): f.now} + }) + + w := f.bouts("", f.now+1, map[int]boutIDs{1: {a: b[0].ID}}) + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", fighterNotOnTeamBody) + assert.Equal(t, a[0].ID, f.storedBout(t, 1).SideAMemberID, "a refused write writes nothing") +} + +// A row the write does not name in `changed` is not written either, so its +// fighter is not judged. +func TestSelfRun_ANumberedRowTheWriteDoesNotNameIsNotJudged(t *testing.T) { + f := newRepBoutFixture(t, true) + _, b := f.teamMembers(t) + + sheet := scoreSheet(state.MatchStatusRunning, "", f.now+100, nil) + sheet["subResults"].([]any)[0].(map[string]any)["sideAMemberId"] = b[0].ID + sheet["changed"] = []string{state.GroupPoints} + w := f.send(http.MethodPut, repBoutMatchPath+"/score", "", sheet) + require.Equal(t, http.StatusOK, w.Code, "bout 1 is not among the groups the write changes: %s", w.Body.String()) + assert.Empty(t, f.storedBout(t, 1).SideAMemberID, "and it is not written") +} diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index ab1022e35..1d3b9ac1e 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -818,7 +818,9 @@ func TestHoldSelfReportedWriteUnderTx(t *testing.T) { result := &state.MatchResult{SubResults: tc.incoming, Winner: tc.winner, WinnerID: tc.winnerID, Status: tc.status} err := store.WithTransaction("c1", func(stx state.StoreTx) error { - return holdSelfReportedWriteUnderTx(stx, "c1", "B1", result, tc.startOnly) + snap, found, err := matchSnapshotOrErr(stx, "c1", "B1", "test") + require.NoError(t, err) + return holdSelfReportedWriteUnderTx(stx, "c1", "B1", snap, found, result, tc.startOnly) }) if tc.want != nil { var refusal *selfRunRefusal diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 062d84355..b2b14bb1c 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -59,11 +59,11 @@ info: a fighter whose member id the side's team does not hold is refused with 400 `team_member_not_in_team`, and a `winnerMemberId` that is not one of the row's two fighters is refused with 400 `winner_member_not_in_bout`; an id the stored row already holds is never - judged, and the organiser's numbered rows are not judged at all. A pick is judged only when - the merge would apply it: a write stamped before a stored representative, one that does not - name the side's pick in `changed`, one whose pick sits on a row it neither has nor creates, - and a finish the merge holds whole are not judged for that pick, which is not written and - is kept in the match's history. When the competition's `team-members.yaml` cannot be read, + judged, and the organiser's numbered rows are not judged at all. A pick or a numbered row is + judged only when the merge would apply it: a write stamped before the stored pick or row, one + that does not name the pick or the row's bout in `changed`, one whose pick sits on a row it + neither has nor creates, and a finish the merge holds whole are not judged for it; it is not + written and is kept in the match's history. When the competition's `team-members.yaml` cannot be read, a write that needs it to judge a choice (a pick, a numbered row's fighter, or the lineup save) is refused with 409 `team_members_unreadable` and nothing is written, so the offline queue does not retry it; a write that introduces no member id never reads the file. The rest of the write is @@ -2610,8 +2610,12 @@ paths: ids, or the stored row's for a side the row is silent about) is refused with `error: winner_member_not_in_bout` and the `message` "The winner recorded is not one of the two fighters in this bout." An id the - stored row already holds is never judged. The organiser's numbered - rows are not judged. + stored row already holds is never judged, and a row is judged only + when the merge would apply its `bout:` group: a row stamped + before the stored one, or one the write does not name in `changed`, is + not judged; it is not written and is kept in the match's history, and + the rest of the write is applied and answered with `heldGroups`, e.g. + `["bout:1"]`. The organiser's numbered rows are not judged. A `completed` write whose `decision` is empty or `hikiwake` on a match whose stored result is a completed withdrawal (kiken or diff --git a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx index 4422af521..7a7249022 100644 --- a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx @@ -291,6 +291,28 @@ describe('the refusal notice goes when it stops being true', () => { expect(noticeIn(rowOf(utils, 'Alice'))).toBeFalsy(); }); + // PR #463 round 15 (F): the court console keeps a refusal across an empty list + // (a list that is empty while it loads says nothing about the match), and the + // Scores tab asks the same rule (start_match.jsx startRefusalStands). + it('but not across an empty list: it says nothing about the match', async () => { + const { utils, onEditScore } = await refused(); + matches = []; + await act(async () => { utils.rerender(ui(onEditScore, vi.fn())); }); + matches = [M1, M2, M3]; + await act(async () => { utils.rerender(ui(onEditScore, vi.fn())); }); + expect(noticeIn(rowOf(utils, 'Alice')), 'the same refusal is still there').toBeTruthy(); + expect(noticeIn(rowOf(utils, 'Alice')).textContent).toBe(WITHDREW); + }); + + it('when a list that still holds matches no longer holds the refused one', async () => { + const { utils, onEditScore } = await refused(); + matches = [M1, M3]; + await act(async () => { utils.rerender(ui(onEditScore, vi.fn())); }); + matches = [M1, M2, M3]; + await act(async () => { utils.rerender(ui(onEditScore, vi.fn())); }); + expect(noticeIn(rowOf(utils, 'Alice')), 'moved away and back is a new situation').toBeFalsy(); + }); + it('when the operator opens another match', async () => { const { utils } = await refused(); const carolScore = rowOf(utils, 'Carol').querySelector('button.test-score-open'); diff --git a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx index 74afbc686..3680c501f 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_representative.render.test.jsx @@ -678,6 +678,62 @@ describe('team daihyosen representative picker (bc-dhrp)', () => { expect(patch.changed).toEqual(['bout:-1']); }); + // PR #463 round 15 (G): Remove then Undo puts the whole board back to the + // server's, the representative picks included, so the editor agrees with the picks + // the server holds (useWithdrawalRemoval's onUndo). The agreement matters when a + // pick was built into a write earlier and has not echoed back: the next build would + // otherwise compare the board the Undo restored (no pick) against that write's pick, + // find them different, and name the group with an EMPTY pick under a newer stamp, + // clearing the editor's own pick on the server. + it('R35: Undo of a withdrawal removal agrees the picks: a pick written before it, undone, is not named again', async () => { + const { onSubmit } = await mount(makeMatch({ + status: 'completed', phase: 'bracket', round: 'Round 1', + decision: 'kiken-voluntary', decisionBy: 'aka', decisionReason: 'knee', + winner: { id: 't2', name: 'Team B' }, + subResults: [DH_ADDED], + })); + // The first Save correction asks for the reason; the editor keeps it, so the next + // one is sent straight away. + const saveCorrection = async () => { + await act(async () => { + fireEvent.click([...document.querySelectorAll('.score-nav button')].find((b) => b.textContent === 'Save correction')); + }); + const confirm = [...document.querySelectorAll('.reason-prompt button')].find((b) => b.textContent === 'Confirm'); + if (confirm) await act(async () => { fireEvent.click(confirm); }); + }; + await pickFromDh('AKA', 'Alice'); + expect(dhInput('AKA').value).toBe('Alice'); + await saveCorrection(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].changed, 'the first save carries Aka\'s pick').toContain('repPickA'); + // The pick has not echoed back: the match prop still holds no pick when the operator + // removes the withdrawal and takes the removal back. + await act(async () => { fireEvent.click(screen.getByTestId('remove-withdrawal')); }); + await act(async () => { fireEvent.click(screen.getByTestId('remove-withdrawal-undo')); }); + expect(dhInput('AKA').value, 'Undo put the server\'s row back, without the pick').not.toBe('Alice'); + await saveCorrection(); + expect(onSubmit).toHaveBeenCalledTimes(2); + const second = onSubmit.mock.calls[1][0].changed || []; + expect(second, 'the pick was taken back with the board, not changed again').not.toContain('repPickA'); + expect(second).not.toContain('repPickB'); + }); + + // PR #463 round 15 (I): a team member with no name is labelled by the team's number and + // the member's number, as every other surface labels one (squadLabelFor). + it('R36: the History labels a member who has no name by the team number and the member number', async () => { + const at = Date.now(); + window.API.fetchSquads.mockResolvedValue({ t1: [{ id: 'm3a', name: '', index: 3 }], t2: SQUADS.t2 }); + window.API.fetchMatchHistory.mockResolvedValue([ + { matchId: 'm-ko-1', door: 'score', stamp: at, receivedAt: at, changed: ['repPickA'], outcomes: { repPickA: 'held' }, held: { repPickA: { sideAMemberId: 'm3a' } } }, + ]); + await mount(makeMatch({ sideA: { id: 't1', name: 'Team A', number: 'K1' }, subResults: [DH_EMPTY] })); + await waitFor(() => expect(window.API.fetchSquads).toHaveBeenCalled()); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await act(async () => { fireEvent.click(screen.getByTestId('match-history-toggle')); }); + const lines = (await screen.findAllByTestId('match-history-held')).map((el) => el.textContent); + expect(lines).toEqual(["Kept in history: Aka's pick for the representative bout, picked K1.3"]); + }); + it('R34: the History names the representatives the sheet holds the members of, and an id the team no longer holds', async () => { const at = Date.now(); window.API.fetchMatchHistory.mockResolvedValue([ diff --git a/web-mobile/js/__tests__/start_match.test.jsx b/web-mobile/js/__tests__/start_match.test.jsx index b82787fd6..6b8a9520c 100644 --- a/web-mobile/js/__tests__/start_match.test.jsx +++ b/web-mobile/js/__tests__/start_match.test.jsx @@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest'; import { CLOCK_SKEW_REASON_TEXT } from '../write_result.jsx'; import { - classifyStartOutcome, startFailureMessage, createStartGuard, startPatch, + classifyStartOutcome, startFailureMessage, createStartGuard, startPatch, startRefusalStands, START_CLOCK_SKEW_MESSAGE, START_FAILED_MESSAGE, } from '../start_match.jsx'; @@ -105,3 +105,27 @@ describe('startPatch', () => { expect(startPatch()).not.toBe(startPatch()); }); }); + +// PR #463 round 15 (F): how long a refused Start notice lives is ONE rule, asked +// by the court console and the Scores tab alike. +describe('startRefusalStands', () => { + it('stands while the refused match is still scheduled in the list', () => { + expect(startRefusalStands({ row: { status: 'scheduled' }, listHoldsMatches: true })).toBe(true); + }); + + it('falls when the refused match has left scheduled', () => { + for (const status of ['running', 'completed']) { + expect(startRefusalStands({ row: { status }, listHoldsMatches: true })).toBe(false); + } + }); + + it('falls when a list that holds matches no longer holds the refused one', () => { + expect(startRefusalStands({ row: null, listHoldsMatches: true })).toBe(false); + expect(startRefusalStands({ row: undefined, listHoldsMatches: true })).toBe(false); + }); + + it('stands across an empty list: it says nothing about the match', () => { + expect(startRefusalStands({ row: null, listHoldsMatches: false })).toBe(true); + expect(startRefusalStands({ row: undefined, listHoldsMatches: false })).toBe(true); + }); +}); diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx index 8b19ed659..390b5dc2f 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -29,7 +29,7 @@ import { matchShowsScore } from './match_shows_score.jsx'; // 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 } from './start_match.jsx'; +import { startPatch, classifyStartOutcome, startFailureMessage, createStartGuard, startRefusalStands } from './start_match.jsx'; const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA } = React; @@ -161,17 +161,18 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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 startRefusalStatus = startRefusalRow ? startRefusalRow.status : null; - // It is dropped when the refused match leaves `scheduled` (or the data), or - // when the operator opens a DIFFERENT match. 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. + 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 || startRefusalStatus !== "scheduled") setStartRefusal(null); - }, [openKey, startRefusalKey, startRefusalStatus]); + 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 diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 0bda0ef44..f4056d9e9 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -2451,8 +2451,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub onUndo: () => { setSubs(serverSubs); setFinishRefused(false); - // The whole board is the server's again, so the editor agrees with every row. + // 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), }); @@ -2966,7 +2972,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub 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 || squadMemberLabel((side === "a" ? m.sideA : m.sideB)?.number || "", member.index); + return member.name || squadLabelFor(side, memberId); }; // slotLabelFor: the fallback label for a NUMBERED fixed-order position diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index ec4908028..b8f936360 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -36,7 +36,7 @@ import { // 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 } from './start_match.jsx'; +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'; @@ -1201,8 +1201,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, ? filteredScheduled.filter((m) => matchKey(m) !== matchKey(upNext)) : filteredScheduled; // 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; the - // Scores tab keeps the same rule, admin_schedule_score_editor.jsx). It is + // 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 @@ -1217,11 +1218,11 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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 courtHoldsMatches = courtMatchesRaw.length > 0; + const startErrorStands = startRefusalStands({ row: startErrorRow, listHoldsMatches: courtMatchesRaw.length > 0 }); useEffectSh(() => { if (!startErrorKey) return; - if (startErrorRow ? startErrorStatus !== "scheduled" : courtHoldsMatches) setStartError(null); - }, [startErrorKey, startErrorStatus, courtHoldsMatches]); + 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 diff --git a/web-mobile/js/side_marks.jsx b/web-mobile/js/side_marks.jsx index 939b53351..7fbf80208 100644 --- a/web-mobile/js/side_marks.jsx +++ b/web-mobile/js/side_marks.jsx @@ -96,9 +96,9 @@ export function sideMarks(decision, decidedByHantei) { // 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). +// 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 diff --git a/web-mobile/js/start_match.jsx b/web-mobile/js/start_match.jsx index 3cc8ae249..024f6c1d3 100644 --- a/web-mobile/js/start_match.jsx +++ b/web-mobile/js/start_match.jsx @@ -95,6 +95,21 @@ 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. // From 5a385369138885c2674947fdf6b7c0f6bc8e2ac7 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 12:41:50 +0100 Subject: [PATCH 32/38] fix: an announcement already taken down is not a failed withdrawal Found in the browser check: two consoles calling the same match send the identical announcement twice, so the server keeps one; when the match starts, both consoles withdraw their call, and the second DELETE answers 404 because the first already took the banner down. deleteAnnouncement now reads a 404 as the outcome the caller asked for (the banner is gone), so the console logs no warning and the organiser's Dismiss of an announcement that expired or another console withdrew refreshes the list instead of showing "Announcement not found". docs: console-correct-completed recaptured (the Completed row's winner tick takes the row's gap). Co-Authored-By: Claude Opus 5.5 --- .../screenshots/console-correct-completed.png | Bin 97983 -> 97941 bytes .../js/__tests__/delete_announcement.test.jsx | 22 ++++++++++++++++++ web-mobile/js/api_client.jsx | 3 +++ 3 files changed, 25 insertions(+) create mode 100644 web-mobile/js/__tests__/delete_announcement.test.jsx diff --git a/docs/screenshots/console-correct-completed.png b/docs/screenshots/console-correct-completed.png index 6590846b2b6d311bee688fc6cf6a970177c62038..37cd7bf04dffcbc18dd8a781bc49bb52c668f450 100644 GIT binary patch literal 97941 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 E1B-#R=l}o! literal 97983 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�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 P2IrBys$9N|iO>H6ar50t diff --git a/web-mobile/js/__tests__/delete_announcement.test.jsx b/web-mobile/js/__tests__/delete_announcement.test.jsx new file mode 100644 index 000000000..13d8a885b --- /dev/null +++ b/web-mobile/js/__tests__/delete_announcement.test.jsx @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { API } from '../api_client.jsx'; + +// Taking an announcement down is asked for its OUTCOME: the banner is gone. A +// 404 says it already is (it expired, another console withdrew it, or the +// identical call that replaced it was withdrawn first), so it is not a failure +// for either caller: the court console's withdrawal of its own call, and the +// organiser's Dismiss, which refreshes the list after it. +describe('deleteAnnouncement', () => { + const realFetch = global.fetch; + afterEach(() => { global.fetch = realFetch; }); + + it('resolves when the announcement is already gone (404)', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: 'Announcement not found' }) }); + await expect(API.deleteAnnouncement('gone', 'pw')).resolves.toBeUndefined(); + }); + + it('still throws on any other failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'Invalid password' }) }); + await expect(API.deleteAnnouncement('x', 'bad')).rejects.toThrow('Invalid password'); + }); +}); diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index d5f4a865a..d3674615f 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -4494,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})`); From a3f28c6cb4492d3db353da2f1a034dd2ca963463 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 13:21:03 +0100 Subject: [PATCH 33/38] build(deps): pin excelize to the master commit that fixes its 15 advisories CI's validate job fails its Anchore image scan on main and on this PR ("vulnerabilities with level 'medium' or higher"). The only matches are fifteen GHSA advisories against github.com/xuri/excelize/v2 v2.11.0, each fixed only on excelize's master: no release after v2.11.0 exists yet. The repo owner chose to pin that master commit here (v2.11.1-0.20261010030226-066c84b8f7de, which brings github.com/xuri/efp v0.0.2 with it); a grype scan of the rebuilt binary finds nothing. The example workbooks are regenerated (make examples): the new excelize writes each font's properties in a different order and a cell's number to 15 significant digits (Excel's own precision). Every cell value, formula, style, page break and sheet is otherwise identical in all 17, checked by comparing their XML with the numbers normalised. Co-Authored-By: Claude Opus 5.5 --- go.mod | 4 ++-- go.sum | 8 ++++---- knockout-example-large-seeded.xlsx | Bin 37709 -> 37707 bytes knockout-example-large.xlsx | Bin 37744 -> 37742 bytes knockout-example-medium-seeded.xlsx | Bin 17956 -> 17954 bytes knockout-example-medium.xlsx | Bin 17954 -> 17952 bytes knockout-example-small.xlsx | Bin 17645 -> 17644 bytes pools-example-large-max-size.xlsx | Bin 66841 -> 66839 bytes pools-example-large-seeded.xlsx | Bin 66727 -> 66725 bytes pools-example-large-teams-max-size.xlsx | Bin 159465 -> 159464 bytes pools-example-large-teams.xlsx | Bin 159734 -> 159732 bytes ...s-example-medium-seeded-one-qualifier.xlsx | Bin 26681 -> 26678 bytes pools-example-medium-seeded.xlsx | Bin 28764 -> 28763 bytes pools-example-medium.xlsx | Bin 28763 -> 28762 bytes pools-example-single-pool-8-round-robin.xlsx | Bin 21863 -> 21860 bytes pools-example-single-pool-8.xlsx | Bin 17203 -> 17200 bytes pools-example-small-3-teams.xlsx | Bin 17053 -> 17050 bytes pools-example-small.xlsx | Bin 32729 -> 32728 bytes pools-example-teams-of-3-round-robin.xlsx | Bin 20960 -> 20958 bytes 19 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 688cf54ee..01e85f693 100644 --- a/go.mod +++ b/go.mod @@ -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,7 +51,7 @@ 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 diff --git a/go.sum b/go.sum index 9e57fc6c7..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= diff --git a/knockout-example-large-seeded.xlsx b/knockout-example-large-seeded.xlsx index 2c0a130156d04bd06d641588c40274464b9b94d7..184563bb30d2b9f266c28ea35c14c4d8758fc443 100644 GIT binary patch delta 2879 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 I@_%mq8zSzAqW}N^ diff --git a/knockout-example-large.xlsx b/knockout-example-large.xlsx index 32465aa9bf08e067461b4da74f6bf97606e05da1..3e18b3c2a72231286563e665473baf2b344c5cbd 100644 GIT binary patch delta 2907 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 C=#g&# delta 2882 zcmV-I3%&I2rULM$0eJui?-r9_F>rr9^u6Xr{~pszIl9(r3;*Z& zYd!GmX-&*JsvjO-m%g{pPwiiA|6W#k_TTZTI;=@myr)FVcAskkM9YqK%0#=ggA|sW z)syb?)BRZ1#nL_&%vpg}7ibNE*3xli#oAr0J;b`R0^Kgq83Nf7=G~Ncu^h{^zV&s9 z!dT8l@;Ms!DK}=Ramkjo+@lvkxUhitPoR$+bfJBzx2hUZSlalTRhrKN71s4$JBz| zi3{7Y`W5)_&82BllQZcj&lO$u%>q^h@x5pNVMBlQE!6|6hxE}io=3|WtMi-8a_RV| z)ICuGR6oj6i!L`yG89n#7hU{CdoNP4*{ou-+eO>n84K==g-p)v%VJ;kCfux}^h%u2?YPP#={fUpo@5yE1GB?uGF?Uiw7 zZ9?v(Epz!n-%b^3PrWKe#=>co`a(|?${QKGUv4|@m)njzciZ8!)}D_#+DdJO<3Y7X z!&vVv1{e%67-2BRV1hvbaA&R9_3RiyyLZF5E3lQ) zTjD`A$|XUmP^tWsSl2smn#;)%*xO+ z8&wQ8Y6YVLZ0uMnZuE!dyQ~R}z`?92T`>j+wSrLr4t9S+7GNy|*f;#RgC7_3vr_%I zs2_|9aIq7+0LR#Xb;A!I{P62TH~6tkWmAd=mE;GbqNCL5K7p7!ycF^yy&7DsFc-@9 z7sCp5fl<*>>PTmO0em6ARjShd1El@<&eGx~4bs!8)b{!Nx$a@0tI&GftF~S2rmLjdE_qkyDvyxeTgkgcG6A?%POt&@}C z*H!TvGo(I<6!Vvoq{k!X%~oCE=i1k6>-Ke z*98UV8uJ7vcc~O9mYQHyK}Bz9oGC5X5hUtDOeFe~@YOLt;~w_$8cAIVh!fct@oOQg zCJELlK6~C6Go;CaP@=aLD_OA~sDz-EM680)KEa_Pe!F6a+){3a$tEYqFz)&AaO_8x} zAGMZbk?E$4F-7>x88YE7uJ1u_XD-BW)J;&ih?!)Zx_=L@e^7ER2SM*>(LudPZKqPw z!!76?Ebm<*2aDFD`?w=0?;f6Nv4Gx*LXpD}in}>@R2zybWr^yT+^z9{*ssb=iLUNh z0p9drElXCcD`IEJ_LSss+zqlH7U;PU?v5Gs`^FeT7q()Y!*OqtmDNIZT(l3|8@Up1 zh`DQTP=9_;*0^T*4eY)rY*NGS8(7CoH~||qu)Z15z{U-1G8op}Kgj7|*tCH?42C^4 zu*bo$#|8$y!8GW#aBw&dws3u?L6aY_H4otMi2z$Oq3fb}%GuUj=)F9)aLsIheP_ey zaIu{XSdKiuT)A01`@G^pq6NjF6T=aRP2~j$j(>o?DFC^7**054L84M%%NMW}2;^`K z?8%u#||`XHD0AeSSM%Q4911mtoGa`^yq`3Q0eJ#&EOU@nE`TEmhSW?mEXl0IMp zupF=r5MWdJ&N@B@FceoYutm6CtXRiK0mG;tNA?h#(8(m9mBGKO=ahK_nlTj5Hq8T} zIDd6*A+mg{EKbr3vYcfk37!9X<=>s>dC(lZ=1~3p&xrz_u{qNU#3JK5bo{yr6mOrC z(1DYY1BwTYPa?sg^Y6dk%}1X1$9l%tOsW5Xk6+xUFHRJ=h0#&Qc#pCEqp)t9mG&wM zmV!5`AashBDBy@;ut79*J|RjLl2~)ZaDNmm5hHY4d}#b|i-stO1r-wLHD)+;49c}F zQJ}sTS6jZTzDI#;`W@2Oq9mHOMb))jEkb8Lw?X%g_3vM;|2OaOn8QapoKzlWMS~)I zMX_njZWQ%XUf)E-*GhJkm;cc7t|p-4?1I`6gpgRJ3U$#7??FWW4e-P3;l6VYb7X2jK0;U zcF6x}{~2ca!By!+65ozqjn--Hwi6Wv3n3FM>(QDzRZ|pDyfnqKc#~wcHvcQ%wMe7D zwa10fS}>D1<%xOF7aW;RTBjAYO@9aW%csy`I3XEx&SidE%B7}_?n^COPF`x-3eB~g zzar|&KJ^Xl0qBOY)@#DgqmHCmH^aju>t=T(#kv_DBv^T(q9DeUel;S#E$i8BN_xI* z-7kCQZkgAogwpb~z!d+90ykfpPFuu+-$MJ-UTlyrcRG1Z9Xw9}C{5{hE`Q9eyp|a* z8$MF9;tbcVQGluzuojYh7hId#7h|rmbj&yAn#ApvKP5_^aozyjns7$7e-qV%6_!t0 z7i#=HV+$qQ~|@xy!Tq_}@8)o-n7iKyBUb%B7b9$<^B>d%B%t__xAYUH;LcX-7zTfs`ayFmLUdEN3 zyI?B9)?B>*sf0JWM?aJ9*J{N1MCq{b6#hI(p>b?}h1N%XT3drPNq-W|tA!R6KRRvJ zUAMXPEf@UrPTt5NI2j+E@TbSI`{Uq%%JPHd;Xs*wpZW66adyjnp4Nua&URAW3e1-p zq4T@hAE2#dS!9H2!s}h0+MvCoq@p0no6RJ<8)q7#K${;3>~`xpAi+zNQT;?VLfa2R zr#b)4+>XFWebsY~2qK;4{EOXJwBNh5`Y>N;=q<%x*BI;Q{{sL}O9u$h_snO0pydQ zk46GUOOv6G7L)#uF9O0)lcA3mlSPm(0vlG7p^p}mjF2w^lv|Ubj~0{DkS_x6V3VPb z7Lyv0E&{uX~Xq9x+|{7lcvN0Jw(?6M`W)`^l_4%yS` z^0FoW{2l$t$xrtW=bGiO#W6`r_hqrp z@)xam%ai?69h9Cms_T}P?oSuuI!~uGxl}h)^5l6-c0gQ5>!9m@)HnL~lw6C!wNhF5 zKg(XLfnQHsqE}Jf@bJ2DokMnR{wjy}qRi6&PEX}=OUnE`AxgUc+!7#4da6<;%C#A! zu;#3q^pKtBV_6$ZyI9Z{1sZLj(FYo9%UTp`wy|a(YcC44+d!)iWNTPzW?REir+#B^1f&r6Tpru5+kFJ0MC?Ys)Pui0gc~x}$wm)3yq4 zssLHxPZd;F1XBf*72#B2$ckvHFl9wNRamk@Oci#g(DLls6^}N>18cT;w4RQlWgCu- z3pNuMG-K5(@ZsufU8g!{gP%N8cvIJpugr<-Ji8A&s(Ms^^{E=rM@M@eJj`f3yUq-o z4u10V@kl=FMYLHNk{O?>zv${O+WC;m%odfI-7l9nGv?2X1x(KDi!$HT!^|FbO!b)R zGc{o2GPCcMc_g3pA}}u@TUB;^rbg^>6*Iir#-eH)_521IEE^ADKEeWog$Rof79&i^ zOjy5EddVLRgG2Vct*1T9k}6ozz2^kFWq?A;Kbr#RwDD{U>8D z+IZ|qE9P?Z@urG#AH69C#)4_o=miSxFSi=|%dN)#a;veIZZ&+-v*&|eMn@TW!@+Tl z`mxSiz~z3UQ(OAUGlU#zI1f9ndRz_oxbyMvZ7}?QJDrbX8%)Som6?b?u43Mqky^!O zQH3|tRqi3oM_7Qc5MdF*VuT5qNy&h|C>zj`bTH^+Fu-7l!3cvf1_kIdS;3+M7l1TH zdq72?kHG+gAqFE1#uyX;GtY|Mj*1~P+cpdv0dVQI zJcMR{Z>8qYkTE)5MZHQggi!$ob^sK2j(%YFwJ|X(qed2iiCV#^024cliaY(G{tT-F zL$EL_quNDap;j;|z`~Bj0*sXa^Nt_8_hVyzM*Tkm8})-x0XB9_7hq{SFz)!_y&ryC zbVEOrPqj`Z`N61YDYe=!9%lD@t-Pc+y^9T3=3=x*N3cO%U{th}TGA0;0AC5PM^$P5 z-_d+)r%C>j_{sTPD2MF*Qgtvgpw0hQLSR3t*VT2jZe(~}xQ=$s^4H>+B&GX%08mQ@ z2(X|Z=Ia9h01gt9F+d!%?;Fw+f8)3n$KU%Y2%)bTiHa|YA!{+6t$_h{76B#&259$z zrf8c8F9Jzv?wf;QbQ+Ez;(dk*_O43;;Q2LQlPwG3mtjHC_>p^aq&|k#n|?e~KfcKS;Io zb*czTw6)^Q4})Ojb3}z#5+*3^0+EXa#pG9sGfsplDI_;UBqX^@l}w2;l;|2NdqZ)i zjpTcfs0%Ss=uaw^`~1W`?C}aIQwzX}>Wlcbl+}$+IbWRyHuQe-K@*b#! zqNPH-hOj=tVG_TP+>Ip5$lag(Q;e@8AK#H`gr{>S195O1!6B4A!HEd2ma$-<|4DzuQh6wZgnl{xb zjL^MMh0JfdNji3llZ_%K#qw!qap0(rq;`=h+CCbi=sYv+GR8F(FGt8!y!c@NgN<6i zaL`RqHHq0|9QzM`f4H;coDPD)-bDxXBDqebw1-NsidxHs?>SyOx0-lD>uf2?7{3R~EHPuQq|-M6rg zneYZ|(8Bs=L<<|Xu+iDD_WnVQ&xVa#*u&YdhZgpDHtey5!QgBf3>+Lh9|s*=-!f?P z13Gm8&o2b%R6^HD@hxYaTIfAJI=HqPVBcyO>`u0$Gp3^;tY&VGC!d!>Dl}(g>R~tl z*si<)!2u|4e+ocu|BAL^4F!o#L9u*+Vg&*@9D-uzw`caKK(0cNt9y{E0m#)5Sq{JW;6>?6>GBRpBR4}|iqe``yT)k|&hCcPx9Nk)^Y_uruU zxeI~-+C$JD>Yx94aU?Q6<39#aR7OTDxYIy^f=W6s;-B4!0c6%6~N#OGd29%+WO32*Nvw2(KmL`aK*-uJGY~#=3TR ze5>R>;6Dt48wqHJ7~ZPKLoV+RdHHjh`J{NUf3qrOteI7Zc2cE`#ZiikAxg|BW1u>> zn?O@fNKw?BbAhea!sv+UQlVEE4trW_$!KErEkC(2`DgKKnbjK?rI&N~_2~6@zOB9J zM90xws)VR!w8mcD6i19K>|$BID7swP|EgCb(>U^r<6IgexlOj}#6IXtfovzvX-#$M zf1vpAY3gy3(2RTaDto9HT`8xUwz^NXbeufZ)CtX{oWCII!ans4?GEUQu{J9zj-&RZ zSvSMoBh?#=cq+-xf`E+k4D>;oL6=_HJ1;rg!1TVeI2b)v@KGd?G3Rys1pPXE+OE;K^a~`(>XyI-6|-%Re6HS@lT=h&;kOFX81pfe{0`O z_@Q+<+!p?}&U5nc&@y(vEn#ORI3srUChQV%8f=TaUYrg|7~W0->?+}sLsi`No|okh z?^&LDrKos$mNQy=dX_Jbe<5F-(hpr?qyuqxf4neI<DgAj;(hzLFh1N%X+E{}Xe@zl1>WLPVUpj5)UBA8bmJ|MQC$HoX91Zst{M+N$ z{c-S&mE{M^!!u=ued^_%xvK)|9=2bO9u#%Dh%Eb2LJ#>F_SSs9JBT|)C&o)pdaSz0{{RH zlhZ<10@53k@f#bHM?)$C3?Gy68y1s^LoWhHB9rkO7L(9JE&&mf|3f?ho0B|5CL56| z4Bik2002ZW000dD000000000000000kCS~wIsw{~xI{q#c9Z%z>%06$o(ga7~l delta 2974 zcmV;P3t{x4i~*#K0iYOvHb}G`;zf%3^tbrC1!@uR=VyM@KC-ep6sI+TrAf5xv@4zt zr_zOAce zQN9?}TbXU2+MxVsP}6jbe0w+%-+Mab*{QjqR%Xv@vH{|I#ufU1UVEc|581gIU2Bbn z|BK?a8Tj?ICT0_L9Ufm-zPBrm-Cu3@Ue!hZ-{Gm=uSs3LXGF`lpKAg{%MVS;L_2qb z6qcMdlkSS+{aDt=(rYZ3vjVL?&>8}*rQ^(swfk6mh;?TLx_zKC1hOT}yD9HuIhJX2 z>&p^F+j{GJWpUPj%C@HadQEH~eF;T&cxgyGv+wO1(FTZCwc2`3D&l(|zUdhs&9uED zm?}V4gi{5T7130|WJNqxSh6CSDr{MiP8E)<5L1PFRTyP)z7~&m#RKPV@n}08MawoG zQww${F6zdbSKz}pm!?Tg&ZM6_SA5a83s{%L_n!TS4b=mGs)tmM=%Z&mkCro0=Qo+< z((zBJd#VJeew3vaeQuUyD5Ux?y7-G;y-3Aovx?1b7cICm7Ty_)n4H^}#lGmrxjk%{ z9xy#*dc@RXv+ovL3HYkmn4g#}8cQM56ZW`B8D4X1R?V$;ev6Fm86Ye~ScI?`VF|)g zgbA5R50E{7D;Zmwpi2f}A;Kbr#Ry9fmLg2px07*ZCF4vd4G4xhF5e6*ucC8JqC&V=%#Bia`NzXRX-f>=;AWyJ5U509(ngTQaCdxuhr+DwUrS>vHE! zdpS9OqsP!SSZa?AS;|Q(8B~%Xj0&)@v!J+f3Iuzojg47RcC!R*)Cxug*x0dD+}I!5 z@3J;91_!gE)Gh%BwSrLr4t7EoU@ZjLH~hGR9~bkZG=T(M)DK1lxY&tZfMaaHy5WZp ze)#2~8~ah4=%AAPU{v&!I{haQbBBg2KhmpO!Nm%5p)~3QR;UY%ik?zOI_nGI3jwZD zmF^!P-N$#Hl`mPC9gmf^E8b7d4n{__`)5lG>__vu`krwe8DCevXPk@jwc2M{?f)JC zP)i30y;cz=?gIb-9}<%xJ{+^`8`2Yh+c*}--}h4x!oH>w6<-oV(xBT}0|lmo0L=mg zcISbXXqyNx0!ew~F7~@GNL_u2ElYONP72E^t@Gpm93JvWw1Z!AM!^P4MFbBW_`!1k z=CMc!Uxm)Up67QD&M(p50;RVUe_dm&0mvEUDsQi)Nbj1{AOxE;`jhb0F+bxT_VF4?T?vR2*%$F^A*&_{)+s)F-WW5a$%0U#w-qZ{ zu^y;|pp`_dg3vy}p(1|4$tT2rH>e)>l=LB`B-WY46(sK!=vJEVg}W2&Sb|GTktTwx zHA!n1lTMTF7_La|Z;V!`P9t}W#E@QBkh}||6`W&pL(xtUI~Ko@g+y|D2w|Ouo+0^? zrFT2IY+1EG93OBPiKS3tsXxSml@W1q9^)gvFeSR#n@(L1mcx>f3V`x|3+z#>$p(L` zY~wVQ7g)z)gXI%i;U_7Y42u(zUIMXcRV~e*c`;-}@F&D^oolha0LidM3DU@o1=pDC z=WSZgPOiD?g|afgsXFQCB~I3gsNk!ogW|wYA4yG-v27oX_WE@qgH_%1nu_?pXoe^k6MZR;(*xXUO)H# zvL6=cxe)G-8T9+c7(y4eVw}TqZ<3YOLUmlU58NBM5^spPYj040eoxl8X88^5z9(!_ z!|oec$4oc@8#S=L8PUMT4Qw(P*4#hH>0sEjfjta{Jv6Y#!LY{$2ED;F=(TWgI1aXO zeWyW_AFwqK;P8n6TQi~SqIk;L)?DblJhpJnY=C`d!{~6aoeWrxJilDISv>o^;zFVY z#i0|!5r|FY1qhCRfW0XIxp~<(TSGyjQeev$uoVd8a189qZ_dn7f?WC_m-ir-Baq86 z$mIm&atdVatS?ifaYK>h2~nrk``uO6Z4WjU;?lluniDkQ~Az1J_axpS23_f zxLvGR$43Ffs2@l65S!4+B%hVRzpLkzc?6m<6wfxz1EDy7b!{QCe5))@(hIVjWh4om z|9a)$o#%Pb9K7aG{r%600-mus(+b2Q<2rQwx(O6-pOesmlaT|82aQi6!J+f-zu(PA zp7+Ok#@I}$|A3EQ+@~*26u5=aQO0x0Xik2wgh+(inG;}^8 zN*0n>bHs3e6f6-VbX$CA{BVngD2N3W66iH%ICKokwJlMgz86zzM>?ET9%e;@B78-$Y0PdE^;2HoM8w%%Z{p_J zvGyO{xBuz&lDn7x(DSY)pyTX<*X!|=%f+A5A?2)p46~{dO6yg1Y!+2QX%wVLYb^Pk zPzuU(vk4SBTyQ%*S{E3YH9BLul4uM4aZhU{C`pXI)u(pI|7rgjX8FNY=|vLXj$Vz{ zY3;TX6$J|+6D;e|nmSce6i~c0#j<#lWVJT`E8n$9qrkPth0t0slQ`vxdC(UenNC`# z6}3%&2lmUS&|x?s8FS8Mep||=rj71PEn7}rYT63TwVb~q>dHR#4ebHwhOyRb!q20Q zq**t^!zAlwcO=ES86G59d7`2q#*}_FBEBu_*=K`_o=*kS}*Sc}*QWPXH)Q>2@xE%&okZ87><>QnKO<*R4^2sur*ol6)6j zo7xv+uCa8?H|Cne?Up|!N}qAw0Nk2zMzwzv)q@q5Pg)mh{5@j}Ea&#&YDeouA^-8i zd+VgQe=F5*t!as<+7WeufYCJsTsbJ?YiJr0l*wBK<#1MBpmO?CsC=z}z_S_t4}0x@ zy9s}-F8lk!PwO0#hp&~f`+W%qm0&>Z%uU!e;xyV9dAm5hCSm+`5@1&e*BmN)+dC}F zAKr6%s??_9{)nVw5Gn__GNN5pUhsym7Tj_D#F%Wy#J|$H@ZhZlkV4Q z#Q8+&u<#WAJV~K(Y<`8-M}1mbgEdKi63nZG78E}^ZPs14x%4d;{PRxU$RRixAD!^0 z$Fck4;DE~VgXQ5snSP)7^3HK~%YB~KhSJVp397OO#RlL^eX(4@0Lp|IOTvz)5}8bBzcmo#y*)Uj08mQ@2+;S>7Y_#j07Nm9AwC?l^fuHB3B6VkCGG1_sF#rG!0000000000000000Fsk?L^=W7leR=b0eF-6L`MPHlR!m20VR`vMLhu- Ulf6Ya0W*{KMJ5L5K>z>%0LlE8&j0`b diff --git a/knockout-example-medium.xlsx b/knockout-example-medium.xlsx index 1cec46c1026091b4ef19c3c83cd373ab6429056b..87d097332f53c042c9615d8aa32940a6fc0ca762 100644 GIT binary patch delta 2972 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 SMK}R3llDa>2IN5i0001-7O4#Y delta 2989 zcmV;e3sUr;i~*vI0kHiSe>O<89pXib`t-N>y9H_y@8@TJ)IPGZIuxfhfu%{b?6fPM z4yTtj`RDKCPfmXNe|YDje699bR%@U6@l>tJzOLVX7)G_*XZf~T7H`?XS{@>xL-mghrzGpy;- zbL-0zMcaDodu4Iff6BI|`+7}mAbkl%c6ez>JG1ZY8qo%bR<+uCO)BDhAHL}sAI-GA zBA6;bR)kXpl@-xc!DK}|RamkjnJR2qkxmtktPoR$dsP@^alRIhcEtndZt-Y49YxDF z9#ac;Coby7npfb%H4hYi&Of2xO6kLaUkJdc($QRg?A z<w`aWO~HZVzciSTM788*qEP~EgDN9(-Zc%NEu#pYgWyzc7BVD?inB~L|BBd7-0#* zQiKVaNe_@ce=8YVnxIPtVIjgIgvAI;5SAiL*te5$W+mfHCk+r5A}m5!jIab@DZ+$v zdu7~NO~{>W%UpiYgQ-H@Q!k2eCq zB<&ZgS!G^LAN2rXA;Kbr#Ry9fmLg2ZOiBjKS=oSzq=Uf_gAoQ}3?>*%F(|;ilNHQ5 zfdNQU^e0vXh8T=67-KNOV2VKjaA&R9xf1}6HHCSqo4Oz-bD;ZRhA&d&Ju(P1JaS8-`sEv(TQFgNgY}5)y1=!fJRNUAf z+V8S9Fa`&+qSP({2epDx0SAo9LjD{9sh{lsf$<5OaryD?ie!S;55$bD=cq1XidEjEbI8M>^{Z;0pn+ zQkCu>Al=7zo|P|Im>rLmwkzIG%??IJwEJgE4D3hqy851R9T{I&zGs|^^0nG$S?&KG z08mQ@2)$MjCGGw6<-oV(xBT}0|lmo0L=mg zcISbXXqyNx0!ew~F7~@GNL_u2ElYONP72E^t@Gpm93JvWw1Z!AM!^P4MFbBW_`!1k z=CMc!Uxm)Up67QD&M(p50;RVUe_dm&0mvEUDsQi)Nbj1{AOxE;`jhb0F+bxT_VF4?T?vR2*%$F^A*&_{)+s)F-WW5a$%0U#w-qZ{ zu^y;|pp`_dg3vy}p(1|4$tT2rH>e)>l=LB`B-WY46(sK!=vJEVg}W2&Sb|GTktTwx zHA!n1lTMTF7_La|Z;V!`P9t}W#E@QBkh}||6`W&pL(xtUI~Ko@g+y|D2w|Ouo+0^? zrFT2IY+1EG93OBPiKS3tsXxSml@W1q9^)gvFeSR#n@(L1mcx>f3V`x|3+z#>$p(L` zY~wVQ7g)z)gXI%i;U_7Y42u(zUIMXcRV~e*c`;-}@F&D^oolha0LidM3DU@o1=pDC z=WSZgPOiD?g|afgsXFQCB~I3gsNk!ogW|wYA4yG-v27oX_WE@qgH_%1nu_?pXoe^k6MZR;(*xXUO)H# zvL6=cxe)G-8T9+c7(y4eVw}TqZ<3YOLUmlU58NBM5^spPYj040eoxl8X88^5z9(!_ z!|oec$4oc@8#S=L8PUMT4Qw(P*4#hH>0sEjfjta{Jv6Y#!LY{$2ED;F=(TWgI1aXO zeWyW_AFwqK;P8n6TQi~SqIk;L)?DblJhpJnY=C`d!{~6aoeWrxJilDISv>o^;zFVY z#i0|!5r|FY1qhCRfW0XIxp~<(TSGyjQeev$uoVd8a189qZ_dn7f?WC_m-ir-Baq86 z$mIm&atdVatS?ifaYK>h2~nrk``uO6Z4WjU;?lluniDkQ~Az1J_axpS23_f zxLvGR$43Ffs2@l65S!4+B%hVRzpLkzc?6m<6wfxz1EDy7b!{QCe5))@(hIVjWh4om z|9a)$o#%Pb9K7aG{r%600-mus(+b2Q<2rQwx(O6-pOesmlaT|82aQi6!J+f-zu(PA zp7+Ok#@I}$|A3EQ+@~*26u5=aQO0x0Xik2wgh+(inG;}^8 zN*0n>bHs3e6f6-VbX$CA{BVngD2N3W66iH%ICKokwJlMgz86zzM>?ET9%e;@B78-$Y0PdE^;2HoM8w%%Z{p_J zvGyO{xBuz&lDn7x(DSY)pyTX<*X!|=%f+A5A?2)p46~{dO6yg1Y!+2QX%wVLYb^Pk zPzuU(vk4SBTyQ%*S{E3YH9BLul4uM4aZhU{C`pXI)u(pI|7rgjX8FNY=|vLXj$Vz{ zY3;TX6$J|+6D;e|nmSce6i~c0#j<#lWVJT`E8n$9qrkPth0t0slQ`vxdC(UenNC`# z6}3%&2lmUS&|x?s8FS8Mep||=rj71PEn7}rYT63TwVb~q>dHR#4ebHwhOyRb!q20Q zq**t^!zAlwcO=ES86G59d7`2q#*}_FBEBu_*=K`_o=*kS}*Sc}*QWPXH)Q>2@xE%&okZ87><>QnKO<*R4^2sur*ol6)6j zo7xv+uCa8?H|Cne?Up|!N}qAw0Nk2zMzwzv)q@q5Pg)mh{5@j}Ea&#&YDeouA^-8i zd+VgQe=F5*t!as<+7WeufYCJsTsbJ?YiJr0l*wBK<#1MBpmO?CsC=z}z_S_t4}0x@ zy9s}-F8lk!PwO0#hp&~f`+W%qm0&>Z%uU!e;xyV9dAm5hCSm+`5@1&e*BmN)+dC}F zAKr6%s??_9{)nVw5Gn__GNN5pUhsym7Tj_D#F%Wy#J|$H@ZhZlkV4Q z#Q8+&u<#WAJV~K(Y<`8-M}1mbgEdKi63nZG78E}^ZPs14x%4d;{PRxU$RRixAD!^0 z$Fck4;DE~VgXQ5snSP)7^3HK~%YB~KhSJVp397OO#RlL^eX(4@0Lp|IOTvz)5}8bBzcmo#y*)Uj08mQ@2+;S>7Y_#j07Nm9A3hwj^ET593B6VkCGG~I=laM}10kEMVe+C3?LcB;(pZ*qqH$pAq{QOMM%14qHr|hyNu-1u^Tn^dO z>GHBA|NI^O$;nUm59gZYuf;J*O64;>Uy3a`mgU@B%`rswA@Pxoc9 z&hi(nc*~RhQyr9^HLB~DmhMj%;yO>KG`UnaRPy9`OLjn9N9&;Lf7Cbn_mo_V!L?FZ z_&>{DtASrnTcTG{-SF_baGgVTZvHBV_oB?w|4vWkaZAelJt0cE|J)KFN_whNCd#!L zq_F0!n)HyJ=VMtLOS@Rm7X=z^pwR~!Ys*>`YqqgwA8RiPwA(7^p|%&v2&L^~i#QESUBDTwQQxVoc#RMWN! zZ>j)U;ZGG*Rs>T8lNI4qVaSSTsxW0mJXKh-LQEBQr_l23+7*vB#RF@$c(k65qGcP7 zjg0y;aX~Xyy#gPuzSecBb2j+NGle&G{rJk9xX!ctu%oI+e^sBV0ey6|=fT5_#y5EddVLRgG2VcmZ+ z_M*mPPqtz%Z$6&=W#hRi2F8ME)aV5Y?Jrx+R55Nf_Lr^3UfOE-qG!(sd)Y=d;|<5g zt)?IA^Z}Q*8=czHPo5#<$bR#%y5EddVLRgG2Au}l%&=+L`I+6|seGCQ|3^5pCFvg$&eI_edblw7x zrfAQn2=p-+U@*jBguxht0$}D@vD-;8gr;l5a6d3d?{U1&%nyTLmTt#Egu?MrY)7&24} zy`pL3V$!MT_TkFZ{>E92>NIluNF13}4avJeoZ#%M4b3`1im~{O&J|LxyAa-K=s8j^ zS$emT%LS|PN01#3r&viX7v=+&yo!iVa!mI4!nN#XZ#(q^SPe@?YXHhmuzyF`&^7s1 z+u}4;Cs<>-Ch7?-$&-?8hT(*!r$CCds;B19q8u_R#S;>w$u(?FKyqSGf(-Jp6ov@% z{F*k^DU8s)P=(BIx=A{Aij$2ZCdKk;XK~=DkEC{yDcU|7qv$*{?J~wS6)#7~RJ{0M z0E3NMz;Mt_P&J9!WE}eset)>L1zFxZ*iyFzv*tw;ZHLr~s5 zJoRJ&gEtn691alf=HOm!2-nII-7&eH@qgG}mARH(-75qHDJfno&$IUIsw=C^0|s6ehlkgI!;s{zQ> z5aenEay16IdH}h41i6BNJwSV~mqL53WvK#Y|3>ytg@8-IbHLX?f_3dX@Aw$N5w2rU zEW++$#XCLRJ?qV<2k$1ncV7cY+d(r7PZxW!ogQChoZmA#Img%qtS2)(i;j`Vl1MyzV` z_4hc!l1T;3ihpo2^@`S_C5{B*q`deOVsx&kea|+=(E@SGUduPF%YNAqN51X1?rUCk zWiOaJU5A@ptL1#^&1OYNgG=^*^A5Kh&dPr^6-!2}%FNL;+6clsi3qPH;`%)tNUreV zea5nfau6v468FWvrQ1hjvn>jKxukj3G+Q zC}W^Hx0^syPe@VJoO6M#*23tB=~AIr7!G?{YsqM0^({ZSG5KflYnjy>7p0eT`1R=Z zc)qQ@=tRfST&jeqX0*m$-4sWREbL-gz9_m}+5f6nBhxtYi{o4xBe_ks>cl?iOMz@B z&S_0`>3^X3@M-FClF*EM^(uR)7+oo+nzp)6wRD_3)zk^irJTPY>cT$t4ebu-im^5; zDvqP}q**t^-6ZQ~wa@g^ z|HP4>FKnj{vE)1GaNLUx^2J6cht$F21c1_%y?@T6y_GjABUQskMwfz7?OK3ie+q;;ak-!ncZYF0d4ZD>6y zBB{(B?_9pBSaT;ukyk49RNf_Qv0_-Z`l0#M8 z_MVsJ5ARu?dZnm%d6qL;dwP~HkAERwoYD_nUq(ms$?j!b*tru*5jwSa|5FKXbq{_f z-G|nQg%92yAv6cpxDQlri%}*U}VkPQCX0H+wq*N6l5w4Jcyj zwdY@oeZ}HK5vLFPp^$lHjMA)29qj6R|0b-laVDFlbu2;0f&>wLOcQPlLbR21(7NY-Vl>8LlptylSV^20WFh( vLqP)MI+FoA9FyQfDgii?3q(Evgp*1{Jpp)=h(tI6l9RziCI&u100000>wBwv delta 2935 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 diff --git a/pools-example-large-seeded.xlsx b/pools-example-large-seeded.xlsx index e766459b88308b5737101b13cc994cf7c9198726..53834ca62bd00eb792aa8e3d6eaf287e8896388e 100644 GIT binary patch delta 3197 zcmV-@41)8gi3FvI1h5#ae_L;>IunNPzh8lTwMz5_#^&HG5tb58Ui*3tbL?KdCD(q0!x!<>1CfEPM4Q8`RmX4|D62t z|L0xv;39ij;^)Z!oPX`S`Yj_tch7i z^~2-q()ae0-K-pcr%beKJ4j*4Sv~1KKR=FTT`cWm!JHLnb%E9pXe}LQR;=B{+C!{6 zE70u%ogt7dVct!77t66s>s#L*QMjvizE|W|t!Qd`tk%Q^(v?u8rAV3QJah#8ZVWE0U?gkriU9aC?PO z1b0nrq*pds8(z|wo(SecCda4K74a&n$+ZM^pn-mP2D`esvy4i z?ElzMeM|L#>LGpdjOWqgjMdpqX1R3qlc!G<`9m+F6N6+Xp!#pR_=EO7q(ZYGL zs% zxL-8a*`;AFm=_k(+airn=*l>Nz^^mKbkAH82 z;os?eofxDx9PveMCgx9zg!g8oRCfWZ)h5e8!nCKwa|51tjf9Tp>Kw{7TN zqh`Td=H!&yt46sbC>1J|SBZ7I^QO6+9LghT_f|$FLrR~E2bE+9qXI1K{S4-5ns1=L~aImf=z*-2f@Az>CKQ87+ z*(qaiQ9l?J;9^}=fMaaHy5olre)w(Cjr}P5Q9P(5KNuAqWw$+mxx*Tim-K3Iu|iyc z@@Wvm3Uz@|(NQMvasv25fU8uc{f|fcwVkEKOB$r-bE)m~_e_fuHWzE&qr0Y#xCqu!cDYH4~@QrAi??aqTq3|Qq$WIIf%SNq*p&ehyQ zfTDV;1O$`w^Zy(lUm^G=Kv@I~nS>t%X3c7BQe7AXBj@wX+$ z8i1Tpu0m(2bvkif6)!PE>XS$@&l#P63(1gHpM+evDwP-|%2+Y#`kps(86w;%2@{ld zfr$B>#Q0YcXAE;)QgE&@PjGUVN|9oz305^!^oGWn(t_V4dQ#;f*mvn#>3#`mJUq3)Tab5VVkp)ezbzI8?;1IC(;Ue1)2J zPe~tAN@AT!Tto6+fwt0oBix;6yAoVtiZl^iElJwAm~`rNyKrS{e`mBtb!xd?B!={| zhU8r!R&aLp4MjUainjQT%p{U;+Yr{N=^2u*S$emY%LS|PhvO{{Be4`p%=M>Ouqq-h z&SSj87p6ovdsC_F!Kzs@QUg$beu6!UHCf^BwQZcH@&xNxtgw7W3;Zlan_+Q6(o-Ns zTGd1I7hX0Q5&Ri(+~iuUPe3xPQGzsbW5G4%`sFQcdMDRh^+FXgzpEzc=qXM%im2d= z=dH;~Bk3-nwF*V_#GePaxv3#}OEaM+tv z$g)r!2kkBQ2Cl>_Vpi>cEz0l78a6Dyh28grjT+c}3+tE(2VjF1);A+s*sz6-&W5$? z2l;R|?4gB?&xVa#*yGu-#})>?vuV(?aPWK_v~Yd9L7N}Y>Id-rjsUHm&~;Eef zdJm5luI&x5Z*LfE54NK-h9l3fMs5~QzAU(qXhw19#Bcy&GxGv}1P7p46oA}3in3x3 z1&K;QF@1qz1_C)8f@0)1SLRuQT>2oF_aK)8kjo*+*vmMz$tU@P~d{PDf zuIW=|3p8OUo~+t`4WT%6Zz-~RsVxrDOR}6~Bnh4Wdez^Z=XuaPdF@mE_dh2Jc*dqo zD-er}>(KF=B2d13NkRvX1`a4U8c!m@q4V#*-%SUe_s4R=*hH!SfX`pu=dVr_xTVof z#&CnN{G+tC%_@5p1#`h$Rq);^OQJyi04qd82SQ_~endfkEU1t`FEPWRQGvTyb=P?^WZzlpn4OxJZqKt7aY{ls5B-8M9156r@ONEcujB3aV>!>=im( z@M4s)ZZOQk=!oe;qBrOdds-_&Nn-RZp6ph5^31awoREw;^|EDO@3y6!YTD{P)zWhER8uQ7`<>TI!jGeNxZcfh+wpF8JKXMO zxYy|`#;_}_O6fz*aT@BvKJ`t{3wm^m-t|G`e-IfL%Bku?ML~=y{bu_9_jx6l-mXcF5ce@c|T;JgL64dIAt|0=4_R#=^4 zoT%~ljLoo|76%t=T2Bi3#}Dtdlj8opRKK@>ra7W&P1Fej2A2?U;h+pJq3N8UjNU6K z+r9Dxl@C9K%5kgGw^hQU8E!UcYX$!(jQ90|gPs&CgiGY$aoLAeo|BgFN}8^o1J-nW zZwvR22J@0RaJue&OSAh;-PddL$5z7NX9}H_tHYm3_r8_m@l@@QnWaC^QfM5TAI$ZC zQJ*%}U`dh$^LqXVhLzRn(GW|X}yPxA-#R0cEIJQox*UqP#QeeJN51rr5fg`n&WtI`D32%;#8w2}@T17#U zx2My^mpG;%3bgq(-+Hy40}?z(8P(5ZC3Nv&=(N|rnQ0o1n!8>aMCi2FUyCy#`{Fx) zs}A!E2K`3ywO9u#(o6BE?2LJ%^IFn)c9J6)q0}=_DMeNP-0{{RJm4N|Q z0vE88amyB!@BuFYzn35Z0Y(C5zLRmw7MFej0WSgr#FKH$7MH~V0WShl%ad`-8{0s$HUrI)_~0VW%h zo6BE?2LJ%^H~;_*0000000000000000GXBp0XhL$mpcOiK>}g;lYjUemzo0sDgoq| j#RCC80Wg;Y1OYt(B$q-20XP9ZmwN;OCI$%q00000aEKjV delta 3184 zcmV-$43G1ri3F#K1h5#ae@kzxIv0lL->*O(?Gk-~g}FFegslWH-e-I+#VFs3ba!Zi zva?~CrfX!o(}e{7;gqG9dO)p6pVwpqB=C(M27WWp->39ij-IvJ!oPX`S~vVYtch7i z_08kwGVu44-K-pcPnl@fw$XwmqjjhI{QNkUcCl6;3+Ajqy9>03Kzr%Bvtpet))`{G zS%F>`=njE&3G)VJT`a?9S`U3&qG(s`0>8+wTG8b6SgnZzq)VYlPcJoTdJg=3E!qIl z%0^qQNl60#BQSmAqwcm-Y^Ms472#B2$%<&Ipt2&KDr{MQ!KVsGRwPq}D=WlQ;q?lm z$gh3!tTuVzK1`m~bZ0ebqiWv9jcUfmZ7ZcQY8&fA@DZ3x)3i)RrJt;iZ~A5ltAYgn zbMRwh>03(=Ej_ZHeB*hvpj_?Wq_$^`e@f;PB|!C~EVk(6AQ=iR{kOIF!|J_A>87*N zO-ET%v;CldEqu@xQF(4(Ci|wBlTGz4)kCUBR82PhZnBktuab@ViRq%Y6jGhjr$s{X zoZGWH_gBW7bq#rw!pxL^13p!#MfIi_8H=V->VcjrloQFl zFE1YN%ZtaGd-1SYYtKd%%~{EQx8p`NM?+h$kCOv{sg%0k2psW?A2Oy4p+Vruzg z-@R+t_sU-<2dN&%Y*8EH>}ip(-jGx;R0Q14DVAeYrfYe3z&WgYggAoQ}3~~%67!&{x){5TV9%E?l zZs?9uv*m45tv-WlG?xUWLZ$L9v2P=9n#ajodJOFaOD)NeQtbGkk_=%~fQ_9`apx@% zoFO+3W<{y0930dNMg=%nsStPSL-S?U1jgWhVpfLo-KaRYs1=L~aIvZ-z+MP&?)dQr zKOW{ssnQ%g)DK1lcvuw`;2ImS@AzSZA9mYxV?WBJ#0Qn+2cx2+^mYqSZ`gzKmR=1m zR)`Bwz7jaBP!|{#9i{S3Cx9&kcuH28Pco$?<;e4t`I`1swYu^ zIGoSlIX)NL!TTj+6l}0eh~T~jKRFh_JQNY(E8qIp%lvL?{Sy4mOVpO)Z)=Pd02!k^ z@vXH|@yxc9aE%#CK8YCfjM0CkkPNBhlaMPri6ur+qNNzMUB{W&3=wV>2s0FS0TIh3 z3GuHYOc~~?K*5>9Ji^gkEJci^A~>l)MQ_kBO_X3qAW;{@NTNRpUmepk>>(enkyI6d zFp_;Cel2L#NP=~;&z?3~3@Nf8l&Ecm6|GngsEDAIM63d#eT+jz_=Fjw1Vn1a>tMuQtJvN z>jGjJ=U8kcv=c}^7Qc~&L~?rw!a5B-L-I9^?{<7S$Ex+=_yC8IP>Mt>)u&Lf(jqp_ zLVQFQmWXchx>MVM<*g1Fne%*f$zRhskzwMLKfaGIO(V_GQI|L<@?2D};X|5bDYc5FCMgQUEgj$=mW8 z3Zf(idHDi)1p*lygFN%=D}9t8lP<{QJ;>w;WO58LIRTk`0GXVEOg@54LPsBHpq*xS*%#crGUn$mm_-+8{bMLpOwbH zt7A$pfo6XUg|khwAQb2BEl8H9O5!Yffh=b!iG1t7PWifX90%%y(;TYT|E$2{DVsBu zfKa4d`IcKZf#UQf@+~+SS)f>GJc$VV*1!LLHy=69AL|)ovn2Ts`25v={%QrDT?ie; z81G@M|0twQu~I$>yrtj`D|l}eErG`o!(fA`Z#{n@N*0pj;)vnETOvm2w)j!&VHXX7 z7YZsQP;1PvZ$bS_-V%7pkK%5aHzq#<&({53)7PRz)NPBZYq?tZ)_k5D-8t63J6Qj3 z-r+HYk90VxG)#+zgzy!`x-q?x)z4{t6B4bx-bBsTv395L+yBFG&b_BUF}{-bwtw-m zKHh)$L&q6x06JzDI9!j%T>h{}cf8+Ye$sV>P+Hd!orH8l;KfKOEcu*J3d(D}?d4ls z@H`bkFqAhgCJ*5*tNu>F$o=hV@Ir^mmv-9IZC?%No#PUM#oCuC|FUDns zYNCVu-#p)9I3g*t+Cs^sa;a#;`%+0m$xB5I)0|dbtqE_3I-+_v!9&Hn$sJLM zZ)n4=V5Li+8;;AM2G*&sdk(14dqmd4CGVgMG|-+ru3VR{qM^r(_4X;EscM* zsH0b+yuKuqmX`%g@g?x=Y^fO=hy}g@eLw1j4YK7_rTXR|>n(UCefWT-70s|XmJOrm}j52vEqZ~%% z1uP$a3YOD`rE9W;tsbryXgdx6$c0Zu!C6i68Nv`g*iQRA%WK>c-iXu9eZVNk_a9`Zrys;iSInrH1gW=K5>CXP+QHBw+NRpEpoj ziodNfR>A)V08mQ@2(?5 z6_xD)FapB3lfeHKlkm6(mpTCfDgtS~lfeHKmwN#LFaid|lfeHKm%;%7FalM}lfeHK zmk9y^Fai?OlfeHKmrw!$F9D#JfC2$L0;u4V!Qd5_RRRGJ8?^e&B!mY50P#2g01W^D z00000000000001-m;M3)IssdkIRgPf0%Q1-efS)gm;(VS0p^#(0|7n(GM54b0X+dG Wmp}voH~~PHc?1C_1`7WG0000dXZq{_ diff --git a/pools-example-large-teams-max-size.xlsx b/pools-example-large-teams-max-size.xlsx index 84ef25ac66c36087c1bf9c560aff852a88b6a860..12945937617f8b7ca1f5d51aa65501e98f4c6378 100644 GIT binary patch delta 3213 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 delta 3200 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 delta 3166 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 delta 3101 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 YZ#@BUllN~p0g96_a3%&!YXATM0DR9BdH?_b delta 3156 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 diff --git a/pools-example-medium.xlsx b/pools-example-medium.xlsx index d70f8a550c4470de379ded73f76a91aed59b42ed..134bfc5a9e88da277b9e2737fd0d9c437d57403f 100644 GIT binary patch delta 3165 zcmV-j45IVf-~rm;0kC#Fe_L;>IunNPzh8lTwMz5_#^&HG5tb58Ui*3tbL?KdCD(q0!x!<>1CfEPM4Q8`RmX4|D62t z|L0xv;39ij_$SE!oPX`S`Yj_tch7i z^~2-)()afHx&5o{-^(h`{y810=tHCXY7Lf6*pwTz=GcJfbFE6;3{FZFmn7z-YZg-l-A7iqq!V7#RzvQOXMxO%-adalh;}?w7sBo!e{pf2_Udqgn=vo2@h`j|Ib6&q6NO z8e?|RZRDg?BQ2~yz0qahF_>dodJ9J|| zN{5XHmE;GbqND8A1DHE>H~C1f1{W*Lh1$hog}T6~=qQuBoB+NM;3`>ZfADDk+0N49 zB@NQ^xzzUg`=zd6WJKE^S0Z3P>(|xyjDBQ%U;3VL&5PIan5LEg-vCfc2M7kE=H>7M z1^@sL5&!^@8nci<fX?j#r*Getz&Vxw| zSmjG(J4~uq``uU0)!ajXqI#+X1e5dg{~RA*A^0U{6s)jRMDWmopF9U(9*czVMdp90s#!8p15kf{f<1~gS>f-sZJeg^1nXF= zuzW@f{47PAVR1szQy@iJ)kE_aUN#vK{26iFF?gufmk6aMP@9`x4wLJSAp z1XY8WNydK%ckKFGQ_kri=XTqBoz8rXdc>zD}# zV1pLcHzQiuu!W7zhPCSl`EWMup@oglhK*a;%OS|+2;}ks zu7ch+$mz))PrpqPa1$%=K{ z3K&MS9oayvLMM}aQU(96=~HG4G+`*7tlEDKp*VDJDYAO0Ee_I4vYccj37!9X)!&`x zdC)w0?Nj~tKPL)!#->aw5Q~iK(D9ohP`-RgLI;ip4k$MoPa?sg^Y6dkO$VO$$8y5h zM5+IP&tKi=uTB)WrO{5taD%b@qqMfoDti?LbHQ6x@ZKp)qCousD?~#FLSv_XL_vQn zsE|M}F~gx#lolmXz!Agc%}v(TANi;#MktpD9B+;BK6-zvfv6dRS9rD?Plgd2(QuO;I8HS9?)e|(#^t{r}O zujJnFpIC3n4dV}Y`(OUO$Kc|5I|YASadd(2RpY*tAFd0yNR5T7W*#AwHuH!XvrIu0 zq)2Nl`IJx!s%vxX6*^q-VwACNFwDZ}i0MM2H|P(0S}Q?GV)QMZ>{fX4%)en)A6%6F z&*J}D^m0zT9=-08ha*!|qM~3XWP)Y8rZB?FvM8WEn)rC1Z5geIXtjn5e(?RiVUg$8Kkc>I?vSnZIwxyhE+Uh>l(sJ@tQ!6z4o!3jk zkE3?D-pz2^@osiI-0o($*Xb+9uq&)e=|jzN8tTG6^-a$UdUT84^+Du+5E&QBsp>*S zL5wN=X8Qj3c{|cOQIOBAy{La@cA~twB$QT{C8m561#Uh!iY;Qvx6t>4UTl!h*DBek z4jv}}l&17;D$F)n%M4cqpD9^zhMUqTKy?XN3Q2wluFcXZ#$03Rm|vr465AzzN|e6f zyal)o;fQMgDyq*`Se;^=sPXrV&9Iyn2N!EvPYU_R5AU^;;{Lr71a9-YY2Ez48Q=4?l&FZ1^+0F_w|BI(C3Z0d!!=Fj_zLn$g zRPB(Nr9aP7XdIg#%=Ld!pElNDNs$(eoK>hgcvtb@&xRWt&M*%OBd2c=edy}|tJppf%_HQ@=iIXpICI(Au00000 D*rOOH delta 3150 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 diff --git a/pools-example-single-pool-8.xlsx b/pools-example-single-pool-8.xlsx index 1f10ee7db7f70f6058fb3625cbd75a4609755261..37e9dbbcf62e607089e64c078df814ae2d8f43d2 100644 GIT binary patch delta 3071 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 N2SX+XuRZ_(002lW>2Lr5 delta 3093 zcmV+w4C?c+h5@sN0kD-DO9pdrmIzA;V7&JAK3r!fw22x*1+?kU-{KI!W|C4aD)jgr z^LlJ%X#8KF*;)HYi}I9T)&!O&(bCI4Kb$TvYx38h{Qr#n3jXI`li(XNf3Qr`HL~65 zLIVGA%F;{yK&?oh*JJ}E@Qoe@e)FKePwBNB-D|alfAjpc9{7D&6SI!$hsXD2;P3Nu z`&Zk)msOtqb2?PVHK~gClxW%Rb4`F~*{NPK(XQ>F1xrTjN%#5raV+g(tv(jaS%G#J zXb*w*(sgIWI$f+Y#Co#=f4wfy9RleR<~@{ku?$;j{pi~kMZ0Pj_(guziYBMWYE2v< zT?$2ddZ|e>bKvi5(FTZCHri@UN)q@Vf$19`^|YO0J5_+J2&W25Rzy<;l@;++Vap0W zRXDODnJQdaA*KqiR~SWp?UQG<$piOc@~mb$3rouO!B{vRW3t7le;un|fsepknxF**CqMY^ram9#TD`YO?8fldS}Nm2AvU zOc%AKkm{U1EfR|7f83sxbGw<}CZlI=5f&mWLRgG2j<5t_LT1tda%L^YktS#tgRl@` z5yE1GafBrZ6VCm`xU&}HPAAqC&V~}Go!Jq(m zuvhf<=@>)Xf4ZT2m6{{(D&JCk5RI0Spj4<-J|y<-!JFoGQa55~+bgxkew1p-2a)6l zqXKN~?G$%D0l^tE;~-AdstgY51fv2RtWJnK#i9A1tO<<4MVu(rpM#4!!KeTit5^c; zg#hP{9dEGXA$F8j!@)!CU{rvI6;J`Lu>t#z9X8luf42iSvNQCxM#Kk^WCx?7qx4q) zsW;?WKFzDa#0oK?G%XG*)C5LFN2%Q61h9nwPsvI9!$z4^dsZ@GVqOSUc8paG_8XF1^`e?2MEBDUh?e&000*f002-+0|XQR z2mlBGlWigpvzH%k4u4u(kE1vie&1hVp?$3eQ%Ncn0i)iUjnvZgsHCoyTH2il2b{zz zUyv~w(yRUND|2k1_60%92FSZpM;6G%Q5zmbJRa(f8EIt@KT@->a`c6>R< zs`cUc0EdxKibO2cr%>%uz#ea0zlaX@+eefgTGg@VI0c~ zq(ia6@)@o0vlNYog%OD_0mvp7t}s_G+qjNS zu6WW5DyR89shy5qqGZj83ch+iC=Lwuq0|(ayzR46k}OiylrfeN{@Q{}_^a(Y(AkNF z5RSSLDu0uhcE$&LYP$y|=W-zE94$Ji7m}$|OnRsVorCn=733gkJ=%{ugtG4DsgebB z&J>Caj!@W5!K2(zSTT!|j?Qh2|8&19vqW@NFDKwU9OSZS#kxZ54A~x&434{j?3)ET zHiWxl2Hn0khR}wFjWamzjg-?epgJb)1NBC>#D5#2XYCD?+Y@VCW4R63eNV7S4R+sv zb@YTYV50`CuSYas;|6SUHCQu$kPlabJv3m`tHGuX*yGh;j|~`fu11570SB*#g9cpR zFlhV(8gT%x4+LmLLf52tPFW)sdZ$MNu89WNHyTEV$#!x@I&$1HbF*;vWyOU=3yOU! zgnuIt>dFfc9D#gN05bi_+wvL;q9g`+`2u+b0vQ~GJoD=-eUu=RF399P$m9rQatty# z0hxRNnVf=5K7vd_M<1X*=v$$_S7XT>On)Z&BVE7*U>RT=Ai$>5opoFWFcelc$cwO9 ztXRjTfX1koBYO}V-%2H)mBzoTV@fZ9W`7KYvrV%g6zA?ONS3Ec;w*ZBEN3Z+eCxkX z`MPr)2kL{<9IDsmRmQ0;`Al*EjSrjpjc=;i3t1FzyE$WA34q+ z>ltISB>4~c{MCN`Y6YHM2pz>3?_sR}D5OoXQa%a1rQi)KcyARgfyWWUV1uY{J%1re z7Lw%Rh~dCnB1Y)8_)+U&7Y%_I3MwQ}Ys|23LH$eK5_rjv;%=BXCO-nt*8N`7*P=w! zZHuaFxmx(ve4ZQKIo7{BSpRR{;W34ebU3LrOpAtu@D;_nF};!1&uM)V60N=7M9tQ* zcBk*#|HE+3y{A7hzLNL0fAO+D-hcQ*#~Ew@I%XF*T#v_G{;)@Pyx(Jf(shJTTGtVs zgmgpT#YibE`J7M+%4@yt}RMUl{K`BS3! z1!oO_T_d!xc5lM^YKG-5#)TYzPuT*?dA@P6!}TJQe|+=a+9~eeihuQ6b6O&r?2x*E zz-R~p0~=*L1gC3^GI=Yb97g2@EFXRfmeYo%YqEr`9*eqz-hRqXlRg9A&rYywj_nh0r|eWr2d53P*4 zg!QnTrXP^g<2!PK&U?=7lC>oK^+n7{?WtFV2K*~2b1JNcUw>wu9R?-h*7O7?ga-fs@i>!VA{?``HEIe8 zz>;3_?E?S+7n3GHR|0__li>yxldc~IlbS#(0%szV;RY3x+dwY?E|UvEJOb$_lL06d zlU_j(8?^e&B!mY50P#2g01W^D00000000000002plZin(0i2V>K|umHIFnE~9FrqL jDgmF9PC`Bb>ywQ_Jptd7%R)E-`I7`gCI+%T00000|3T78 diff --git a/pools-example-small-3-teams.xlsx b/pools-example-small-3-teams.xlsx index 52037ce8e6d97c8849463d5c28ff3312535dd4aa..3076ecea7e09cfd586ce6f477d17a4086ef9881e 100644 GIT binary patch delta 3112 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 YK|KLUlQlv(0b`SHLM8?rJ^%m!0Gf>BlK=n! diff --git a/pools-example-small.xlsx b/pools-example-small.xlsx index 0d04d5816f24c180d19761fe8139b072c51b3fbf..3982ebc3497ef2a42d4a0cab19dd61b933abf4d6 100644 GIT binary patch delta 3169 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 H00000Cm|Cw delta 3149 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 YQ9S{QlY3D(0i=_(Q6>gmO#lD@03C)000000 delta 3123 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 Date: Sat, 10 Oct 2026 20:17:31 +0100 Subject: [PATCH 34/38] fix: judge who a write seats on what it lands (PR #463 round 16) The round-15 change judged a participant's numbered bout row only when a pre-write probe of the payload said the merge would apply it. The engine rewrites a kachinuki write before its merge (it fills blank member ids and the winner member id, then names the rewritten bout in `changed`), so a row the probe skipped landed unjudged: a participant could store another team's member on a kachinuki bout (reproduced, 200 instead of 400). The member judges now run after the engine write, in the same transaction: the handler reads the match back (a transaction sees its own staged write), compares it with the snapshot read before the write, and judges only member ids that landed. A refusal aborts the transaction, so nothing of the write survives, and is answered as before. The probe is gone. The organiser's representative picks are judged the same way, and bulk-score now judges each entry's picks in that entry's own transaction. Also: - A knockout Winner slot whose pool standing has no id no longer repaints and loses its id on every pool write (as the sides already). - A team rename seats a side through seatBracketSide, so a representative bout's row names and winner follow (reachable when the auto-start after a representative bout is added fails). - ResolveQualifiedPools records a reseat history line when its repaint clears a representative pick (DELETE .../overrides reaches it). - The individual editor agrees a group only when its whole value then equals the server's, so a later edit is never silently unnamed. - The past-tense "Not started" notice appears only when the blocking start actually went out. - Comments and the spec say what is judged and what is reported; CLAUDE.md records the rules, the excelize pin and the court-call 404. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 17 +- internal/engine/bracket_result.go | 7 +- internal/engine/bracket_seat.go | 38 +- internal/engine/bracket_seat_test.go | 183 +++++++ internal/engine/kachinuki_test.go | 4 +- internal/engine/knockout.go | 35 +- internal/engine/knockout_test.go | 39 ++ internal/engine/match_merge_rep_picks_test.go | 144 ------ internal/engine/match_merge_test.go | 2 +- internal/engine/rep_pick_probe.go | 62 --- internal/engine/replace_participant.go | 71 ++- internal/engine/scoring.go | 6 +- internal/mobileapp/handlers_match.go | 465 +++++++++--------- .../handlers_match_kachinuki_test.go | 5 +- internal/mobileapp/handlers_match_test.go | 4 +- .../organiser_rep_pick_judge_test.go | 79 ++- .../mobileapp/self_run_bout_members_test.go | 114 ++++- internal/mobileapp/self_run_daihyosen_test.go | 14 +- internal/mobileapp/team_finish_gate.go | 7 +- internal/state/models.go | 5 +- specs/openapi.yaml | 70 ++- .../render/admin_shiaijo.render.test.jsx | 45 ++ ...hanged_groups_every_editor.render.test.jsx | 56 +++ web-mobile/js/admin_scoring_individual.jsx | 47 +- web-mobile/js/admin_shiaijo.jsx | 35 +- 25 files changed, 985 insertions(+), 569 deletions(-) delete mode 100644 internal/engine/rep_pick_probe.go diff --git a/CLAUDE.md b/CLAUDE.md index 3eaacdc79..cc503d81e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,7 +177,7 @@ tournament-data/ - **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 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 lands, and the refused match is never started on its own; if the blocking start fails instead, its own refusal 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. 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, 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 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 (a rename: `nameRepBoutRow` with `another` false; `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. @@ -287,16 +287,16 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **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`+`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); 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 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"`. 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, 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); 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`. +- **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). @@ -331,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 over the stored match the `/score` handler reads ONCE per write for every guard (`matchSnapshot`, whose `Pairing` holds the stored sides and ids; the pick judge and `applyCorrectionReason` read the same one), and each refusal is a `selfRunRefusal` carrying its own status, code and sentence. It also judges the numbered bout rows a participant's write introduces against the stored row at the same position, and only when the merge applies the row's `bout:` group (`numberedRowsRefusal`, asking the same `memberJudge.applied` once per write; a row the merge holds is kept in the history, never refused; the organiser's write is not judged there): a fighter's member id must be on the side's team (400 `team_member_not_in_team`, `errBoutFighterNotInTeam`) and a winner member id must be one of the row's two fighters (400 `winner_member_not_in_bout`, `errWinnerMemberNotInBout`). Every member-id check of one write (those rows, the representative picks, and the lineup PUT's `memberIDsOutsideTeam`) goes through one `memberJudge` (`rowMembersOutsideTeams`), which reads team-members.yaml at most once and only when an id is introduced; a file it cannot read refuses the write with 409 `team_members_unreadable` (`errTeamMembersUnreadable`), never a 500 the offline queue would retry forever. +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, carries a member id on a judged row (`carriesJudgedMember`). 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, except its representative picks: `judgeRepPicksUnderTx` (handlers_match.go) judges a pick the organiser's write introduces through the same `repRowRefusal` as a participant's, with the same 400 `team_member_not_in_team` and sentence. `repRowRefusal` judges a pick exactly when the merge would apply it, by asking the merge itself on copies (`engine.AppliedGroups`, through `memberJudge.applied`): a pick the merge holds (its group not named, stamped older than the stored pick, on a row the write neither has nor creates, or in a write held whole) is kept in the history, never refused (a pick names a SIDE, whose team a correction can change under an open editor), never asking the match's status. 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`. @@ -346,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. @@ -368,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. @@ -459,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/internal/engine/bracket_result.go b/internal/engine/bracket_result.go index ed99b4836..8106b414b 100644 --- a/internal/engine/bracket_result.go +++ b/internal/engine/bracket_result.go @@ -93,9 +93,10 @@ func bracketMatchAsResult(bm *state.BracketMatch) *state.MatchResult { } // BracketMatchAsResult is a bracket match as the pool-shaped record every -// write, merge and probe reads (bracketMatchAsResult), for callers outside the -// engine that ask the merge about a stored bracket match (AppliedGroups). The -// result shares bm's bout rows: a caller that changes it clones them first. +// 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) } diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go index c39e4ac53..45c8e38ad 100644 --- a/internal/engine/bracket_seat.go +++ b/internal/engine/bracket_seat.go @@ -18,13 +18,15 @@ import ( // (SubMatchResult.SideAMemberID/SideBMemberID, the state.GroupRepPickA and // state.GroupRepPickB groups), so a side given ANOTHER team takes its pick away. // -// The one exception is ReplaceParticipantInDraw, which writes side names -// directly (forEachBracketSideWithID) when a participant is replaced. That is -// safe: it runs only while the competition is draw-ready (checked again under -// the transaction lock; the participant edit is refused once it has started), -// and no representative bout can exist before the start (adding one moves its -// match to running, which starts a draw-ready competition), so there is no -// pick or row name for the direct write to leave behind. +// 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 @@ -37,7 +39,9 @@ import ( // 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 match with no row has nothing to write. +// 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 +// (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 @@ -109,7 +113,7 @@ func seatBracketSide(bm *state.BracketMatch, side domain.MatchSide, name, id str } // 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) + nameRepBoutRow(bm, row, side, name, another) if !another { return } @@ -143,19 +147,29 @@ func seatedAnotherTeam(storedName, storedID, name, id string) bool { // 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. -func nameRepBoutRow(bm *state.BracketMatch, row int, side domain.MatchSide, name string) { +// pick); a name the row already has writes nothing and copies nothing. 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. +func nameRepBoutRow(bm *state.BracketMatch, row int, side domain.MatchSide, name string, another bool) { field := func(r *state.SubMatchResult) *string { if side == domain.MatchSideA { return &r.SideA } return &r.SideB } - if *field(&bm.SubResults[row]) == name { + old := *field(&bm.SubResults[row]) + if old == name { return } subs := slices.Clone(bm.SubResults) *field(&subs[row]) = name + // 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, 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. + if !another && old != "" && subs[row].Winner == old { + subs[row].Winner = name + } bm.SubResults = subs } diff --git a/internal/engine/bracket_seat_test.go b/internal/engine/bracket_seat_test.go index 497e2d063..d73a1fde2 100644 --- a/internal/engine/bracket_seat_test.go +++ b/internal/engine/bracket_seat_test.go @@ -417,6 +417,132 @@ func TestResolveSlots_PaintedSideLosesItsPickAndALockedMatchKeepsIt(t *testing.T }) } +// 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") +} + +// 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 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) { @@ -471,3 +597,60 @@ func TestRequalify_ForcedReopenTakesThePickAndItsLineNamesIt(t *testing.T) { 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/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 b01d6aa59..0e2c818f4 100644 --- a/internal/engine/knockout.go +++ b/internal/engine/knockout.go @@ -340,15 +340,15 @@ func (e *Engine) resolveSlots(bracket *state.Bracket, resolver map[string]resolv if !ok { return } - // A side whose resolver entry has no id (a standings player of + // 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 "": it is already seated, and repainting it - // would count a change on every pool write that never converges. - // Only that direction: a slot with no id against an entry that - // has one is still painted, which is the id backfill above. The - // Winner is written directly, id included, so it keeps the exact - // compare and converges after one repaint. - if *name == rf.Name && (*id == rf.ID || (side != domain.MatchSideNone && rf.ID == "")) { + // 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)] != "" { @@ -507,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 } @@ -532,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 { @@ -546,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 9cd04ea28..a28145d05 100644 --- a/internal/engine/knockout_test.go +++ b/internal/engine/knockout_test.go @@ -929,3 +929,42 @@ func TestResolveSlots_ASideTheResolverKnowsByNameOnlyIsSeated(t *testing.T) { }) } } + +// 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/match_merge_rep_picks_test.go b/internal/engine/match_merge_rep_picks_test.go index e68a3a80e..6b42f4daa 100644 --- a/internal/engine/match_merge_rep_picks_test.go +++ b/internal/engine/match_merge_rep_picks_test.go @@ -9,8 +9,6 @@ package engine // other side's pick, and an older change of any is kept in the match's history. import ( - "fmt" - "slices" "testing" "github.com/stretchr/testify/assert" @@ -629,145 +627,3 @@ func TestMerge_RepPick_OnARowAHoldTookOut_IsHeldWithItsValue(t *testing.T) { assert.NotContains(t, got.GroupStamps, repPickAName, "the stored match never stamped the pick") }) } - -// The member judges (mobileapp: the participant's and the organiser's) judge a -// representative pick, and a participant's numbered bout row, for team -// membership only when the merge will APPLY its group, and they ask -// AppliedGroups, which runs the merge on copies, rather than re-deriving its -// rule. The table below pins the probe to the merge itself. - -const rpaStoredAt = int64(100_000) - -var rpaComp = &state.Competition{ - ID: "rpa", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed, Format: state.CompFormatKnockout, -} - -// rpaStored is a stored running team match; withRow adds the representative -// bout with side A's pick (carol) stamped at rpaStoredAt on all three groups. -func rpaStored(withRow bool) *state.MatchResult { - m := &state.MatchResult{ - ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, - Status: state.MatchStatusRunning, ModifiedAt: rpaStoredAt, - SubResults: []state.SubMatchResult{{Position: 1}}, - GroupStamps: map[string]int64{"bout:1": rpaStoredAt}, - } - if withRow { - m.SubResults = append(m.SubResults, repRow("carol", "", nil, nil)) - m.GroupStamps[repBoutGroup], m.GroupStamps[repPickAName], m.GroupStamps[repPickBName] = rpaStoredAt, rpaStoredAt, rpaStoredAt - } - return m -} - -// rpaWrite is a client's running score write that picks dave and erin. -func rpaWrite(at int64, changed []string) *state.MatchResult { - return &state.MatchResult{ - ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, - Status: state.MatchStatusRunning, ModifiedAt: at, Changed: changed, WriteDoor: DoorScore, - SubResults: []state.SubMatchResult{repRow("dave", "erin", nil, nil)}, - } -} - -// rpaCopy is a deep copy built without the probe's own cloning. -func rpaCopy(m *state.MatchResult) state.MatchResult { - c := *m - c.SubResults = state.CloneSubResults(m.SubResults) - c.GroupStamps = state.CloneGroupStamps(m.GroupStamps) - if m.Changed != nil { - c.Changed = append([]string{}, m.Changed...) - } - return c -} - -func TestAppliedGroups_AgreesWithTheMerge(t *testing.T) { - changeds := []struct { - name string - changed []string - }{ - {"naming nothing", nil}, - {"naming both picks and the bout", []string{repBoutGroup, repPickAName, repPickBName}}, - {"naming both picks", []string{repPickAName, repPickBName}}, - {"naming side A's pick", []string{repPickAName}}, - {"naming side B's pick", []string{repPickBName}}, - {"naming the bout alone", []string{repBoutGroup}}, - } - stamps := []struct { - name string - at int64 - }{ - {"older", rpaStoredAt - 100}, {"equal", rpaStoredAt}, {"newer", rpaStoredAt + 100}, {"unstamped", 0}, - } - for _, knockout := range []bool{false, true} { - for _, storedRow := range []bool{true, false} { - for _, ch := range changeds { - for _, st := range stamps { - name := fmt.Sprintf("knockout=%v/storedRow=%v/%s/%s", knockout, storedRow, ch.name, st.name) - t.Run(name, func(t *testing.T) { - stored, incoming := rpaStored(storedRow), rpaWrite(st.at, ch.changed) - storedBefore, incomingBefore := rpaCopy(stored), rpaCopy(incoming) - - got := AppliedGroups(stored, incoming, rpaComp, knockout) - gotA, gotB := slices.Contains(got, repPickAName), slices.Contains(got, repPickBName) - - assert.Equal(t, storedBefore, *stored, "the stored match is not changed by the probe") - assert.Equal(t, incomingBefore, *incoming, "the write is not changed by the probe") - - // The merge, run on its own copies with the branch's context. - s, w := rpaCopy(stored), rpaCopy(incoming) - rep := mergeMatchWrite(&s, &w, matchWriteForward, mergeCtx{comp: rpaComp, knockout: knockout, nilSubsClear: !knockout}) - assert.Equal(t, rep.Applied, got, "every group the merge applies, not only the picks") - assert.Equal(t, slices.Contains(rep.Applied, repPickAName), gotA, "side A") - assert.Equal(t, slices.Contains(rep.Applied, repPickBName), gotB, "side B") - - // And the answer is the plain reading of the rule, so the - // equality above cannot hold only because both are wrong: - // the write names the pick, it is not older than the stored - // pick's stamp, and it lands on a row (the match's own, or - // one the write creates by naming the bout: a pick named - // onto a row nothing creates is held). - applies := func(group string) bool { - named := ch.changed == nil || slices.Contains(ch.changed, group) - onARow := storedRow || ch.changed == nil || slices.Contains(ch.changed, repBoutGroup) - return named && onARow && (st.at == 0 || !storedRow || st.at >= rpaStoredAt) - } - assert.Equal(t, applies(repPickAName), gotA, "side A by the rule") - assert.Equal(t, applies(repPickBName), gotB, "side B by the rule") - }) - } - } - } - } -} - -// A finish stamped before the stored verdict that names another winner is held -// WHOLE (HoldReasonFinishAtomic): its picks land nowhere, whatever their own -// stamps say. A finish that only repeats the stored verdict is not a hold, so -// its picks are ordered by their own stamps. -func TestAppliedGroups_AStaleFinishLandsNoPick(t *testing.T) { - stored := func() *state.MatchResult { - m := rpaStored(true) - m.Status, m.Winner, m.WinnerID = state.MatchStatusCompleted, wrTeamA, wrTeamAID - m.GroupStamps[state.GroupResult] = rpaStoredAt - m.GroupStamps[repBoutGroup], m.GroupStamps[repPickAName], m.GroupStamps[repPickBName] = rpaStoredAt-40, rpaStoredAt-50, rpaStoredAt-50 - return m - } - finish := func(winner, winnerID string) *state.MatchResult { - w := rpaWrite(rpaStoredAt-20, nil) - w.Status, w.Winner, w.WinnerID = state.MatchStatusCompleted, winner, winnerID - return w - } - for _, knockout := range []bool{false, true} { - t.Run(fmt.Sprintf("knockout=%v", knockout), func(t *testing.T) { - picks := func(winner, winnerID string) (a, b bool) { - got := AppliedGroups(stored(), finish(winner, winnerID), rpaComp, knockout) - return slices.Contains(got, repPickAName), slices.Contains(got, repPickBName) - } - a, b := picks(wrTeamB, wrTeamBID) - assert.False(t, a, "another winner, older than the stored verdict: held whole, side A") - assert.False(t, b, "side B") - - a, b = picks(wrTeamA, wrTeamAID) - assert.True(t, a, "the stored verdict repeated is no hold: the pick is newer than its stored stamp, side A") - assert.True(t, b, "side B") - }) - } -} 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/rep_pick_probe.go b/internal/engine/rep_pick_probe.go deleted file mode 100644 index 231c3990c..000000000 --- a/internal/engine/rep_pick_probe.go +++ /dev/null @@ -1,62 +0,0 @@ -package engine - -import ( - "slices" - - "github.com/gitrgoliveira/bracket-creator/internal/state" -) - -// AppliedGroups says which of the groups incoming changes the merge would APPLY -// onto stored (an echo of the stored value counts as applied, as in -// MergeReport.Applied). The judges of a score write (mobileapp: the -// representative picks, and a participant's numbered bout rows) judge a group's -// content exactly when it lands, and ask this rather than re-deriving the -// merge's rule: whether the write names the group, whether its stamp is older -// than the stored group's, whether a hold takes the whole write (a stale finish -// naming another winner is held whole, HoldReasonFinishAtomic), and what -// becomes of a pick on a row that is gone or never was (settleOrphanPicks) all -// live in mergeMatchWrite, and run here. A group that is held, displaced or -// sits on no row is kept in the match's history by the real write and is not -// the judge's to refuse. -// -// It is the probe shape decisionHeldByMerge and engiFinishProbe have: the merge -// runs on COPIES of both matches (it rewrites the write it is given), so neither -// argument is changed by the call. knockout says which branch the write will -// land on (a bracket match, true, or a pool/league match, false): the two pass -// the merge different contexts (mergeCtx.knockout; mergeCtx.nilSubsClear, which -// is what an omitted bout list means on the pool branch). stored is the match -// as the store holds it (a pool row, or BracketMatchAsResult of a bracket -// match); comp is the competition (nil reads as an individual one). -// -// The write is folded onto the stored pairing first (reconcileSides, as both -// branches do before they merge), because the merge works the verdict out from -// the sides. A write that names other competitors is refused by the engine -// (ErrMatchSideMismatch) and lands nothing, so it applies no group. The bracket -// branch skips that fold for a match not playable yet (sidesBeforeMerge) and -// refuses the write itself; the probe folds unconditionally, which cannot -// disagree, since an empty stored side never mismatches. -func AppliedGroups(stored, incoming *state.MatchResult, comp *state.Competition, knockout bool) []string { - if stored == nil { - stored = &state.MatchResult{} // a match the store does not hold has no row and no stamps - } - prior, probe := cloneForProbe(stored), cloneForProbe(incoming) - if reconcileSides(&probe, storedSides{A: prior.SideA, B: prior.SideB, AID: prior.SideAID, BID: prior.SideBID}) { - return nil - } - return mergeMatchWrite(&prior, &probe, matchWriteForward, mergeCtx{comp: comp, knockout: knockout, nilSubsClear: !knockout}).Applied -} - -// cloneForProbe is a copy of m the merge can rewrite without reaching m: its -// bout rows, ippons, overtime, stamps and group names are cloned (nil stays -// nil, which the merge reads differently from empty), and no merge report rides -// along. -func cloneForProbe(m *state.MatchResult) state.MatchResult { - c := *m - c.IpponsA, c.IpponsB = cloneIppons(m.IpponsA), cloneIppons(m.IpponsB) - c.SubResults = state.CloneSubResults(m.SubResults) - c.GroupStamps = state.CloneGroupStamps(m.GroupStamps) - c.Changed = slices.Clone(m.Changed) - c.Encho = m.Encho.Clone() - c.Merge = nil - return c -} diff --git a/internal/engine/replace_participant.go b/internal/engine/replace_participant.go index ad86aaa60..a601c28af 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 } }) @@ -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, false) + } } }) if bracketNameAmbiguous && bracketFound { @@ -317,6 +332,29 @@ func matchesParticipant(rowID, pid string) bool { return rowID != "" && pid != "" && rowID == pid } +// 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 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 +} + // 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 @@ -328,17 +366,28 @@ func matchesParticipant(rowID, pid string) bool { // (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)) { + forEachBracketSlot(b, func(_ *state.BracketMatch, _ domain.MatchSide, name, id *string) { + fn(name, id) + }) +} + +// forEachBracketSlot is forEachBracketSideWithID 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). The rename passes that +// write a side through seatBracketSide, or keep the representative bout row in +// step with one, need 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/scoring.go b/internal/engine/scoring.go index 8d65df015..bda032785 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -3027,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 @@ -3503,7 +3503,7 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName 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) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 17bbac3af..75799e1da 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -729,7 +729,9 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // "downstream_knockout_running" when that pool entry would move a // qualifier out of a knockout match being fought now, or a knockout // entry would change a side of one (terminal: finish or requeue - // that match, then retry). + // that match, then retry); and "team_member_not_in_team" when a + // representative pick the entry lands is not on its side's team + // (the entry stores nothing; /score answers it with a 400). // // It matters because the single-match endpoints answer those // conditions with a distinct body ({"applied": false, "reason": @@ -879,10 +881,11 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // completed -> completed overwrite, otherwise carry the STORED // reason forward; ending a match reopened without a reason is // never refused). The rule itself lives in - // applyCorrectionReasonUnderTx, shared with the single-score path - // so the two cannot drift; only the error SHAPE differs here - // (partial-success entries carry a plain message). - check, snapErr := applyCorrectionReasonUnderTx(stx, id, results[i].ID, &results[i].MatchResult) + // applyCorrectionReason, shared with the single-score path (which + // calls it over its own snapshot) so the two cannot drift; only + // the error SHAPE differs here (partial-success entries carry a + // plain message). + check, snap, snapErr := applyCorrectionReasonUnderTx(stx, id, results[i].ID, &results[i].MatchResult) if snapErr != nil { return snapErr } @@ -892,6 +895,11 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi if refusal := teamFinishRefusalUnderTx(finishRefusal, check, &results[i].MatchResult); refusal != nil { return errors.New(refusal.Error()) } + // The entry's representative picks are judged on what the write + // landed, as /score's are (landedMembersRefusal), decided here, + // before the engine rewrites the entry. Its numbered rows are + // not: this is the organiser's tooling. + judgePicks := carriesJudgedMember(results[i].SubResults, false) status, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, results[i].ID, &results[i].MatchResult, engine.ForceOptions{ Force: results[i].ForceDownstreamReopen, Reopened: &reopenedThisItem, @@ -905,6 +913,14 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi if err != nil { return err } + if judgePicks { + // A refusal aborts this entry's transaction: it stores + // nothing, history line included, and the entries beside + // it are unaffected. + if err := landedMembersRefusal(stx, id, results[i].ID, snap, false); err != nil { + return err + } + } capturedStatus = status if check.ClearBracketReopenPending { // ClearBracketReopenPending is snap.InBracket && snap.ReopenPending, @@ -921,7 +937,14 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi var downstreamPlayedErr *engine.DownstreamKnockoutPlayedError var downstreamRunningErr *engine.DownstreamKnockoutRunningError var alreadyIneligErr *engine.AlreadyIneligibleError + var landedRefusal *landedWriteRefusal switch { + case errors.As(err, &landedRefusal): + // A pick the write landed that the side's team does not + // hold: the refusal's code rides in Reason and its + // sentence, the page's own, in Error. + bulkErr.Reason = landedRefusal.refusal.code + bulkErr.Error = landedRefusal.refusal.message case errors.Is(err, engine.ErrMatchSuperseded): bulkErr.Reason = "superseded" bulkErr.Error = SupersededMessage @@ -1386,7 +1409,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // no reason either (operator ruling 2026-09-25: a match can be reopened // without any reason, and nothing is gated on that). A reason-less reopen // sets state.MatchResult.ReopenPending, which only lets a reason sent with - // the next completion be kept (see applyCorrectionReasonUnderTx). + // the next completion be kept (see applyCorrectionReason). // // A completed match that is neither kachinuki nor decided by a withdrawal // gets 400 (its sanctioned edit of a finished result remains the @@ -1901,7 +1924,7 @@ func enforceSelfRunPolicy(c *gin.Context, tl TournamentLoader, verifier Password // selfRunRefusal is a refusal of a score write the handler judged under the // write's lock: an anonymous self-run write, or the organiser's representative -// pick (judgeRepPicksUnderTx, which answers the same refusal as a +// pick (landedMembersRefusal, which answers the same refusal as a // participant's). It carries the status of the response, its code (the body's // error) and the sentence the page shows as it is (the body's message). The // sentence is written where the refusal is decided, so the handler that sends @@ -2002,13 +2025,12 @@ var errRepBoutAdded = &selfRunRefusal{ // unscored check): a write listing the bouts without the row the match // has comes from a sheet a moment behind an add, so the stored row is // kept, scored or not, and a finish is refused (errRepBoutAdded). -// - A numbered bout row may seat only its own team's members and may name as -// its winner only one of its two fighters, for what the write introduces -// against the stored row at that position, and only when the merge applies -// the row's bout group (numberedRowsRefusal; errBoutFighterNotInTeam, -// errWinnerMemberNotInBout). The organiser's write -// is not judged there. A members file that cannot be read refuses a write -// that needs it, terminally (errTeamMembersUnreadable). +// - Who a write seats is not judged here. It is judged on what the write +// LANDS, after the engine has merged it and in the same transaction +// (landedMembersRefusal): the engine rewrites a write before its own merge +// (a kachinuki bout log is merged by position, member ids are filled in, +// the winner is worked out), so a reading of the payload can disagree with +// what is stored. // - Once the organiser recorded a judges' decision on the representative // bout, the write's row must send it back (sameHanteiVerdict) or leave the // row's ippons out (engine.KeepsStoredDaihyosenVerdict), a write listing @@ -2031,7 +2053,7 @@ var errRepBoutAdded = &selfRunRefusal{ // error rather than allowing the write (matchSnapshotOrErr). A match in neither // store (found false) passes on purpose: the write that follows under the same // lock refuses it (errMatchNotFound), so nothing can land on it. -func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, startOnly bool) error { +func holdSelfReportedWriteUnderTx(compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, startOnly bool) error { if !found { return nil } @@ -2046,13 +2068,6 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, sna if row >= 0 && state.DaihyosenSubIndex(subs[row+1:]) >= 0 { return errDuplicateRepBout } - // The fighters a numbered row introduces are the team's own, and its winner - // is one of its two fighters. Judged before the representative bout's - // branches below, which return early on the rows they drop or keep. - judge := &memberJudge{stx: stx, compID: compID} - if err := numberedRowsRefusal(judge, snap, result, subs); err != nil { - return err - } stored := snap.RepBout if row >= 0 && stored == nil { if result.Status == state.MatchStatusCompleted { @@ -2078,11 +2093,6 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, sna if row >= 0 && subs[row].HanteiDecided() { return repBoutHanteiRefusal(false) } - if row >= 0 { - // bc-dhrp: the picks name the side's own team's members (the lineup's rule). - // Not judged when a stored verdict stands: it replaces the row below. - return repRowRefusal(judge, snap, result, subs[row], stored) - } return nil } switch { @@ -2115,7 +2125,7 @@ func holdSelfReportedWriteUnderTx(stx state.StoreTx, compID, matchID string, sna } // errRepMemberNotInTeam refuses a representative pick, a participant's or the -// organiser's (judgeRepPicksUnderTx), that names a member the side's team does +// organiser's (landedMembersRefusal), that names a member the side's team does // not hold (400 team_member_not_in_team). The lineup refusal // (errLineupMemberNotInTeam) says the same thing about a fielded position. var errRepMemberNotInTeam = &selfRunRefusal{ @@ -2124,98 +2134,121 @@ var errRepMemberNotInTeam = &selfRunRefusal{ message: "The representative chosen is not on this team. Pick again from the list.", } -// repRowRefusal judges what a representative bout row would change, a -// participant's (holdSelfReportedWriteUnderTx) or the organiser's -// (judgeRepPicksUnderTx), as the merge will order it (bc-mrgc): each side's -// representative (state.GroupRepPickA, state.GroupRepPickB), side by side. A -// pick is judged exactly when the merge APPLIES it, and the merge is asked -// (memberJudge.applied: engine.AppliedGroups runs it on copies) rather than -// re-derived here: a side's change the write does not name, one whose stamp is -// strictly older than the stored change's (domain.ApplyByTimestamp: an equal -// stamp applies), one on a row the write neither has nor creates, and every -// pick of a write the merge -// holds whole (a stale finish naming another winner) is never written, so it is -// not judged. Such a pick is held and kept in the match's history, as any stale -// change is, and the write is answered applied with the held side's group in -// heldGroups (e.g. ["repPickA"]), or superseded when every group it changes is -// held. Judging it would refuse a stale queued pick the merge was going to hold -// anyway, and a side the write does not change is never judged against the team -// it has been given since (a write naming one side while echoing the other's id, -// which a re-seat has cleared). An unnamed write (an older client) changes every -// group its row carries. -// -// Nothing is read for a row that introduces no pick (rowMembersOutsideTeams' -// scope), not even the competition the merge is asked about. -// -// The bout's winner id is NOT judged: it belongs to the bout group and the -// merge keeps it consistent with the representatives that stand -// (SubMatchResult.ReconcileWinnerMemberID), so a winning point carrying the id -// of a pick another device has since replaced is applied and its winner id -// re-derived, rather than refused and lost. -func repRowRefusal(j *memberJudge, snap matchSnapshot, result *state.MatchResult, row state.SubMatchResult, stored *state.SubMatchResult) error { - introducesA := introducesMember(snap.Pairing.SideAID, row.SideAMemberID, stored.SideAMemberID) - introducesB := introducesMember(snap.Pairing.SideBID, row.SideBMemberID, stored.SideBMemberID) - if !introducesA && !introducesB { - return nil - } - applied, err := j.applied(snap, result) - if err != nil { - return err +// landedWriteRefusal carries the refusal of a write judged on what it LANDED +// out of the write's transaction, as an error. Returning it is what aborts the +// transaction: the engine's match write, its history line and its eligibility +// record are all staged in that transaction, so a refusal stored into the +// caller's engErr from inside it would commit them instead. The caller unwraps +// it once WithTransaction has returned and answers it like any other refusal. +type landedWriteRefusal struct{ refusal *selfRunRefusal } + +func (e *landedWriteRefusal) Error() string { return e.refusal.Error() } +func (e *landedWriteRefusal) Unwrap() error { return e.refusal } + +// carriesJudgedMember says whether a score write, as the judges before the +// engine leave it, names a member id on a row landedMembersRefusal judges: a +// representative pick on the representative bout's row (anyone's write), and +// a numbered row's fighters and winner (a participant's, numbered). The engine +// adds an id to a row only by copying one the stored row already holds at +// that position (preserveKachinukiMemberIDs) or by deriving the winner's from +// the stored picks (ReconcileWinnerMemberID), and neither introduces one: a +// write that carries none can introduce none, so the staged match is not read +// back to look. +func carriesJudgedMember(subs []state.SubMatchResult, numbered bool) bool { + for _, row := range subs { + rep := row.Position == state.DaihyosenSubPosition + sides := row.SideAMemberID != "" || row.SideBMemberID != "" + if rep && sides || numbered && !rep && (sides || row.WinnerMemberID != "") { + return true + } } - return rowMembersOutsideTeams(j, snap.Pairing, row, *stored, - slices.Contains(applied, state.GroupRepPickA), slices.Contains(applied, state.GroupRepPickB), errRepMemberNotInTeam) + return false } -// judgeRepPicksUnderTx judges the representative picks of an organiser's score -// write exactly as a participant's are judged (repRowRefusal: the same code and -// the same refusal, 400 team_member_not_in_team), so the organiser and a -// participant never disagree about which pick is valid. -// -// The decision (the operator judges it): the lineup's organiser exemption -// (handlers_lineup.go, mp-q722) is about WHEN the organiser may write, after the -// match finished, and that URL names the team, so the organiser is explicitly -// writing that team's lineup. A representative pick names a SIDE, whose team a -// correction elsewhere can change under an open editor (seatBracketSide), and -// the bare member id is resolved later by every surface through the side's team -// (ReconcileWinnerMemberID, resolveBoutSideDisplayName, the export), where an id -// the team does not hold shows as nobody. The stamp order already holds a pick -// made before the re-seat cleared it; this is the pick stamped after the clear -// from an organiser editor that has not yet received the re-seat. +// landedMembersRefusal judges who a score write SEATS by what it landed, read +// back through stx after the engine wrote it (a StoreTx reads its own staged +// bytes), against the match as it stood before (before, the snapshot read once +// at the top of the write). Introduced means the stored row now holds an id it +// did not hold before at that position: an id the row already had is its own, +// whoever seated it, and a write answers for what it introduces, not for what it +// inherited. In the match as it now stands: // -// What is judged is what the write introduces (repRowRefusal's scope): a side -// the write names whose pick the merge will apply, a member the stored row does -// not already hold, against a side that carries a team id. The winner id is not -// judged (the merge reconciles it). Only team membership is read, never the -// match's status, so the organiser can pick again at once, on a finished match -// too (isMatchFinalized is a participant's gate and is not asked here). +// - a representative bout's pick must be on its side's team +// (errRepMemberNotInTeam), for any caller, as the lineup's member check is +// (handlers_lineup.go: the pick names a SIDE, whose team a correction +// elsewhere can change under an open editor, and every surface resolves the +// bare id through the side's team); +// - with numbered, a participant's write: each numbered row's fighters must be +// on their side's team (errBoutFighterNotInTeam), and a winner member id the +// row now holds must be one of its two fighters (errWinnerMemberNotInBout). +// The organiser's numbered rows are not judged (the lineup's always-editable +// rule, mp-q722). // -// A match with no representative bout is judged against an empty row: the -// organiser's write can create the row (the participant's never does), and every -// pick on it is then introduced. A write whose row names no member judges -// nothing, and reads neither the members nor the competition. Bulk-score and -// quick-score do not come through here: they are main-gated, server-built -// writes. +// It judges the landed match rather than the payload because the engine rewrites +// a write before its own merge: a kachinuki bout log is merged by position, +// member ids are filled in and the winner is worked out, and two merge rules +// decide from that rewritten content (the newer scoring a finish displaces, a +// finish held whole). A reading of the payload could skip a row the real write +// landed. A group the merge does not land is the stored one in the landed +// match, so it introduces nothing and is never judged: a stale change the write +// NAMES is kept in the history and answered in heldGroups, never refused, and +// one it does not name is copied from the stored match, neither written nor +// reported. Only team membership is read, never the match's status, so the +// organiser can pick again at once, on a finished match too. // -// Called under the write's lock, where the participant's judge is, before the -// correction audit, over the one snapshot (snap, found) the write reads for -// both. -func judgeRepPicksUnderTx(stx state.StoreTx, compID string, snap matchSnapshot, found bool, result *state.MatchResult) error { - i := state.DaihyosenSubIndex(result.SubResults) - if i < 0 { - return nil +// A refusal is returned as a *landedWriteRefusal and the caller returns it from +// the transaction, which writes nothing. The caller runs this only for a write +// the engine accepted (a superseded or rolled-back write must still commit its +// history and its restore) and that carries a judged member id +// (carriesJudgedMember). A members file that cannot be read refuses a write +// that needs it, terminally (errTeamMembersUnreadable): nothing is written, +// which the offline queue reads as final. +func landedMembersRefusal(stx state.StoreTx, compID, matchID string, before matchSnapshot, numbered bool) error { + after, found, err := matchSnapshotOrErr(stx, compID, matchID, "member") + if err != nil || !found { + return err } - row := result.SubResults[i] - if row.SideAMemberID == "" && row.SideBMemberID == "" { - return nil + judge := &memberJudge{stx: stx, compID: compID} + err = membersOutsideTeams(judge, before, after, numbered) + var refusal *selfRunRefusal + if errors.As(err, &refusal) { + return &landedWriteRefusal{refusal} } - if !found { - return nil // the write that follows under the same lock refuses an unknown match + return err +} + +// membersOutsideTeams is landedMembersRefusal's judgement: it answers the first +// refusal, an error of another kind being a read that failed. +func membersOutsideTeams(judge *memberJudge, before, after matchSnapshot, numbered bool) error { + pairing := after.Pairing + if numbered { + for _, row := range after.Stored.SubResults { + if row.Position == state.DaihyosenSubPosition { + continue + } + var stored state.SubMatchResult + for _, s := range before.Stored.SubResults { + if s.Position == row.Position { + stored = s + break + } + } + if err := winnerMemberOutsideBout(row, stored); err != nil { + return err + } + if err := rowMembersOutsideTeams(judge, pairing, row, stored, errBoutFighterNotInTeam); err != nil { + return err + } + } } - stored := snap.RepBout - if stored == nil { - stored = &state.SubMatchResult{} + if i := state.DaihyosenSubIndex(after.Stored.SubResults); i >= 0 { + var stored state.SubMatchResult + if before.RepBout != nil { + stored = *before.RepBout + } + return rowMembersOutsideTeams(judge, pairing, after.Stored.SubResults[i], stored, errRepMemberNotInTeam) } - return repRowRefusal(&memberJudge{stx: stx, compID: compID}, snap, result, row, stored) + return nil } // errBoutFighterNotInTeam refuses a participant's numbered bout row that seats a @@ -2252,11 +2285,11 @@ var errTeamMembersUnreadable = &selfRunRefusal{ } // memberJudge is what the member checks of ONE score write share: the -// transaction they run in, and the competition's team members and the -// competition itself (what the merge is asked about), each read at most once -// and only when a row introduces a member id to judge. A write that introduces -// none never reads the file, so a corrupt team-members.yaml blocks no ordinary -// scoring; one that introduces several reads it once. +// transaction they run in, and the competition's team members, each team's +// member ids built at most once, and only when a row introduces a member id +// to judge. A write that introduces none never reads the file, so a corrupt +// team-members.yaml blocks no ordinary scoring; one that introduces several +// reads it once. type memberJudge struct { stx state.StoreTx compID string @@ -2265,27 +2298,8 @@ type memberJudge struct { squads map[string][]domain.TeamMember err error - compRead bool - comp *state.Competition - compErr error -} - -// applied says which of the groups the write changes the merge would APPLY -// onto the stored match (engine.AppliedGroups, which runs the merge on copies). -// A member is judged exactly when the group it rides on lands: a group the merge -// holds is kept in the match's history by the real write, and refusing the write -// for it would lose the groups of the same write that do apply. -func (j *memberJudge) applied(snap matchSnapshot, result *state.MatchResult) ([]string, error) { - if !j.compRead { - j.compRead = true - if j.comp, j.compErr = j.stx.LoadCompetition(j.compID); j.compErr != nil { - j.compErr = fmt.Errorf("member guard: load competition %s: %w", j.compID, j.compErr) - } - } - if j.compErr != nil { - return nil, j.compErr - } - return engine.AppliedGroups(snap.Stored, result, j.comp, snap.InBracket), nil + // ids is each team's member ids (teamMemberIDs), built on the first ask. + ids map[string]map[string]bool } // holds reports whether the team holds the member (teamMemberIDs, the one @@ -2302,7 +2316,15 @@ func (j *memberJudge) holds(teamID, memberID string) (bool, error) { if j.err != nil { return false, errTeamMembersUnreadable } - return teamMemberIDs(j.squads, teamID)[memberID], nil + held, ok := j.ids[teamID] + if !ok { + if j.ids == nil { + j.ids = make(map[string]map[string]bool) + } + held = teamMemberIDs(j.squads, teamID) + j.ids[teamID] = held + } + return held[memberID], nil } // introducesMember reports whether a write puts a member id on a side that the @@ -2315,24 +2337,22 @@ func introducesMember(teamID, memberID, storedID string) bool { return memberID != "" && memberID != storedID && teamID != "" } -// rowMembersOutsideTeams judges the member ids a bout row names against the -// team that holds each side, for the representative bout (repRowRefusal) and -// the numbered rows (numberedRowsRefusal) alike. Ids are bare UUIDs, so the -// team's own members are the only thing that says whose they are: a member of -// another team, or an id no team holds, is refused (refusal) rather than -// written. A side is judged only when the caller says so (judgeA, judgeB: the -// merge applies the representative's pick), and only for what the row -// introduces (introducesMember). stored is the row at the same position in the -// stored match, an empty row when the match has none. -func rowMembersOutsideTeams(j *memberJudge, pairing domain.WinnerAttribution, row, stored state.SubMatchResult, judgeA, judgeB bool, refusal *selfRunRefusal) error { +// rowMembersOutsideTeams judges the member ids a landed bout row names against +// the team that holds each side, for the representative bout and the numbered +// rows alike (membersOutsideTeams). Ids are bare UUIDs, so the team's own +// members are the only thing that says whose they are: a member of another +// team, or an id no team holds, is refused (refusal) rather than kept. A side +// is judged only for what the row introduces (introducesMember). stored is the +// row at the same position before the write, an empty row when the match had +// none. +func rowMembersOutsideTeams(j *memberJudge, pairing domain.WinnerAttribution, row, stored state.SubMatchResult, refusal *selfRunRefusal) error { for _, side := range []struct { - judged bool teamID, memberID, storedID string }{ - {judgeA, pairing.SideAID, row.SideAMemberID, stored.SideAMemberID}, - {judgeB, pairing.SideBID, row.SideBMemberID, stored.SideBMemberID}, + {pairing.SideAID, row.SideAMemberID, stored.SideAMemberID}, + {pairing.SideBID, row.SideBMemberID, stored.SideBMemberID}, } { - if !side.judged || !introducesMember(side.teamID, side.memberID, side.storedID) { + if !introducesMember(side.teamID, side.memberID, side.storedID) { continue } held, err := j.holds(side.teamID, side.memberID) @@ -2346,10 +2366,10 @@ func rowMembersOutsideTeams(j *memberJudge, pairing domain.WinnerAttribution, ro return nil } -// winnerMemberOutsideBout judges the winner member a numbered row INTRODUCES -// (the write changes it against the stored row at the position): it must be -// empty or one of the row's two fighters, read after the kachinuki merge's -// inherit of a side the row is silent about (the stored row's id). A winner id +// winnerMemberOutsideBout judges the winner member a landed numbered row +// INTRODUCES (it differs from the row's at the same position before the write): +// it must be empty or one of the row's two fighters, a side the landed row +// carries no id for counting as the fighter the row held there before. A winner id // the stored row already holds is never judged, whoever it names: a legacy row, // a correction that seated another team and a renamed member leave such ids // behind, and refusing them would refuse every later write of the match. Pure: @@ -2379,61 +2399,6 @@ func introducesWinnerMember(row, stored state.SubMatchResult) bool { return row.WinnerMemberID != "" && row.WinnerMemberID != stored.WinnerMemberID } -// numberedRowsRefusal judges every numbered bout row of a participant's write -// (the representative bout's row, position -1, is repRowRefusal's): a fighter it -// introduces must be on the side's team (errBoutFighterNotInTeam), and a winner -// member it introduces must be one of its two fighters (errWinnerMemberNotInBout). -// The organiser's write is not judged here, as the lineup's is not (mp-q722: the -// organiser is always editable). Rows are compared with the stored match's at -// the same position (snap.Stored), a row it lacks counting as introduced. -// -// A row is judged exactly when the merge APPLIES its bout group -// (state.BoutGroup; memberJudge.applied, as repRowRefusal judges a pick): a -// row whose stored counterpart is newer, or that the write does not name, is -// held and kept in the match's history, and refusing the write for it would -// lose the groups of the same write that do apply. The merge is asked once per -// write, and only when some row introduces a member. -func numberedRowsRefusal(j *memberJudge, snap matchSnapshot, result *state.MatchResult, subs []state.SubMatchResult) error { - var applied []string - probed := false - for _, row := range subs { - if row.Position == state.DaihyosenSubPosition { - continue - } - var stored state.SubMatchResult - if snap.Stored != nil { - for _, s := range snap.Stored.SubResults { - if s.Position == row.Position { - stored = s - break - } - } - } - if !introducesWinnerMember(row, stored) && - !introducesMember(snap.Pairing.SideAID, row.SideAMemberID, stored.SideAMemberID) && - !introducesMember(snap.Pairing.SideBID, row.SideBMemberID, stored.SideBMemberID) { - continue // nothing to judge, so nothing is read - } - if !probed { - var err error - if applied, err = j.applied(snap, result); err != nil { - return err - } - probed = true - } - if !slices.Contains(applied, state.BoutGroup(row.Position)) { - continue // the merge holds this row, whose fighters are not written - } - if err := winnerMemberOutsideBout(row, stored); err != nil { - return err - } - if err := rowMembersOutsideTeams(j, snap.Pairing, row, stored, true, true, errBoutFighterNotInTeam); err != nil { - return err - } - } - return nil -} - // isMatchFinalized reports whether the given stored status represents a // concluded match. Any completed match is finalized, anonymous callers must // not overwrite it regardless of whether a winner was explicitly recorded. @@ -2454,7 +2419,7 @@ type matchStores interface { // matchSnapshot is the stored state of a match as this file's guards read // it: Status drives the correction / stale-write / finalized gates, // CorrectionReason carries the kachinuki reopen justification forward (see -// applyCorrectionReasonUnderTx), and ReopenPending says a reason-less reopen +// applyCorrectionReason), and ReopenPending says a reason-less reopen // has not been ended again. // // It carries only the fields the guards actually read. The post-advance @@ -2463,8 +2428,9 @@ type matchStores interface { // here deep-clones the stored sub-bouts (mp-gmcg review: that was a write-only // value, and an expensive one, under the per-comp write lock). Stored is the // one reader of the stored match's rows, and it SHARES the loaded copy rather -// than cloning it: the self-run judge (numberedRowsRefusal) and the merge probe -// (engine.AppliedGroups) only read it, and the probe clones before it merges. +// than cloning it: the member judge (landedMembersRefusal) only reads it, as the +// match stood before the write, and each load hands out a copy of its own, so +// the engine's write never reaches it. // // InBracket is the match's HOME rather than its content: the pool write is a // whole-struct overwrite that carries ReopenPending itself, while the bracket @@ -2485,9 +2451,10 @@ type matchSnapshot struct { // RepBout is a copy of the stored representative-bout row (position -1), // nil when the match has none: the self-run judge compares a // participant's write against it, and writes back a deep copy of it once - // the organiser has decided it (holdSelfReportedWriteUnderTx); the - // organiser's picks are judged against it too (judgeRepPicksUnderTx). One - // row, which a writer deep-clones (Stored carries the whole bout log). + // the organiser has decided it (holdSelfReportedWriteUnderTx); the picks the + // write lands are judged against it, for what they introduce + // (landedMembersRefusal). One row, which a writer deep-clones (Stored + // carries the whole bout log). RepBout *state.SubMatchResult // Pairing is the stored match's attribution (its sides, their ids and its // winner), which the self-run judge reads a participant's winner against @@ -2501,10 +2468,11 @@ type matchSnapshot struct { // reads (a pool row as loaded; a bracket match through // engine.BracketMatchAsResult). It is what the judges ask about, and it // shares the loaded copy's rows, so a reader clones before it changes - // anything: the member judges ask the merge on copies (engine.AppliedGroups) - // which groups it would apply, and the participant's numbered-row judge reads - // the stored row at each position (numberedRowsRefusal). nil only on a - // zero snapshot (no match found). + // anything. Read before the write it is the baseline the member judge + // compares the landed match against, row by position; read again after the + // write (the same lookupMatchSnapshot, through the same transaction) it is + // what landed (landedMembersRefusal). nil only on a zero snapshot (no match + // found). Stored *state.MatchResult } @@ -2841,7 +2809,7 @@ func matchStatusFromStore(store CompetitionStore, compID, matchID string) state. return status } -// correctionCheck is applyCorrectionReasonUnderTx's verdict. +// correctionCheck is applyCorrectionReason's verdict. // // - StoredStatus is the match's status as stored, returned so the caller's // stale-after-complete guard doesn't have to look the match up again. @@ -2955,20 +2923,20 @@ func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionC return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} } -// applyCorrectionReasonUnderTx reads the match's snapshot itself and applies -// applyCorrectionReason to it: the entry point of a write that runs no other -// guard over the stored match (bulk-score, one read per entry). The score -// handler reads the snapshot once for all its guards and asks -// applyCorrectionReason directly. -func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult) (correctionCheck, error) { +// applyCorrectionReasonUnderTx reads the match's snapshot itself, applies +// applyCorrectionReason to it and hands the snapshot back: the entry point of +// bulk-score, one read per entry, whose later guard (the landed picks' +// judge) compares the match against it. The score handler reads the snapshot +// once for all its guards and asks applyCorrectionReason directly. +func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult) (correctionCheck, matchSnapshot, error) { // matchSnapshotOrErr fails CLOSED on a load error: the correction-reason // gate must not pass on an assumed-running status, silently overwriting a // finalized result without the mandatory reason. snap, _, err := matchSnapshotOrErr(stx, compID, matchID, "correction-reason") if err != nil { - return correctionCheck{}, err + return correctionCheck{}, matchSnapshot{}, err } - return applyCorrectionReason(snap, r), nil + return applyCorrectionReason(snap, r), snap, nil } // missingCorrectionReasonError is the refusal for overwriting a completed @@ -3556,21 +3524,20 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // mp-ba3, bc-dhas: the self-run judge runs under the per-comp // lock, so concurrent anonymous submissions cannot race it. It // may rewrite result's representative bout before the write. - if resultSource == "self-reported" { - if err := holdSelfReportedWriteUnderTx(stx, id, mid, snap, found, result, body.StartOnly); err != nil { - engErr = err - return nil - } - } else if !body.StartOnly { - // The organiser's write is judged on the representative - // picks it introduces and on nothing else (judgeRepPicksUnderTx). - // A start keeps the stored score whatever it sends, so the - // row it carries is no pick. - if err := judgeRepPicksUnderTx(stx, id, snap, found, result); err != nil { + selfReported := resultSource == "self-reported" + if selfReported { + if err := holdSelfReportedWriteUnderTx(id, mid, snap, found, result, body.StartOnly); err != nil { engErr = err return nil } } + // Who the write seats is judged after the engine has written + // it, on what it landed (landedMembersRefusal), and decided + // here, before the engine rewrites result: a participant's + // numbered rows and the representative bout's picks, the + // organiser's picks alone. A start keeps the stored score + // whatever it sends, so the row it carries is no pick. + judgeLanded := found && !body.StartOnly && carriesJudgedMember(result.SubResults, selfReported) // Correction audit: a completed -> completed overwrite requires a // non-empty CorrectionReason for traceability. Ending a match // reopened without a reason is never refused (operator ruling @@ -3631,6 +3598,18 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // includes the K3 rollback for the AlreadyIneligible path, // returning nil here commits whatever final state the engine // settled on. + // + // The one exception is a refusal of what the write landed: + // it is RETURNED, which aborts the transaction, so nothing of + // the write survives (no match write, no history line, no + // eligibility record). It is judged only for a write the + // engine accepted: a superseded or rolled-back one must still + // commit its history and its restore. + if engErr == nil && judgeLanded { + if err := landedMembersRefusal(stx, id, mid, snap, selfReported); err != nil { + return err + } + } if engErr == nil && check.ClearBracketReopenPending { // Only after the write actually landed: this branch commits // even when engErr is set, so discharging any earlier would @@ -3641,6 +3620,12 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti return nil }) }) + // A refusal of what the write landed aborted the transaction + // (landedMembersRefusal) and is answered like any other refusal below. + var landed *landedWriteRefusal + if errors.As(txErr, &landed) { + engErr, txErr = landed.refusal, nil + } if txErr != nil { // txErr carries errors from CheckCrossCompCourtBusy (cross-comp // court conflict or match-not-found) or from the WithTransaction diff --git a/internal/mobileapp/handlers_match_kachinuki_test.go b/internal/mobileapp/handlers_match_kachinuki_test.go index 1ed8204e4..501aa9086 100644 --- a/internal/mobileapp/handlers_match_kachinuki_test.go +++ b/internal/mobileapp/handlers_match_kachinuki_test.go @@ -1401,8 +1401,9 @@ func TestReopenHandler_ReEndKeepsAReasonSent(t *testing.T) { } // TestReopenHandler_BulkScoreEndsAReopenWithoutReason pins the same rule on the -// bulk-score path, which shares applyCorrectionReasonUnderTx with the single -// score path: a reopened match is ended there without a reason too. +// bulk-score path, which shares applyCorrectionReason with the single score +// path (through applyCorrectionReasonUnderTx): a reopened match is ended there +// without a reason too. func TestReopenHandler_BulkScoreEndsAReopenWithoutReason(t *testing.T) { compID := "kachinuki-reopen-bulk" r, store := setupKachinukiScoreServer(t, compID) diff --git a/internal/mobileapp/handlers_match_test.go b/internal/mobileapp/handlers_match_test.go index 4999da7d9..51cff67d6 100644 --- a/internal/mobileapp/handlers_match_test.go +++ b/internal/mobileapp/handlers_match_test.go @@ -45,7 +45,7 @@ func TestApplyCorrectionReasonUnderTx_FailsClosedOnLoadError(t *testing.T) { var gotErr error txErr := store.WithTransaction("c1", func(stx state.StoreTx) error { r := &state.MatchResult{ID: "m1", Status: state.MatchStatusCompleted} - _, gotErr = applyCorrectionReasonUnderTx(stx, "c1", "m1", r) + _, _, gotErr = applyCorrectionReasonUnderTx(stx, "c1", "m1", r) return nil }) require.NoError(t, txErr) @@ -3774,7 +3774,7 @@ func seedKcdgBracket(t *testing.T, store *state.Store, compID string) { // against: {"error":"downstream_knockout_played","matchId":..., // "blockingMatchId":...,"displaced":...,"message":...}. A correctionReason is // required first (a completed->completed write is a correction, gated by -// applyCorrectionReasonUnderTx) so the payload includes one throughout. +// applyCorrectionReason) so the payload includes one throughout. func TestScoreHandler_DownstreamKnockoutPlayed_409Shape(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) defer os.RemoveAll(tempDir) diff --git a/internal/mobileapp/organiser_rep_pick_judge_test.go b/internal/mobileapp/organiser_rep_pick_judge_test.go index 168336c1d..94aab97cb 100644 --- a/internal/mobileapp/organiser_rep_pick_judge_test.go +++ b/internal/mobileapp/organiser_rep_pick_judge_test.go @@ -15,6 +15,7 @@ package mobileapp // carries the main password, in a self-run tournament and in an officiated one. import ( + "encoding/json" "net/http" "net/http/httptest" "os" @@ -185,9 +186,10 @@ func TestOrganiser_AStalePickTheMergeHoldsIsNotRefused(t *testing.T) { // A finish stamped before the stored verdict that names another winner is held // WHOLE by the merge (HoldReasonFinishAtomic): its picks land nowhere, even when -// their own stamps are newer than the stored picks'. The judge asks the merge -// (engine.AppliedGroups), so such a write is answered superseded and kept in -// the history, never refused for a pick that would not have been written. +// their own stamps are newer than the stored picks'. The judge reads what the +// write landed, and a held write lands nothing, so such a write is answered +// superseded and kept in the history, never refused for a pick that was not +// written. func TestOrganiser_AStaleFinishWithAForeignPickIsSupersededNotRefused(t *testing.T) { for _, mode := range organiserModes { t.Run(mode.name, func(t *testing.T) { @@ -340,9 +342,10 @@ func TestOrganiser_UnreadableTeamMembersRefusesThePickTerminally(t *testing.T) { require.NoError(t, os.WriteFile( filepath.Join(p.store.GetFolder(), "competitions", "c1", "team-members.yaml"), []byte("not: [valid yaml"), 0o600)) + before := p.storedState(t) requireTeamMembersUnreadable(t, p.pick(p.now+100, nil, p.membersA[0], "")) - assert.Empty(t, p.storedRepBout(t).SideAMemberID, "a refused pick writes nothing") + assert.Equal(t, before, p.storedState(t), "a refused pick writes nothing, not even its history line") }) } } @@ -359,3 +362,71 @@ func TestOrganiser_APickOnARowTheWriteCreatesIsJudged(t *testing.T) { requireNotOnTeam(t, w) assert.False(t, carriesDaihyosenRow(storedB1(t, f.store, "c1").SubResults), "a refused write creates nothing") } + +// Bulk-score takes the client's subResults entry by entry, each written in its +// own transaction, so a representative pick on an entry is judged like /score's +// and a refused entry stores nothing, the history line included, while the +// entries beside it are written. +func TestOrganiser_BulkScoreJudgesTheRepresentativePicksPerEntry(t *testing.T) { + for _, mode := range organiserModes { + t.Run(mode.name, func(t *testing.T) { + p := newOrganiserPickFixture(t, mode.selfRun) + before := p.storedState(t) + + entry := func(a string) map[string]any { + sheet := sheetWith(state.MatchStatusRunning, "", p.now+100, nil, repBoutRow([]string{}, []string{}, ""), a, "") + sheet["id"] = "B1" + return sheet + } + bulk := func(entries ...map[string]any) (succeeded int, errs []map[string]any) { + w := p.send(http.MethodPost, "/api/competitions/c1/matches/bulk-score", organiserPassword, entries) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body struct { + Succeeded int `json:"succeeded"` + Errors []map[string]any `json:"errors"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body.Succeeded, body.Errors + } + + succeeded, errs := bulk(entry(p.membersB[0])) + assert.Zero(t, succeeded) + require.Len(t, errs, 1) + assert.Equal(t, "B1", errs[0]["matchId"]) + assert.Equal(t, codeTeamMemberNotInTeam, errs[0]["reason"]) + assert.Equal(t, notOnTeamBody, errs[0]["error"]) + assert.Equal(t, before, p.storedState(t), "the refused entry stores nothing, not even its history line") + + succeeded, errs = bulk(entry(p.membersA[0])) + assert.Equal(t, 1, succeeded, "the side's own member is accepted: %v", errs) + assert.Equal(t, p.membersA[0], p.storedRepBout(t).SideAMemberID) + }) + } +} + +// A members file that cannot be read refuses a bulk entry that needs it, with +// the reason a client can branch on, and the entry stores nothing. +func TestOrganiser_BulkScoreRefusesAPickWhenTheTeamMembersCannotBeRead(t *testing.T) { + p := newOrganiserPickFixture(t, true) + require.NoError(t, os.WriteFile( + filepath.Join(p.store.GetFolder(), "competitions", "c1", "team-members.yaml"), + []byte("not: [valid yaml"), 0o600)) + before := p.storedState(t) + + entry := sheetWith(state.MatchStatusRunning, "", p.now+100, nil, repBoutRow([]string{}, []string{}, ""), p.membersA[0], "") + entry["id"] = "B1" + w := p.send(http.MethodPost, "/api/competitions/c1/matches/bulk-score", organiserPassword, []any{entry}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var body struct { + Succeeded int `json:"succeeded"` + Errors []struct { + Reason string `json:"reason"` + } `json:"errors"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Zero(t, body.Succeeded) + require.Len(t, body.Errors, 1) + assert.Equal(t, errTeamMembersUnreadable.code, body.Errors[0].Reason) + assert.Equal(t, before, p.storedState(t), "the refused entry stores nothing") +} diff --git a/internal/mobileapp/self_run_bout_members_test.go b/internal/mobileapp/self_run_bout_members_test.go index bb802edcd..a7af86767 100644 --- a/internal/mobileapp/self_run_bout_members_test.go +++ b/internal/mobileapp/self_run_bout_members_test.go @@ -1,16 +1,17 @@ package mobileapp // A participant's fighters are their team's, and a bout's winner is one of its -// two fighters. The self-run judge (holdSelfReportedWriteUnderTx) refuses a -// score write that INTRODUCES, on a numbered bout row, a side member id the -// side's team does not hold (400 team_member_not_in_team), or a winnerMemberId -// that is not one of the row's two side ids (400 winner_member_not_in_bout). -// Introduced means the write changes it against the stored row at the same -// position: an id the row already holds is never judged, so a row that a -// correction left with another team's fighter, or a stale winner id, does not -// refuse every later write. The organiser is not judged here (the lineup's -// always-editable rule), and a members file that cannot be read refuses the -// write with a terminal 409 only when a row actually needs it. +// two fighters. The member judge (landedMembersRefusal) refuses a score write +// that LANDS, on a numbered bout row, a side member id the side's team does not +// hold (400 team_member_not_in_team), or a winnerMemberId that is not one of the +// row's two side ids (400 winner_member_not_in_bout), and the refused write +// leaves the match and its history untouched. Landed means the stored row now +// holds it and did not before, at the same position: an id the row already held +// is never judged, so a row that a correction left with another team's fighter, +// or a stale winner id, does not refuse every later write. The organiser is not +// judged here (the lineup's always-editable rule), and a members file that +// cannot be read refuses the write with a terminal 409 only when a row actually +// needs it. import ( "net/http" @@ -200,9 +201,10 @@ func TestSelfRun_UnreadableTeamMembersRefusesANumberedRowTerminally(t *testing.T f := newRepBoutFixture(t, true) a, _ := f.teamMembers(t) f.corruptTeamMembers(t) + before := f.storedState(t) requireTeamMembersUnreadable(t, f.bouts("", f.now+100, map[int]boutIDs{1: {a: a[0].ID}})) - assert.Empty(t, f.storedBout(t, 1).SideAMemberID, "a refused write writes nothing") + assert.Equal(t, before, f.storedState(t), "a refused write writes nothing, not even its history line") } // The participant's lineup PUT is refused the same way when the members file @@ -219,13 +221,13 @@ func TestSelfRun_UnreadableTeamMembersRefusesTheLineupTerminally(t *testing.T) { assert.False(t, ok, "a refused lineup writes nothing") } -// A numbered row is judged exactly when the merge APPLIES its bout group, as a -// representative pick is (repRowRefusal): a row whose stored counterpart is -// NEWER is held and kept in the match's history, and judging it would refuse the -// whole write, terminally, for a fighter that would never have been written, -// losing the groups of the write that apply. Here the stored bout 1 names fighter -// Y, stamped T; the write names another team's member on it under T-1, beside -// bouts 2 and 3 and the scoreline, which apply. +// A numbered row is judged on what LANDS, as a representative pick is +// (landedMembersRefusal): a row whose stored counterpart is NEWER is held and +// kept in the match's history, so the fighter it names is never written, and +// refusing the write for it would refuse the whole write, terminally, for a +// fighter that was not written, losing the groups of the write that apply. Here +// the stored bout 1 names fighter Y, stamped T; the write names another team's +// member on it under T-1, beside bouts 2 and 3 and the scoreline, which apply. func TestSelfRun_AHeldNumberedRowIsNotJudged(t *testing.T) { f := newRepBoutFixture(t, true) a, b := f.teamMembers(t) @@ -277,3 +279,79 @@ func TestSelfRun_ANumberedRowTheWriteDoesNotNameIsNotJudged(t *testing.T) { require.Equal(t, http.StatusOK, w.Code, "bout 1 is not among the groups the write changes: %s", w.Body.String()) assert.Empty(t, f.storedBout(t, 1).SideAMemberID, "and it is not written") } + +// A fighter is judged on what the write LANDS, not on what its payload says it +// changes. A kachinuki write is rewritten by the engine before the merge (the +// bout log is merged by position and the rows' member ids are filled in), so a +// row the payload does not name can still land: here `changed` names the match's +// points alone, and the engine writes bout 1 with the foreign fighter anyway. +// The write is refused and leaves the match and its history untouched. +func TestSelfRun_AKachinukiRowTheEngineRewritesIsJudgedOnWhatLands(t *testing.T) { + f := newRepBoutFixture(t, true) + require.NoError(t, f.store.SaveCompetition(&state.Competition{ + ID: "c1", Name: "Teams", Kind: "team", Format: state.CompFormatKnockout, + TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki, + })) + _, b := f.teamMembers(t) + before := f.storedState(t) + + sheet := scoreSheet(state.MatchStatusRunning, "", f.now+100, nil) + row := sheet["subResults"].([]any)[0].(map[string]any) + row["sideA"], row["sideB"], row["winner"] = "Alice", "Bob", "Alice" + row["sideAMemberId"] = b[0].ID + sheet["changed"] = []string{state.GroupPoints} + w := f.send(http.MethodPut, repBoutMatchPath+"/score", "", sheet) + + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", fighterNotOnTeamBody) + assert.Equal(t, before, f.storedState(t), "a refused write leaves the match and its history as they were") +} + +// The pin beside it for the other home of a match: a pool match is read back +// from the staged pool-matches.csv, not bracket.json, and is judged the same way. +func TestSelfRun_AKachinukiPoolRowTheEngineRewritesIsJudgedOnWhatLands(t *testing.T) { + f := newRepBoutFixture(t, true) + require.NoError(t, f.store.SaveCompetition(&state.Competition{ + ID: "c1", Name: "Teams", Kind: "team", Format: state.CompFormatLeague, + TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki, + })) + f.setB1(t, func(bm *state.BracketMatch) { bm.Status = state.MatchStatusScheduled }) // a team fights one match at a time + require.NoError(t, f.store.SavePoolMatches("c1", []state.MatchResult{{ + ID: "Pool A-0", SideA: "TeamA", SideB: "TeamB", SideAID: repBoutTeamAID, SideBID: repBoutTeamBID, + Status: state.MatchStatusRunning, ModifiedAt: f.now - 60_000, + SubResults: []state.SubMatchResult{{Position: 1, IpponsA: []string{"M"}, Winner: "TeamA"}}, + }})) + _, b := f.teamMembers(t) + path := "/api/competitions/c1/matches/Pool%20A-0/score" + poolRows := func() []state.SubMatchResult { + matches, err := f.store.LoadPoolMatches("c1") + require.NoError(t, err) + return matches[0].SubResults + } + before := poolRows() + + sheet := scoreSheet(state.MatchStatusRunning, "", f.now+100, nil) + row := sheet["subResults"].([]any)[0].(map[string]any) + row["sideA"], row["sideB"], row["winner"] = "Alice", "Bob", "Alice" + row["sideAMemberId"] = b[0].ID + sheet["changed"] = []string{state.GroupPoints} + w := f.send(http.MethodPut, path, "", sheet) + + requireRefusal(t, w, http.StatusBadRequest, "team_member_not_in_team", fighterNotOnTeamBody) + assert.Equal(t, before, poolRows(), "a refused write leaves the pool match as it was") +} + +// matchState is what a refused write must leave as it was: the bracket file's +// bytes and the match's history. +type matchState struct { + bracket string + history []state.MatchHistoryEntry +} + +func (f *repBoutFixture) storedState(t *testing.T) matchState { + t.Helper() + bracket, err := os.ReadFile(filepath.Join(f.store.GetFolder(), "competitions", "c1", "bracket.json")) // #nosec G304 -- a test fixture path + require.NoError(t, err) + history, err := f.store.LoadMatchHistory("c1", "B1") + require.NoError(t, err) + return matchState{bracket: string(bracket), history: history} +} diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index 1d3b9ac1e..fa1790ea5 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -817,11 +817,9 @@ func TestHoldSelfReportedWriteUnderTx(t *testing.T) { sent := state.CloneSubResults(tc.incoming) result := &state.MatchResult{SubResults: tc.incoming, Winner: tc.winner, WinnerID: tc.winnerID, Status: tc.status} - err := store.WithTransaction("c1", func(stx state.StoreTx) error { - snap, found, err := matchSnapshotOrErr(stx, "c1", "B1", "test") - require.NoError(t, err) - return holdSelfReportedWriteUnderTx(stx, "c1", "B1", snap, found, result, tc.startOnly) - }) + snap, found, err := matchSnapshotOrErr(store, "c1", "B1", "test") + require.NoError(t, err) + err = holdSelfReportedWriteUnderTx("c1", "B1", snap, found, result, tc.startOnly) if tc.want != nil { var refusal *selfRunRefusal require.ErrorAs(t, err, &refusal) @@ -1111,7 +1109,7 @@ func TestSelfRun_ASideTheWriteDoesNotNameIsNotJudged(t *testing.T) { } // A write that names the bout alone changes no pick, so a foreign id on its row -// is neither judged nor written: the judge asks the merge which picks it lands. +// is neither judged nor written: the judge reads which picks the write landed. func TestSelfRun_AWriteNamingTheBoutAloneIsNotJudgedForThePick(t *testing.T) { f := newRepBoutFixture(t, true) f.addRepBout(t) @@ -1145,11 +1143,13 @@ func TestSelfRun_UnreadableTeamMembersRefusesThePickTerminally(t *testing.T) { filepath.Join(f.store.GetFolder(), "competitions", "c1", "team-members.yaml"), []byte("not: [valid yaml"), 0o600)) + before := f.storedState(t) + row := repBoutRow([]string{}, []string{}, "") row["sideAMemberId"] = membersA[0].ID w := f.score("", state.MatchStatusRunning, "", f.now+100, row) requireTeamMembersUnreadable(t, w) - assert.Empty(t, f.storedRepBout(t).SideAMemberID, "a refused pick writes nothing") + assert.Equal(t, before, f.storedState(t), "a refused pick writes nothing, not even its history line") } // Removing the representative bout dates both sides' representatives with the diff --git a/internal/mobileapp/team_finish_gate.go b/internal/mobileapp/team_finish_gate.go index c3b13742f..d770d2fbe 100644 --- a/internal/mobileapp/team_finish_gate.go +++ b/internal/mobileapp/team_finish_gate.go @@ -149,9 +149,10 @@ func refuseUnfinishedTeamFinishForComp(comp *state.Competition, matchID string, // correction to the bouts of a match a default-win ruling already ended, and // it keeps the ruling (operator ruling 2026-09-24: "Save correction should // just save what the operator enters"), so the bouts nobody fought after it -// are not a finish it has to answer for. check is the snapshot -// applyCorrectionReasonUnderTx read under the same lock the write holds, so -// the stored decision this exemption reads is the one the engine will see. +// are not a finish it has to answer for. check is the verdict +// applyCorrectionReason drew from the snapshot read under the same lock the +// write holds, so the stored decision this exemption reads is the one the +// engine will see. func teamFinishRefusalUnderTx(refusal *ValidationError, check correctionCheck, req *state.MatchResult) *ValidationError { if refusal == nil || engine.KeepsWithdrawalRuling(check.StoredStatus, check.StoredDecision, req) { return nil diff --git a/internal/state/models.go b/internal/state/models.go index f1e11769c..f1c72c5c1 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1656,9 +1656,8 @@ type MatchResult struct { // // SERVER-OWNED. MatchResult binds straight from the score request body, so // a client could otherwise plant or clear the flag; the handler overwrites - // it from the STORED value on every write (see - // applyCorrectionReasonUnderTx in mobileapp/handlers_match.go) and is the - // only thing that moves it. + // it from the STORED value on every write (see applyCorrectionReason in + // mobileapp/handlers_match.go) and is the only thing that moves it. // // Append-only CSV column (rec index 24, after FlagsB at 22/23); older files // with the column absent load as false. Bracket matches mirror it on diff --git a/specs/openapi.yaml b/specs/openapi.yaml index b2b14bb1c..f4b8e5533 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -59,11 +59,16 @@ info: a fighter whose member id the side's team does not hold is refused with 400 `team_member_not_in_team`, and a `winnerMemberId` that is not one of the row's two fighters is refused with 400 `winner_member_not_in_bout`; an id the stored row already holds is never - judged, and the organiser's numbered rows are not judged at all. A pick or a numbered row is - judged only when the merge would apply it: a write stamped before the stored pick or row, one - that does not name the pick or the row's bout in `changed`, one whose pick sits on a row it - neither has nor creates, and a finish the merge holds whole are not judged for it; it is not - written and is kept in the match's history. When the competition's `team-members.yaml` cannot be read, + judged, and the organiser's numbered rows are not judged at all. A member a write would seat + (a participant's numbered row fighter or winner, a representative pick from anyone) is judged + on what the write LANDS, after the merge, in the same transaction: the match is read back as + stored, and a refused write lands nothing (no change to the match, no history entry). A stale + change the merge holds is therefore not judged: a pick or a row the write names (in `changed`, + or by default every group its row carries) and stamps before the stored one is kept in the + match's history and answered in `heldGroups`, e.g. `["bout:1"]`, never refused, while one the + write does not name (and the engine does not rewrite, as it does a kachinuki write's bout log) + is copied from the stored match, neither written nor reported: it is in neither `heldGroups` + nor the history. When the competition's `team-members.yaml` cannot be read, a write that needs it to judge a choice (a pick, a numbered row's fighter, or the lineup save) is refused with 409 `team_members_unreadable` and nothing is written, so the offline queue does not retry it; a write that introduces no member id never reads the file. The rest of the write is @@ -2588,13 +2593,19 @@ paths: is judged against an empty row, since the organiser's write can create it. The row's winner member id is not judged: the merge keeps it naming one of the two stored representatives, or empty. - A pick is judged only when the merge would apply it. A write whose - stamp is strictly before a stored representative, one that does not - name the side's pick in `changed`, one whose pick sits on a row it - neither has nor creates, and a finish the merge holds whole (stamped - before the stored verdict and naming another winner) are not judged - for that pick: the merge holds it, so it is not written and is kept - in the match's history (an equal stamp applies, so it is judged). + A pick is judged on what the write LANDS, after the merge, in the same + transaction: the match is read back as stored, and a pick it now holds + that the stored row did not is refused when its side's team does not + hold the member. A refused write lands nothing: no change to the + match and no history entry. A pick the merge holds is not judged: one + the write names (in `changed`, or by default every group its row + carries) and stamps strictly before the stored representative, or one + carried by a finish the merge holds whole (stamped before the stored + verdict and naming another winner), is kept in the match's history and + answered in `heldGroups`, never refused (an equal stamp applies, so it + is judged). A pick the write does not name in `changed` is copied from + the stored match, neither written nor reported: it is in neither + `heldGroups` nor the history. The rest of the write is ordered by its own stamps, so it is applied and answered with `heldGroups` naming the held side's group, e.g. `["repPickA"]`; it is answered as superseded, with nothing @@ -2610,12 +2621,18 @@ paths: ids, or the stored row's for a side the row is silent about) is refused with `error: winner_member_not_in_bout` and the `message` "The winner recorded is not one of the two fighters in this bout." An id the - stored row already holds is never judged, and a row is judged only - when the merge would apply its `bout:` group: a row stamped - before the stored one, or one the write does not name in `changed`, is - not judged; it is not written and is kept in the match's history, and - the rest of the write is applied and answered with `heldGroups`, e.g. - `["bout:1"]`. The organiser's numbered rows are not judged. + stored row already holds is never judged. A row is judged as it + LANDS (see the pick above), not as its payload reads: the engine + rewrites a kachinuki write before its merge (it merges the bout log by + position and fills in member ids), so a row the payload does not name + in `changed` can still land, and is judged then; a refused write lands + nothing. A row the merge holds (named in `changed`, or by default, and + stamped before the stored one) is not judged: it is kept in the + match's history, and the rest of the write is applied and answered + with `heldGroups`, e.g. `["bout:1"]`. A row the write does not name + and the engine does not rewrite is copied from the stored match, + neither written nor reported. The organiser's numbered rows are not + judged. A `completed` write whose `decision` is empty or `hikiwake` on a match whose stored result is a completed withdrawal (kiken or @@ -3499,6 +3516,14 @@ paths: "Bout 5 (Taisho) has no result. Record a score, a Tie, or a Fusensho before finishing." Nothing is written for that entry, and other entries are unaffected. + An entry is written in its own transaction and its representative bout (position `-1`) + is judged on what it lands, as on PUT /score: a pick the stored row did not hold that + names a member the side's team does not hold is refused, reported in `errors` with + `reason: team_member_not_in_team` and the page's sentence in `error`. When the + competition's `team-members.yaml` cannot be read, an entry that needs it is refused with + `reason: team_members_unreadable`. Nothing is written for a refused entry, its history + entry included, and other entries are unaffected. Numbered bout rows are not judged. + Stays main-password-gated in self-run mode (401 without it): it writes results without PUT /score's self-run checks. @@ -3541,7 +3566,7 @@ paths: classes `reason` names below. reason: type: string - enum: [superseded, clock_skew, corrupt_overrides, downstream_knockout_played, downstream_knockout_running] + enum: [superseded, clock_skew, corrupt_overrides, downstream_knockout_played, downstream_knockout_running, team_member_not_in_team, team_members_unreadable] description: > Machine-readable discriminator, present only for the classes named here and omitted for every other failure, so @@ -3570,7 +3595,12 @@ paths: fought now, or a knockout correction whose new winner would change a side of a later knockout match being fought now; `forceDownstreamReopen` does not get past it; finish or - requeue that match, then retry. In + requeue that match, then retry. + `team_member_not_in_team`: the entry's representative pick + names a member the side's team does not hold; `error` carries + the sentence to show. `team_members_unreadable`: the + competition's team-members.yaml could not be read, so the + pick could not be judged. In every case the entry is excluded from `succeeded` and never broadcast. Other entries in the batch are unaffected. heldGroups: diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 894697c3e..18063eb54 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -2135,6 +2135,51 @@ describe('a Start tapped while another start is still out is refused before anyt expect(onEditScore.mock.calls.map((x) => x[1])).toEqual(['m3']); } finally { c.restore(); } }); + + // The past-tense sentence says the blocker WAS being started, so it is written only + // for a start that went out. A pick whose deferral of the running bout fails never + // starts anything: the court is exactly as it was, and "m3 was being started" would + // be false. + describe('the past-tense notice follows a start that happened', () => { + const pickBlocker = async (onEditScore) => { + const c = await mountCourt([courtMatch('m1', 'running'), courtMatch('m2', 'scheduled'), courtMatch('m3', 'scheduled'), courtMatch('m4', 'scheduled')], { onEditScore }); + let settleRevert; + window.API.revertMatchToQueue = vi.fn(() => new Promise((resolve, reject) => { settleRevert = { resolve, reject }; })); + // The pick: a queue row's Start, which sends m1 back and waits on it. + await act(async () => { startButton(rowOf(c, 'Aka m3')).click(); }); + await act(async () => {}); + // A Start on another row while that wait is out is refused as "still being started". + await act(async () => { startButton(rowOf(c, 'Aka m4')).click(); }); + await act(async () => {}); + expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]').textContent) + .toBe(startWhileStartingMessage({ label: scoreRowMatchName(courtMatch('m3', 'scheduled')) })); + return { c, settleRevert: () => settleRevert }; + }; + + it('e) a pick whose deferral fails never started, so the refused row gets no past-tense notice', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { c, settleRevert } = await pickBlocker(onEditScore); + try { + await act(async () => { settleRevert().reject(new Error('Could not defer the current bout')); }); + await act(async () => {}); + expect(onEditScore, 'the pick was abandoned: nothing was started').not.toHaveBeenCalled(); + expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]'), 'the court is as it was: no sentence about a start').toBeNull(); + expect(c.utils.container.textContent).not.toContain('was being started'); + } finally { c.restore(); } + }); + + it('f) a pick that does start leaves the past-tense notice on the refused row', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const { c, settleRevert } = await pickBlocker(onEditScore); + try { + await act(async () => { settleRevert().resolve(true); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((x) => x[1])).toEqual(['m3']); + expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]').textContent) + .toBe(startWasBlockedByStartMessage({ label: scoreRowMatchName(courtMatch('m3', 'scheduled')) })); + } finally { c.restore(); } + }); + }); }); // The one-start guard is judged from the start in flight NOW. An advance awaits diff --git a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx index ccf9052ec..4c6ad0883 100644 --- a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx +++ b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx @@ -475,6 +475,62 @@ describe('an adopted value is agreed, never re-sent', () => { expect(lastWrite().changed).toContain('result'); }); + // An adopt agrees a change group only for what it took. Agreeing moves BOTH baselines to + // the server's value, so a group the adopt did not take whole (a point or a pick of + // ours still being saved) would then read as unchanged when it is taken back. + describe('an adopt that took only part of a group does not agree the whole', () => { + // A recorded verdict whose winner the match cannot place (a legacy row): its points + // carry no Ht mark, so the editor's markless running body can equal the baseline. + const unplaced = (over = {}) => individual({ decidedByHantei: true, ...over }); + + it('a hantei adopted while a point is still being saved: taking the point back is named (B)', async () => { + const { rerender } = await mount(individual()); + await pointerTap(ipponBtn('aka', 'M')); + await settle(); + expect(lastWrite().changed).toEqual(['points']); + // Another device's verdict arrives before the write's answer, then the point lands. + await act(async () => { rerender(editorFor(unplaced())); }); + await act(async () => { rerender(editorFor(unplaced({ ipponsA: ['M'] }))); }); + // The armed verdict locks the slots: cancel it, then take the point back. + await click(screen.getByTestId('scoring-modal-hantei-cancel')); + await pointerTap(slotWith('Aka', 'M')); + await settle(); + expect(lastWrite().ipponsA).toEqual([]); + expect(lastWrite().changed, 'the server keeps the point unless the write names the points').toContain('points'); + }); + + describe('the two picks of a representative bout are one group', () => { + const repBout = (over = {}) => individual({ + id: 'Pool 1-DH-1', sideA: { id: 'team-kyoto', name: 'Kyoto' }, sideB: { id: 'team-osaka', name: 'Osaka' }, + repIsTeam: true, repRosterA: ['Kato', 'Mori'], repRosterB: ['Sato', 'Ito'], ...over, + }); + const pick = (testId, value) => act(async () => { fireEvent.change(screen.getByTestId(testId), { target: { value } }); }); + + it('a pick adopted on one side while the other side\'s pick is still being saved: putting it back is named (B)', async () => { + const { rerender } = await mount(repBout({ repPlayerB: 'Sato' })); + await pick('rep-shiro-select', 'Ito'); + await settle(); + expect(lastWrite().repPlayerB).toBe('Ito'); + await act(async () => { rerender(editorFor(repBout({ repPlayerA: 'Mori', repPlayerB: 'Sato' }))); }); + await act(async () => { rerender(editorFor(repBout({ repPlayerA: 'Mori', repPlayerB: 'Ito' }))); }); + await pick('rep-shiro-select', 'Sato'); + await settle(); + expect(lastWrite()).toMatchObject({ repPlayerA: 'Mori', repPlayerB: 'Sato' }); + expect(lastWrite().changed, 'the server keeps Ito unless the write names the picks').toContain('rep'); + }); + + it('both picks adopted in one update are agreed: an unrelated edit does not name them (F)', async () => { + const { rerender } = await mount(repBout({ repPlayerA: 'Kato', repPlayerB: 'Sato' })); + await act(async () => { rerender(editorFor(repBout({ repPlayerA: 'Mori', repPlayerB: 'Ito' }))); }); + expect(screen.getByTestId('rep-aka-select').value).toBe('Mori'); + expect(screen.getByTestId('rep-shiro-select').value).toBe('Ito'); + await pointerTap(ipponBtn('aka', 'M')); + await settle(); + expect(lastWrite().changed).toEqual(['points']); + }); + }); + }); + it('engi: flags adopted from another device and then set back here are named (B)', async () => { const engiMatch = (over = {}) => ({ id: 'm-engi', compId: 'comp1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 775075552..ac4d7acc0 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -66,6 +66,11 @@ import { GROUP_POINTS, GROUP_RESULT, GROUP_ENCHO, GROUP_REP } from './match_grou 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 @@ -146,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 @@ -178,8 +190,12 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on 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. - claimChanged.agree(GROUP_POINTS); + // 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); }, }); @@ -231,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 @@ -249,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 || ""); claimChanged.agree(GROUP_REP); }, + 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 || ""); claimChanged.agree(GROUP_REP); }, + 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. @@ -807,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_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index b8f936360..9f2d28bec 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -839,8 +839,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const [correctingKey, setCorrectingKey] = useStateSh(null); // 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 } - // (`against` is the blocker's key, `label` its name as the operator sees it). + // { 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. The one // that outlives its condition is "starting": when the start it named lands, @@ -1095,7 +1096,13 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // Refuse a tap: remember it against the match that blocks it, and toast. const refuseTap = (m, why) => { const blocker = refusalBlocker(why); - setRefusedTap({ key: matchKey(m), compId: m.compId, why, against: matchKey(blocker), label: scoreRowMatchName(blocker) }); + 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 @@ -1327,11 +1334,22 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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; - const blocked = { key: refusedTap.key, compId: refusedTap.compId, msg: startWasBlockedByStartMessage({ label: refusedTap.label }) }; - setStartError((prev) => prev ?? blocked); + 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]); // `pick` is the pickingRef entry pickMatch made for THIS start, if it is one: @@ -1351,7 +1369,8 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, return false; } setStartError(null); - startingRef.current = { key, match: m }; + const started = { key, match: m }; + startingRef.current = started; setStartingKey(key); try { // Starting makes the match running; the scoring panel shows @@ -1371,6 +1390,10 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, 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) { const msg = startFailureMessage(e); From 87c04987e824f527d2bb12c06865bb1232d726c6 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 21:25:01 +0100 Subject: [PATCH 35/38] fix: polish after the round-16 review (PR #463 round 17) - The post-write member judge reads the match back only when a payload row introduces a member id against the match as it stood before the write (introducesJudgedMember, a pure predicate): an echo of the stored ids, which the team editor stamps on every row with a lineup, no longer re-parses the competition's files under its write lock on each autosave. The engine never lands a member id the judge would refuse that the payload did not introduce. - A team rename carries a representative row's winner when it names the side by the match's old name too, not only the row's own (a row's side name can be blank or stale). - A start the server answers as superseded is not a start that went out: no past-tense "was being started" notice for a match refused meanwhile, and a pick does not pin the panel on a match the server did not start. - forEachBracketSideWithID inlined into its one caller; the bulk-score comment names team_members_unreadable; a bulk-score test sends two entries for one match in one request. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- internal/engine/bracket_seat.go | 28 +++- internal/engine/bracket_seat_test.go | 73 +++++++++ internal/engine/replace_participant.go | 41 ++--- internal/mobileapp/handlers_match.go | 108 +++++++++---- .../introduces_judged_member_test.go | 151 ++++++++++++++++++ .../organiser_rep_pick_judge_test.go | 43 +++++ ..._scores_auto_start_refusal.render.test.jsx | 38 +++++ .../render/admin_shiaijo.render.test.jsx | 36 +++++ web-mobile/js/__tests__/start_match.test.jsx | 12 +- web-mobile/js/admin_schedule_score_editor.jsx | 10 +- web-mobile/js/admin_shiaijo.jsx | 15 +- web-mobile/js/start_match.jsx | 24 ++- 13 files changed, 508 insertions(+), 77 deletions(-) create mode 100644 internal/mobileapp/introduces_judged_member_test.go diff --git a/CLAUDE.md b/CLAUDE.md index cc503d81e..e97c4cc2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,9 +175,9 @@ tournament-data/ - **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. 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 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 lands, and the refused match is never started on its own; if the blocking start fails instead, its own refusal 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. +- **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 or for a start the server held as superseded (`classifyStartOutcome` flags it `superseded`: nothing was written, so the court is as it was and a pick does not pin the panel on that match). The refused match is never started on its own; if the blocking start fails instead, its own refusal stays. The Scores tab keeps no such record, so a superseded start there still opens the next match after a decision. 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. 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 (a rename: `nameRepBoutRow` with `another` false; `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 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; `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. @@ -331,7 +331,7 @@ 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 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, carries a member id on a judged row (`carriesJudgedMember`). 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. +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): 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. 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, 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; diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go index 45c8e38ad..e012fc215 100644 --- a/internal/engine/bracket_seat.go +++ b/internal/engine/bracket_seat.go @@ -40,8 +40,9 @@ import ( // 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 -// (nameRepBoutRow). A match with no row has nothing to write. +// 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 @@ -100,6 +101,7 @@ func seatBracketSide(bm *state.BracketMatch, side domain.MatchSide, name, id str 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. @@ -113,7 +115,7 @@ func seatBracketSide(bm *state.BracketMatch, side domain.MatchSide, name, id str } // 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, another) + nameRepBoutRow(bm, row, side, name, matchOld, another) if !another { return } @@ -149,8 +151,17 @@ func seatedAnotherTeam(storedName, storedID, name, id string) bool { // 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. 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. -func nameRepBoutRow(bm *state.BracketMatch, row int, side domain.MatchSide, name string, another bool) { +// 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 @@ -165,9 +176,10 @@ func nameRepBoutRow(bm *state.BracketMatch, row int, side domain.MatchSide, name *field(&subs[row]) = name // 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, 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. - if !another && old != "" && subs[row].Winner == old { + // 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. + if w := subs[row].Winner; !another && w != "" && (w == old || w == matchOld) { subs[row].Winner = name } bm.SubResults = subs diff --git a/internal/engine/bracket_seat_test.go b/internal/engine/bracket_seat_test.go index d73a1fde2..04d99427d 100644 --- a/internal/engine/bracket_seat_test.go +++ b/internal/engine/bracket_seat_test.go @@ -507,6 +507,39 @@ func TestReplaceParticipantInDraw_ARenamedIdlessSideKeepsItsPick(t *testing.T) { 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). @@ -541,6 +574,46 @@ func TestSeatBracketSide_ARenameCarriesTheRowsWinnerAndAnotherTeamDoesNot(t *tes 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) + }) + // 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 diff --git a/internal/engine/replace_participant.go b/internal/engine/replace_participant.go index a601c28af..04c80f50a 100644 --- a/internal/engine/replace_participant.go +++ b/internal/engine/replace_participant.go @@ -215,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 } @@ -248,7 +248,7 @@ func (e *Engine) ReplaceParticipantInDraw( // (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, false) + nameRepBoutRow(m, row, slot, newName, oldName, false) } } }) @@ -334,8 +334,8 @@ func matchesParticipant(rowID, pid string) bool { // 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 named the side by -// the old name) follows the match's: the same id is the same team, so no pick +// 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 { @@ -355,27 +355,20 @@ func renameBracketSide(m *state.BracketMatch, side domain.MatchSide, newName, id return true } -// 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)) { - forEachBracketSlot(b, func(_ *state.BracketMatch, _ domain.MatchSide, name, id *string) { - fn(name, id) - }) -} - -// forEachBracketSlot is forEachBracketSideWithID with the slot's match and +// 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). The rename passes that -// write a side through seatBracketSide, or keep the representative bout row in -// step with one, need both. +// 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) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 75799e1da..7444f3ebf 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -731,7 +731,10 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // entry would change a side of one (terminal: finish or requeue // that match, then retry); and "team_member_not_in_team" when a // representative pick the entry lands is not on its side's team - // (the entry stores nothing; /score answers it with a 400). + // (the entry stores nothing; /score answers it with a 400); and + // "team_members_unreadable" when the competition's team members + // cannot be read to judge such a pick (the entry stores nothing; + // /score answers it with a 409). // // It matters because the single-match endpoints answer those // conditions with a distinct body ({"applied": false, "reason": @@ -899,7 +902,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // landed, as /score's are (landedMembersRefusal), decided here, // before the engine rewrites the entry. Its numbered rows are // not: this is the organiser's tooling. - judgePicks := carriesJudgedMember(results[i].SubResults, false) + judgePicks := introducesJudgedMember(results[i].SubResults, snap, false) status, err := eng.RecordMatchResultWithIneligibilityTx(stx, id, results[i].ID, &results[i].MatchResult, engine.ForceOptions{ Force: results[i].ForceDownstreamReopen, Reopened: &reopenedThisItem, @@ -2145,26 +2148,76 @@ type landedWriteRefusal struct{ refusal *selfRunRefusal } func (e *landedWriteRefusal) Error() string { return e.refusal.Error() } func (e *landedWriteRefusal) Unwrap() error { return e.refusal } -// carriesJudgedMember says whether a score write, as the judges before the -// engine leave it, names a member id on a row landedMembersRefusal judges: a -// representative pick on the representative bout's row (anyone's write), and -// a numbered row's fighters and winner (a participant's, numbered). The engine -// adds an id to a row only by copying one the stored row already holds at -// that position (preserveKachinukiMemberIDs) or by deriving the winner's from -// the stored picks (ReconcileWinnerMemberID), and neither introduces one: a -// write that carries none can introduce none, so the staged match is not read -// back to look. -func carriesJudgedMember(subs []state.SubMatchResult, numbered bool) bool { +// introducesJudgedMember is the gate in front of landedMembersRefusal: it says +// whether a score write, as the judges before the engine leave it (subs), +// introduces a member id on a row that judge looks at, against the match as it +// stood before the write (before, the snapshot read at the top of it). Those +// rows are the representative bout's, for anyone's write (either side's pick, +// against before.RepBout, the row the judge compares it with), and with numbered +// a participant's numbered rows (either fighter and the winner, against the +// stored row at the same Position). A position the stored match lacks holds no +// id, so every id its row carries is an introduction. +// +// The invariant that makes the gate equivalent to judging every write: the +// engine never lands a member id the judge would refuse that the payload did +// not introduce. It adds an id to a row only by copying the one the stored row +// holds at that position (preserveKachinukiMemberIDs) or by deriving the +// winner's from the row's own landed side ids (ResolveMemberWinnerID, +// ReconcileWinnerMemberID), which the judge accepts. So a write that introduces +// none leaves the staged match unread. The echo is the common case: the team +// editor stamps member ids on every row that has a lineup, and a gate that +// fired on any id would parse pool-matches.csv (and bracket.json for a knockout +// match) again under the competition's write lock on nearly every autosave. +// +// Only the ids are compared, never a team id from before.Pairing: the judge +// reads a side's team from the LANDED pairing, and on the pool branch a payload +// side id over an id-less stored side lands (reconcileSides), so a gate keyed on +// the stored team ids would skip exactly that write. +func introducesJudgedMember(subs []state.SubMatchResult, before matchSnapshot, numbered bool) bool { + var storedRows []state.SubMatchResult + if before.Stored != nil { + storedRows = before.Stored.SubResults + } for _, row := range subs { - rep := row.Position == state.DaihyosenSubPosition - sides := row.SideAMemberID != "" || row.SideBMemberID != "" - if rep && sides || numbered && !rep && (sides || row.WinnerMemberID != "") { + if row.Position == state.DaihyosenSubPosition { + var stored state.SubMatchResult + if before.RepBout != nil { + stored = *before.RepBout + } + if introducesFighter(row, stored) { + return true + } + continue + } + if !numbered { + continue + } + stored := boutAt(storedRows, row.Position) + if introducesFighter(row, stored) || introducesWinnerMember(row, stored) { return true } } return false } +// introducesFighter reports whether a row puts a member id on either side that +// the stored row at the same position does not already hold. +func introducesFighter(row, stored state.SubMatchResult) bool { + return introducesID(row.SideAMemberID, stored.SideAMemberID) || + introducesID(row.SideBMemberID, stored.SideBMemberID) +} + +// boutAt returns the row of subs at position, the empty row when there is none: +// a position a match lacks holds no member id. +func boutAt(subs []state.SubMatchResult, position int) state.SubMatchResult { + for _, s := range subs { + if s.Position == position { + return s + } + } + return state.SubMatchResult{} +} + // landedMembersRefusal judges who a score write SEATS by what it landed, read // back through stx after the engine wrote it (a StoreTx reads its own staged // bytes), against the match as it stood before (before, the snapshot read once @@ -2199,8 +2252,8 @@ func carriesJudgedMember(subs []state.SubMatchResult, numbered bool) bool { // A refusal is returned as a *landedWriteRefusal and the caller returns it from // the transaction, which writes nothing. The caller runs this only for a write // the engine accepted (a superseded or rolled-back write must still commit its -// history and its restore) and that carries a judged member id -// (carriesJudgedMember). A members file that cannot be read refuses a write +// history and its restore) and that introduces a judged member id +// (introducesJudgedMember). A members file that cannot be read refuses a write // that needs it, terminally (errTeamMembersUnreadable): nothing is written, // which the offline queue reads as final. func landedMembersRefusal(stx state.StoreTx, compID, matchID string, before matchSnapshot, numbered bool) error { @@ -2226,13 +2279,7 @@ func membersOutsideTeams(judge *memberJudge, before, after matchSnapshot, number if row.Position == state.DaihyosenSubPosition { continue } - var stored state.SubMatchResult - for _, s := range before.Stored.SubResults { - if s.Position == row.Position { - stored = s - break - } - } + stored := boutAt(before.Stored.SubResults, row.Position) if err := winnerMemberOutsideBout(row, stored); err != nil { return err } @@ -2334,7 +2381,14 @@ func (j *memberJudge) holds(teamID, memberID string) (bool, error) { // seated another team), and a side with no team id has nothing to judge a // member against. func introducesMember(teamID, memberID, storedID string) bool { - return memberID != "" && memberID != storedID && teamID != "" + return teamID != "" && introducesID(memberID, storedID) +} + +// introducesID reports whether a write's member id is one the stored row at the +// same position does not hold: the test every judge and the gate in front of +// them (introducesJudgedMember) share. +func introducesID(memberID, storedID string) bool { + return memberID != "" && memberID != storedID } // rowMembersOutsideTeams judges the member ids a landed bout row names against @@ -2396,7 +2450,7 @@ func winnerMemberOutsideBout(row, stored state.SubMatchResult) error { // write changes against the stored row at the same position, as // introducesMember does for a side's fighter. func introducesWinnerMember(row, stored state.SubMatchResult) bool { - return row.WinnerMemberID != "" && row.WinnerMemberID != stored.WinnerMemberID + return introducesID(row.WinnerMemberID, stored.WinnerMemberID) } // isMatchFinalized reports whether the given stored status represents a @@ -3537,7 +3591,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // numbered rows and the representative bout's picks, the // organiser's picks alone. A start keeps the stored score // whatever it sends, so the row it carries is no pick. - judgeLanded := found && !body.StartOnly && carriesJudgedMember(result.SubResults, selfReported) + judgeLanded := found && !body.StartOnly && introducesJudgedMember(result.SubResults, snap, selfReported) // Correction audit: a completed -> completed overwrite requires a // non-empty CorrectionReason for traceability. Ending a match // reopened without a reason is never refused (operator ruling diff --git a/internal/mobileapp/introduces_judged_member_test.go b/internal/mobileapp/introduces_judged_member_test.go new file mode 100644 index 000000000..972ee23c6 --- /dev/null +++ b/internal/mobileapp/introduces_judged_member_test.go @@ -0,0 +1,151 @@ +package mobileapp + +// introducesJudgedMember is the gate in front of the landed member judge +// (landedMembersRefusal): the judge re-reads the staged match under the +// competition's write lock, so it is asked only for a write that INTRODUCES a +// member id on a judged row, against the match as it stood before the write. +// A team editor stamps member ids on every row that has a lineup, so an echo +// of the stored ids is the common case and must read nothing. + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" +) + +func TestIntroducesJudgedMember(t *testing.T) { + rep := func(a, b string) state.SubMatchResult { + return state.SubMatchResult{Position: state.DaihyosenSubPosition, SideAMemberID: a, SideBMemberID: b} + } + bout := func(position int, a, b, winner string) state.SubMatchResult { + return state.SubMatchResult{Position: position, SideAMemberID: a, SideBMemberID: b, WinnerMemberID: winner} + } + // stored builds the snapshot of a match as it stood before the write. + stored := func(pairing domain.WinnerAttribution, repBout *state.SubMatchResult, rows ...state.SubMatchResult) matchSnapshot { + return matchSnapshot{ + Pairing: pairing, + RepBout: repBout, + Stored: &state.MatchResult{SubResults: rows}, + } + } + teams := domain.WinnerAttribution{SideAID: "team-a", SideBID: "team-b"} + repRow := rep("ma", "mb") + + tests := []struct { + name string + subs []state.SubMatchResult + snap matchSnapshot + numbered bool + want bool + }{ + { + name: "no rows", + snap: stored(teams, &repRow, repRow), + want: false, + }, + { + name: "an echo of the stored representative pick", + subs: []state.SubMatchResult{rep("ma", "mb")}, + snap: stored(teams, &repRow, repRow), + want: false, + }, + { + name: "a representative row that names no member", + subs: []state.SubMatchResult{rep("", "")}, + snap: stored(teams, &repRow, repRow), + want: false, + }, + { + name: "a representative pick on side A that the stored row does not hold", + subs: []state.SubMatchResult{rep("other", "mb")}, + snap: stored(teams, &repRow, repRow), + want: true, + }, + { + name: "a representative pick on side B that the stored row does not hold", + subs: []state.SubMatchResult{rep("ma", "other")}, + snap: stored(teams, &repRow, repRow), + want: true, + }, + { + name: "a representative pick on a match that has no representative bout", + subs: []state.SubMatchResult{rep("ma", "")}, + snap: stored(teams, nil), + want: true, + }, + { + name: "a representative winner id alone is no pick", + subs: []state.SubMatchResult{{Position: state.DaihyosenSubPosition, WinnerMemberID: "ma"}}, + snap: stored(teams, nil), + want: false, + }, + { + name: "an echo of a numbered row's fighters and winner, a participant's write", + subs: []state.SubMatchResult{bout(1, "ma", "mb", "ma")}, + snap: stored(teams, nil, bout(1, "ma", "mb", "ma")), + numbered: true, + want: false, + }, + { + name: "a numbered row's fighter the stored row does not hold, a participant's write", + subs: []state.SubMatchResult{bout(1, "ma", "other", "")}, + snap: stored(teams, nil, bout(1, "ma", "mb", "")), + numbered: true, + want: true, + }, + { + name: "a numbered row's winner the stored row does not hold, a participant's write", + subs: []state.SubMatchResult{bout(1, "ma", "mb", "mb")}, + snap: stored(teams, nil, bout(1, "ma", "mb", "ma")), + numbered: true, + want: true, + }, + { + name: "a numbered row at a position the stored match lacks, a participant's write", + subs: []state.SubMatchResult{bout(3, "ma", "", "")}, + snap: stored(teams, nil, bout(1, "ma", "mb", "")), + numbered: true, + want: true, + }, + { + name: "a numbered row at a position the stored match lacks that names no member", + subs: []state.SubMatchResult{bout(3, "", "", "")}, + snap: stored(teams, nil, bout(1, "ma", "mb", "")), + numbered: true, + want: false, + }, + { + name: "a numbered row's fighter over a stored side that holds none, on a match whose stored sides carry no team id", + subs: []state.SubMatchResult{bout(1, "ma", "", "")}, + snap: stored(domain.WinnerAttribution{}, nil, bout(1, "", "", "")), + numbered: true, + want: true, + }, + { + name: "a representative pick over a stored side that holds none, on a match whose stored sides carry no team id", + subs: []state.SubMatchResult{rep("ma", "")}, + snap: stored(domain.WinnerAttribution{}, &state.SubMatchResult{Position: state.DaihyosenSubPosition}, state.SubMatchResult{Position: state.DaihyosenSubPosition}), + want: true, + }, + { + name: "the organiser's numbered rows are not judged", + subs: []state.SubMatchResult{bout(1, "other", "other", "other")}, + snap: stored(teams, nil, bout(1, "ma", "mb", "ma")), + want: false, + }, + { + name: "a write on a snapshot that holds no stored match introduces every id it carries", + subs: []state.SubMatchResult{bout(1, "ma", "", "")}, + snap: matchSnapshot{}, + numbered: true, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, introducesJudgedMember(tt.subs, tt.snap, tt.numbered)) + }) + } +} diff --git a/internal/mobileapp/organiser_rep_pick_judge_test.go b/internal/mobileapp/organiser_rep_pick_judge_test.go index 94aab97cb..b1fb94d93 100644 --- a/internal/mobileapp/organiser_rep_pick_judge_test.go +++ b/internal/mobileapp/organiser_rep_pick_judge_test.go @@ -404,6 +404,49 @@ func TestOrganiser_BulkScoreJudgesTheRepresentativePicksPerEntry(t *testing.T) { } } +// PIN, green by design (the loop already isolates entries: each runs in its own +// WithTransaction and a refusal is recorded against that entry and moved past). +// The test above sends one entry per request, which cannot show it: here the +// FIRST entry of one request carries a foreign pick and the SECOND, for the same +// match, a valid one. The first is refused and stores nothing, history line +// included, and does not poison the second, which is written. +func TestOrganiser_BulkScoreRefusesOneEntryAndWritesTheNextOnTheSameMatch(t *testing.T) { + for _, mode := range organiserModes { + t.Run(mode.name, func(t *testing.T) { + p := newOrganiserPickFixture(t, mode.selfRun) + before := p.storedState(t) + + entry := func(at int64, a string) map[string]any { + sheet := sheetWith(state.MatchStatusRunning, "", at, nil, repBoutRow([]string{}, []string{}, ""), a, "") + sheet["id"] = "B1" + return sheet + } + w := p.send(http.MethodPost, "/api/competitions/c1/matches/bulk-score", organiserPassword, []map[string]any{ + entry(p.now+100, p.membersB[0]), // a member of the other team + entry(p.now+200, p.membersA[0]), // the side's own member + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body struct { + Succeeded int `json:"succeeded"` + Errors []map[string]any `json:"errors"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + assert.Equal(t, 1, body.Succeeded, "the valid entry is written: %v", body.Errors) + require.Len(t, body.Errors, 1) + assert.Equal(t, "B1", body.Errors[0]["matchId"]) + assert.Equal(t, codeTeamMemberNotInTeam, body.Errors[0]["reason"]) + assert.Equal(t, notOnTeamBody, body.Errors[0]["error"]) + + assert.Equal(t, p.membersA[0], p.storedRepBout(t).SideAMemberID, "the second entry landed") + assert.Empty(t, p.storedRepBout(t).SideBMemberID) + after := p.storedState(t) + assert.Len(t, after.history, len(before.history)+1, + "one history line, the written entry's: the refused entry left none") + }) + } +} + // A members file that cannot be read refuses a bulk entry that needs it, with // the reason a client can branch on, and the entry stores nothing. func TestOrganiser_BulkScoreRefusesAPickWhenTheTeamMembersCannotBeRead(t *testing.T) { diff --git a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx index 7a7249022..ada175bd7 100644 --- a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx @@ -170,6 +170,28 @@ describe('Finish + Start Next on the Scores tab says why the next match did not expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); expect(showToast).not.toHaveBeenCalled(); }); + + // PIN (green by design). A superseded start (HTTP 200 applied:false, reason + // superseded) wrote nothing, but it is not a refusal either: this page keeps no + // "the start went out" record (the court console's `landed` words a past-tense + // notice from one), so nothing here depends on telling it from a landed start. + // The operator lands on the next match in pre-match, still scheduled with its + // Start button, which is where they want to be. + it('a superseded start raises no notice and no toast, and the editor lands on the next match', async () => { + const onEditScore = vi.fn(async (_c, id, patch) => (id === 'm-2' && patch.startOnly + ? { applied: false, reason: 'superseded' } + : { status: 'ok' })); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-1', 'm-2']); + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); + expect(showToast).not.toHaveBeenCalled(); + expect(probe.props.match.id).toBe('m-2'); + expect(probe.props.match.status).toBe('scheduled'); + }); }); describe('one start at a time on the Scores tab', () => { @@ -270,6 +292,22 @@ describe('the start after a decision says why too', () => { expect(noticeIn(rowOf(utils, 'Alice')).textContent).toContain(CLOCK_SKEW_REASON_TEXT); expect(probe.props.match.id).toBe('m-2'); }); + + // PIN (green by design): see the superseded case above. onAfterDecision is the + // one place this page reads startNext's answer, to open the next match, and a + // superseded start still opens it. + it('a superseded start after a decision still opens the next match, with no notice', async () => { + const onEditScore = vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onAfterDecision({ winner: side('m-1-a', 'Yamada') }); }); + + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); + expect(showToast).not.toHaveBeenCalled(); + expect(probe.props.match.id).toBe('m-2'); + }); }); describe('the refusal notice goes when it stops being true', () => { diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 18063eb54..344b60c02 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -2179,6 +2179,42 @@ describe('a Start tapped while another start is still out is refused before anyt .toBe(startWasBlockedByStartMessage({ label: scoreRowMatchName(courtMatch('m3', 'scheduled')) })); } finally { c.restore(); } }); + + // A superseded start is HTTP 200 {applied:false, reason:'superseded'}: a newer + // change to the match's result is already stored, nothing of the start was + // written and the court is as it was. "m3 was being started" would be false, + // and the pick must not pin the panel on a match the server did not start. + const supersededStartOfM3 = () => vi.fn((_compId, matchId) => Promise.resolve( + matchId === 'm3' ? { applied: false, reason: 'superseded' } : { applied: true } + )); + + it('g) a pick whose start is superseded wrote nothing, so the refused row gets no past-tense notice', async () => { + const onEditScore = supersededStartOfM3(); + const { c, settleRevert } = await pickBlocker(onEditScore); + try { + await act(async () => { settleRevert().resolve(true); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((x) => x[1]), 'the pick did send its start').toEqual(['m3']); + expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]'), 'the court is as it was: no sentence about a start').toBeNull(); + expect(c.utils.container.textContent).not.toContain('was being started'); + } finally { c.restore(); } + }); + + it('h) a pick whose start is superseded does not pin the panel on the match the server did not start', async () => { + const landedPick = await pickBlocker(vi.fn().mockResolvedValue({ applied: true })); + try { + await act(async () => { landedPick.settleRevert().resolve(true); }); + await act(async () => {}); + expect(landedPick.c.editorMatch(), 'a start that went out pins the panel on the picked match').toBe('m3'); + } finally { landedPick.c.utils.unmount(); landedPick.c.restore(); } + + const { c, settleRevert } = await pickBlocker(supersededStartOfM3()); + try { + await act(async () => { settleRevert().resolve(true); }); + await act(async () => {}); + expect(c.editorMatch(), 'nothing was started: the panel stays where it was').not.toBe('m3'); + } finally { c.restore(); } + }); }); }); diff --git a/web-mobile/js/__tests__/start_match.test.jsx b/web-mobile/js/__tests__/start_match.test.jsx index 6b8a9520c..5ea1642ea 100644 --- a/web-mobile/js/__tests__/start_match.test.jsx +++ b/web-mobile/js/__tests__/start_match.test.jsx @@ -31,8 +31,16 @@ describe('classifyStartOutcome', () => { expect(classifyStartOutcome(null)).toEqual({ ok: true }); }); - it('a superseded start is not a clock refusal and keeps the console\'s reading (started)', () => { - expect(classifyStartOutcome({ applied: false, reason: 'superseded' })).toEqual({ ok: true }); + it('a superseded start is not a clock refusal to report, but it is not a start that went out either', () => { + // Nothing of it was written: a newer change to the match's result is + // already stored, so a host that records "the start went out" must not. + expect(classifyStartOutcome({ applied: false, reason: 'superseded' })).toEqual({ ok: true, superseded: true }); + }); + + it('only a superseded answer carries the flag: landed, queued and body-less starts did go out', () => { + for (const res of [{ applied: true }, { queued: true }, { status: 'ok' }, undefined, null]) { + expect(classifyStartOutcome(res).superseded, JSON.stringify(res)).toBeUndefined(); + } }); }); diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx index 390b5dc2f..ec42150fe 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -197,8 +197,14 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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. Every start-gating rule is the server's (StartMatchTx), so - // a thrown 409 only needs reporting. + // it never throws. A SUPERSEDED start (classifyStartOutcome: nothing written, a + // newer change to the match's result is stored) also resolves true, on purpose: + // the court console records "the start went out" and words a past-tense notice + // from it, so it must tell the two apart; this page keeps no such record, and + // the answer only decides whether the decision flow opens the next match, which + // is right either way (still scheduled, with its Start button). + // 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; diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 9f2d28bec..cfc80dcf1 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1305,9 +1305,6 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, return { entries, alsoWaiting: selHasActive }; }, [allMatches, effectiveCompId, running, filteredScheduled, courtKnown]); - // 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). // 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). @@ -1352,6 +1349,10 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, } 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 must not steal the panel). // `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 } = {}) => { @@ -1390,6 +1391,14 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, if (showToast) showToast(outcome.msg, "error"); return false; } + // A superseded start wrote nothing (a newer change to the match's + // result is already stored, and the refetch editMatchScore awaited + // shows what that change made of the match): the court is as it was. + // It is not a start that went out, so no refusal waiting on it is + // worded as "was being started", and a pick must not pin the panel on + // a match the server did not start. Nothing is toasted or stored for + // it, as before. + if (outcome.superseded) 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; diff --git a/web-mobile/js/start_match.jsx b/web-mobile/js/start_match.jsx index 024f6c1d3..23f140f40 100644 --- a/web-mobile/js/start_match.jsx +++ b/web-mobile/js/start_match.jsx @@ -27,7 +27,7 @@ // write_result.jsx and result_slot.jsx). Neither host's module is imported // here, and both import this one directly. -import { writeWasRefusedForClock, CLOCK_SKEW_REASON_TEXT } from './write_result.jsx'; +import { writeWasRefusedForClock, writeWasSuperseded, CLOCK_SKEW_REASON_TEXT } from './write_result.jsx'; import { GROUP_RESULT } from './match_groups.jsx'; // Minimal "start" patch (status -> running, empty score). Mirrors the editors' @@ -70,21 +70,29 @@ export const START_FAILED_MESSAGE = "Could not start the match: check eligibilit // 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; the -// court console always has), or came back without a -// body. +// on reconnect, so the host treats it as started), or +// came back without a body. +// { ok: true, superseded: true } +// the server held the start (applied:false, reason: +// superseded): a newer change to the match's result is +// already stored, nothing of the start was written and +// the court is as it was. There is no refusal to report +// (the live data the host re-reads shows whatever that +// change made of the match), but it is NOT a start that +// went out: a host that records "the start went out" +// (the console's `landed`, which words the past-tense +// notice on a row refused behind it) or pins its panel +// on the match must not, and the Scores tab, which keeps +// no such record, may carry on. // { ok: false, msg } the start was refused for the clock: nothing was // stored and nothing will land later, so a host that // called it started would pin a panel on a match that // never started while the tap looked like it worked. -// Only a clock refusal is a refusal here. A superseded answer (applied:false, -// reason: superseded) reads as started, as the console has always read it: a -// newer change to the match's result is already stored, and the live data the -// host re-reads shows whatever that change made of the match. // A start that THROWS (a 409) is not an answer at all; startFailureMessage // words it. export function classifyStartOutcome(res) { if (writeWasRefusedForClock(res)) return { ok: false, msg: START_CLOCK_SKEW_MESSAGE }; + if (writeWasSuperseded(res)) return { ok: true, superseded: true }; return { ok: true }; } From a1d220554f291d585b43b070766d4fd131c49b2c Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 22:15:03 +0100 Subject: [PATCH 36/38] fix: a superseded start is a refused start (PR #463 round 18) - A start the server answers as superseded (a newer change to the match is stored; nothing of the start was written) is now a refused start on the court console and the Scores tab alike: "Not started: a newer change to this match was recorded first. Check the match before starting it." is stored on the match and toasted once, a pick does not pin the panel, and the Scores tab no longer turns a waiting refusal into a false "was being started" notice. Before, it gave no feedback at all. - The post-write member gate and the judge read one selection of rows (judgedRows), and an engine-level test pins the invariant the gate relies on: the engine never lands a member id the judge would refuse that the payload did not introduce. - A rename carries a representative row's winner even when the row already reads the new name. - One first-row-at-position finder (state.SubResultAt). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- internal/engine/bracket_seat.go | 22 ++- internal/engine/bracket_seat_test.go | 22 +++ internal/engine/landed_member_ids_pin_test.go | 182 ++++++++++++++++++ internal/mobileapp/handlers_match.go | 96 ++++----- .../introduces_judged_member_test.go | 56 ++++++ internal/state/match_groups.go | 10 + internal/state/match_groups_table_test.go | 24 +++ ..._scores_auto_start_refusal.render.test.jsx | 66 +++++-- .../render/admin_shiaijo.render.test.jsx | 67 ++++++- web-mobile/js/__tests__/start_match.test.jsx | 38 +++- web-mobile/js/admin_schedule_score_editor.jsx | 26 ++- web-mobile/js/admin_shiaijo.jsx | 38 ++-- web-mobile/js/start_match.jsx | 48 +++-- 14 files changed, 574 insertions(+), 127 deletions(-) create mode 100644 internal/engine/landed_member_ids_pin_test.go diff --git a/CLAUDE.md b/CLAUDE.md index e97c4cc2d..ded71163f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,9 +175,9 @@ tournament-data/ - **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. 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 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 or for a start the server held as superseded (`classifyStartOutcome` flags it `superseded`: nothing was written, so the court is as it was and a pick does not pin the panel on that match). The refused match is never started on its own; if the blocking start fails instead, its own refusal stays. The Scores tab keeps no such record, so a superseded start there still opens the next match after a decision. 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. +- **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 (a newer change to the match is stored and nothing of the start was written). `classifyStartOutcome` answers both `{ok: false, msg}`, so both hosts take them as refused starts: the sentence (`START_CLOCK_SKEW_MESSAGE`, or `START_SUPERSEDED_MESSAGE`, "Not started: a newer change to this match was recorded first. Check the match before starting it.", 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 after a decision lands on that match with its notice. 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. 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; `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 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. @@ -331,7 +331,7 @@ 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 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): 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. 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. +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`: the only id fillers inside the write are `preserveKachinukiMemberIDs` and `ResolveMemberWinnerID`/`ReconcileWinnerMemberID`, while `MaybeAdvanceKachinuki`'s lineup ids are added after the transaction, in the handler). 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, 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; diff --git a/internal/engine/bracket_seat.go b/internal/engine/bracket_seat.go index e012fc215..fe9146d14 100644 --- a/internal/engine/bracket_seat.go +++ b/internal/engine/bracket_seat.go @@ -149,7 +149,8 @@ func seatedAnotherTeam(storedName, storedID, name, id string) bool { // 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. another +// 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 @@ -169,17 +170,22 @@ func nameRepBoutRow(bm *state.BracketMatch, row int, side domain.MatchSide, name return &r.SideB } old := *field(&bm.SubResults[row]) - if old == name { - return - } - subs := slices.Clone(bm.SubResults) - *field(&subs[row]) = name // 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. - if w := subs[row].Winner; !another && w != "" && (w == old || w == matchOld) { + // 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 diff --git a/internal/engine/bracket_seat_test.go b/internal/engine/bracket_seat_test.go index 04d99427d..035733bbc 100644 --- a/internal/engine/bracket_seat_test.go +++ b/internal/engine/bracket_seat_test.go @@ -596,6 +596,28 @@ func TestSeatBracketSide_ARenameCarriesTheRowsWinnerAndAnotherTeamDoesNot(t *tes 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) { 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..323850bfc --- /dev/null +++ b/internal/engine/landed_member_ids_pin_test.go @@ -0,0 +1,182 @@ +package engine + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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. +// These are PINS, green by design: they hold 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 only id fillers inside the write are preserveKachinukiMemberIDs (the +// stored row's id at the same position and name) and ResolveMemberWinnerID / +// ReconcileWinnerMemberID (the winner's id, derived from the row's own landed +// side ids, which the judge accepts). The lineup-sourced ids of +// MaybeAdvanceKachinuki are added AFTER the transaction, in the handler, and +// moving that step inside the write is exactly what these pins catch. The load +// repair (state.resolveSubMemberIDs) runs once per competition per process, not +// on the in-transaction read-back, and fills only a row's own team's members. +// +// 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) + } +} + +func TestEngineLandsNoMemberIDThePayloadDidNotIntroduce(t *testing.T) { + const ( + teamK = "team-k" + teamO = "team-o" + ) + 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"}, + } + } + fixedRows := func() []state.SubMatchResult { + return []state.SubMatchResult{ + {Position: 1, SideA: "K1", SideAMemberID: "k1", SideB: "O1", SideBMemberID: "o1", + IpponsA: []string{"M"}, Winner: "K1", WinnerMemberID: "k1", Decision: "fought"}, + {Position: state.DaihyosenSubPosition, SideA: "Kyoto", SideAMemberID: "k-rep", SideB: "Osaka", SideBMemberID: "o-rep", + Decision: "daihyosen"}, + } + } + // stripIDs is a payload 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 + } + + tests := []struct { + name string + comp state.Competition + stored func() []state.SubMatchResult + payload func() []state.SubMatchResult + // inherited are the (position, side A id, side B id) the landed rows must + // still hold, 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][2]string + }{ + { + name: "kachinuki: a write echoing the stored member ids", + comp: state.Competition{ID: "pin-k-echo", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki}, + stored: kachinukiRows, + payload: func() []state.SubMatchResult { + rows := kachinukiRows() + rows[1].IpponsB = []string{"K"} + rows[1].Winner, rows[1].WinnerMemberID, rows[1].Decision = "Ito", "o-ito", "fought" + return rows + }, + inherited: map[int][2]string{1: {"k-sato", "o-tanaka"}, 2: {"k-sato", "o-ito"}}, + }, + { + name: "kachinuki: a write whose rows carry names and no member ids, over a position the stored match lacks", + comp: state.Competition{ID: "pin-k-names", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki}, + stored: kachinukiRows, + payload: func() []state.SubMatchResult { + rows := stripIDs(kachinukiRows()) + rows[1].IpponsB = []string{"K"} + rows[1].Winner, rows[1].Decision = "Ito", "fought" + return append(rows, state.SubMatchResult{Position: 3, SideA: "Ota", SideB: "Ito"}) + }, + inherited: map[int][2]string{1: {"k-sato", "o-tanaka"}, 2: {"k-sato", "o-ito"}, 3: {"", ""}}, + }, + { + name: "fixed order with a representative bout: a write echoing the stored member ids", + comp: state.Competition{ID: "pin-f-echo", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed}, + stored: fixedRows, + payload: func() []state.SubMatchResult { + rows := fixedRows() + rows[0].IpponsA = []string{"M", "K"} + rows[1].IpponsA, rows[1].Winner = []string{"M"}, "Kyoto" + return rows + }, + inherited: map[int][2]string{1: {"k1", "o1"}, state.DaihyosenSubPosition: {"k-rep", "o-rep"}}, + }, + { + name: "fixed order with a representative bout: a write whose rows carry names and no member ids", + comp: state.Competition{ID: "pin-f-names", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed}, + stored: fixedRows, + payload: func() []state.SubMatchResult { + rows := stripIDs(fixedRows()) + rows[0].IpponsA = []string{"M", "K"} + rows[1].IpponsA, rows[1].Winner = []string{"M"}, "Kyoto" + return append(rows, state.SubMatchResult{Position: 2, SideA: "K2", SideB: "O2"}) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + comp := tt.comp + require.NoError(t, store.SaveCompetition(&comp)) + sideA, sideB := "Kyoto", "Osaka" + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "P1-1", SideA: sideA, SideAID: teamK, SideB: sideB, SideBID: teamO, + Status: state.MatchStatusRunning, SubResults: tt.stored(), + }})) + + payload := tt.payload() + // The write reshapes the rows it is handed in place, so what the + // payload carried is read from a copy made before it. + sent := slices.Clone(payload) + patch := &state.MatchResult{ + SideA: sideA, SideAID: teamK, SideB: sideB, SideBID: teamO, + Status: state.MatchStatusRunning, SubResults: payload, + } + require.NoError(t, store.WithTransaction(comp.ID, func(tx state.StoreTx) error { + _, terr := eng.RecordMatchResultWithIneligibilityTx(tx, comp.ID, "P1-1", patch) + return terr + })) + + landed := loadPoolMatchByID(t, store, comp.ID, "P1-1") + require.NotNil(t, landed) + require.NotEmpty(t, landed.SubResults) + assertNoIntroducedMemberID(t, tt.stored(), sent, landed.SubResults) + + for position, ids := range tt.inherited { + row := state.SubResultAt(landed.SubResults, position) + assert.Equal(t, ids, [2]string{row.SideAMemberID, row.SideBMemberID}, + "position %d: the ids the write was meant to carry over are still there", position) + } + }) + } +} diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 7444f3ebf..c3e093d2f 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2152,11 +2152,12 @@ func (e *landedWriteRefusal) Unwrap() error { return e.refusal } // whether a score write, as the judges before the engine leave it (subs), // introduces a member id on a row that judge looks at, against the match as it // stood before the write (before, the snapshot read at the top of it). Those -// rows are the representative bout's, for anyone's write (either side's pick, -// against before.RepBout, the row the judge compares it with), and with numbered -// a participant's numbered rows (either fighter and the winner, against the -// stored row at the same Position). A position the stored match lacks holds no -// id, so every id its row carries is an introduction. +// rows are judgedRows', the selection the judge (membersOutsideTeams) reads too: +// the representative bout's, for anyone's write (either side's pick, against +// before.RepBout, the row the judge compares it with), and with numbered a +// participant's numbered rows (either fighter and the winner, against the stored +// row at the same Position). A position the stored match lacks holds no id, so +// every id its row carries is an introduction. // // The invariant that makes the gate equivalent to judging every write: the // engine never lands a member id the judge would refuse that the payload did @@ -2174,30 +2175,50 @@ func (e *landedWriteRefusal) Unwrap() error { return e.refusal } // side id over an id-less stored side lands (reconcileSides), so a gate keyed on // the stored team ids would skip exactly that write. func introducesJudgedMember(subs []state.SubMatchResult, before matchSnapshot, numbered bool) bool { + for _, j := range judgedRows(subs, before, numbered) { + if introducesFighter(j.row, j.stored) || (!j.rep && introducesWinnerMember(j.row, j.stored)) { + return true + } + } + return false +} + +// judgedRow is a row the member judge looks at with the stored row it compares +// the row with, and whether it is the representative bout's. +type judgedRow struct { + row, stored state.SubMatchResult + rep bool +} + +// judgedRows is the ONE selection of what the member judge looks at, read by the +// gate (introducesJudgedMember, on the payload's rows) and by the judge +// (membersOutsideTeams, on the landed rows), so a row added to one is added to +// the other. Per row, not per field: the winner check needs the row's two side +// ids with the stored row's as its fallback. Every representative-bout row is +// judged for anyone, against before.RepBout (the row the judge compares a pick +// with, the empty row when the match has none); with numbered, a participant's +// write, every other row too, against the stored row at its Position +// (state.SubResultAt: a position the stored match lacks holds no id, so every id +// its row carries is an introduction). Rows come in the order given. +func judgedRows(subs []state.SubMatchResult, before matchSnapshot, numbered bool) []judgedRow { var storedRows []state.SubMatchResult if before.Stored != nil { storedRows = before.Stored.SubResults } + var out []judgedRow for _, row := range subs { - if row.Position == state.DaihyosenSubPosition { + switch { + case row.Position == state.DaihyosenSubPosition: var stored state.SubMatchResult if before.RepBout != nil { stored = *before.RepBout } - if introducesFighter(row, stored) { - return true - } - continue - } - if !numbered { - continue - } - stored := boutAt(storedRows, row.Position) - if introducesFighter(row, stored) || introducesWinnerMember(row, stored) { - return true + out = append(out, judgedRow{row: row, stored: stored, rep: true}) + case numbered: + out = append(out, judgedRow{row: row, stored: state.SubResultAt(storedRows, row.Position)}) } } - return false + return out } // introducesFighter reports whether a row puts a member id on either side that @@ -2207,17 +2228,6 @@ func introducesFighter(row, stored state.SubMatchResult) bool { introducesID(row.SideBMemberID, stored.SideBMemberID) } -// boutAt returns the row of subs at position, the empty row when there is none: -// a position a match lacks holds no member id. -func boutAt(subs []state.SubMatchResult, position int) state.SubMatchResult { - for _, s := range subs { - if s.Position == position { - return s - } - } - return state.SubMatchResult{} -} - // landedMembersRefusal judges who a score write SEATS by what it landed, read // back through stx after the engine wrote it (a StoreTx reads its own staged // bytes), against the match as it stood before (before, the snapshot read once @@ -2271,29 +2281,21 @@ func landedMembersRefusal(stx state.StoreTx, compID, matchID string, before matc } // membersOutsideTeams is landedMembersRefusal's judgement: it answers the first -// refusal, an error of another kind being a read that failed. +// refusal, an error of another kind being a read that failed. The rows it +// judges are judgedRows' over the landed rows, the same selection the gate +// (introducesJudgedMember) makes over the payload's. func membersOutsideTeams(judge *memberJudge, before, after matchSnapshot, numbered bool) error { - pairing := after.Pairing - if numbered { - for _, row := range after.Stored.SubResults { - if row.Position == state.DaihyosenSubPosition { - continue - } - stored := boutAt(before.Stored.SubResults, row.Position) - if err := winnerMemberOutsideBout(row, stored); err != nil { - return err - } - if err := rowMembersOutsideTeams(judge, pairing, row, stored, errBoutFighterNotInTeam); err != nil { + for _, j := range judgedRows(after.Stored.SubResults, before, numbered) { + refusal := errRepMemberNotInTeam + if !j.rep { + if err := winnerMemberOutsideBout(j.row, j.stored); err != nil { return err } + refusal = errBoutFighterNotInTeam } - } - if i := state.DaihyosenSubIndex(after.Stored.SubResults); i >= 0 { - var stored state.SubMatchResult - if before.RepBout != nil { - stored = *before.RepBout + if err := rowMembersOutsideTeams(judge, after.Pairing, j.row, j.stored, refusal); err != nil { + return err } - return rowMembersOutsideTeams(judge, pairing, after.Stored.SubResults[i], stored, errRepMemberNotInTeam) } return nil } diff --git a/internal/mobileapp/introduces_judged_member_test.go b/internal/mobileapp/introduces_judged_member_test.go index 972ee23c6..18b07c571 100644 --- a/internal/mobileapp/introduces_judged_member_test.go +++ b/internal/mobileapp/introduces_judged_member_test.go @@ -13,6 +13,7 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/gitrgoliveira/bracket-creator/internal/state" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIntroducesJudgedMember(t *testing.T) { @@ -149,3 +150,58 @@ func TestIntroducesJudgedMember(t *testing.T) { }) } } + +// TestJudgedRows pins the ONE selection the gate (introducesJudgedMember, on the +// payload's rows) and the judge (membersOutsideTeams, on the landed rows) both +// read: which rows are judged, and against which stored row. Each used to walk +// the rows and choose for itself, tied only by a comment, so a row or a field +// added to one was silently missing from the other. +func TestJudgedRows(t *testing.T) { + t.Parallel() + + rep := func(a, b string) state.SubMatchResult { + return state.SubMatchResult{Position: state.DaihyosenSubPosition, SideAMemberID: a, SideBMemberID: b} + } + bout := func(position int, a, b string) state.SubMatchResult { + return state.SubMatchResult{Position: position, SideAMemberID: a, SideBMemberID: b} + } + storedRep := rep("sa", "sb") + before := matchSnapshot{ + RepBout: &storedRep, + Stored: &state.MatchResult{SubResults: []state.SubMatchResult{ + bout(1, "m1a", "m1b"), bout(2, "m2a", "m2b"), storedRep, + }}, + } + + t.Run("the representative bout is judged for anyone, against the stored representative row", func(t *testing.T) { + got := judgedRows([]state.SubMatchResult{bout(1, "x", "y"), rep("p", "q")}, before, false) + require.Len(t, got, 1, "a numbered row is left out unless numbered") + assert.True(t, got[0].rep) + assert.Equal(t, rep("p", "q"), got[0].row) + assert.Equal(t, storedRep, got[0].stored) + }) + + t.Run("a match with no stored representative row compares the pick with the empty row", func(t *testing.T) { + got := judgedRows([]state.SubMatchResult{rep("p", "")}, matchSnapshot{}, false) + require.Len(t, got, 1) + assert.True(t, got[0].rep) + assert.Equal(t, state.SubMatchResult{}, got[0].stored) + }) + + t.Run("numbered rows are judged for a participant, each against the stored row at its position", func(t *testing.T) { + got := judgedRows([]state.SubMatchResult{bout(2, "x", "y"), rep("p", "q"), bout(9, "z", "")}, before, true) + require.Len(t, got, 3, "rows come in the order they were given") + assert.False(t, got[0].rep) + assert.Equal(t, bout(2, "m2a", "m2b"), got[0].stored) + assert.True(t, got[1].rep) + assert.Equal(t, storedRep, got[1].stored) + assert.False(t, got[2].rep) + assert.Equal(t, state.SubMatchResult{}, got[2].stored, "a position the stored match lacks holds no id") + }) + + t.Run("a snapshot with no stored match judges every numbered row against the empty row", func(t *testing.T) { + got := judgedRows([]state.SubMatchResult{bout(1, "x", "")}, matchSnapshot{}, true) + require.Len(t, got, 1) + assert.Equal(t, state.SubMatchResult{}, got[0].stored) + }) +} diff --git a/internal/state/match_groups.go b/internal/state/match_groups.go index 31b5d31f1..8b9f776e4 100644 --- a/internal/state/match_groups.go +++ b/internal/state/match_groups.go @@ -150,6 +150,16 @@ func subAt(subs []SubMatchResult, position int) *SubMatchResult { return nil } +// SubResultAt is subAt as a value: a copy of the first bout row at position, +// the empty row when subs has none (a position a match lacks holds no member id, +// which is what a caller comparing a row with the stored one wants). +func SubResultAt(subs []SubMatchResult, position int) SubMatchResult { + if row := subAt(subs, position); row != nil { + return *row + } + return SubMatchResult{} +} + // repPicksOf returns the two representatives the row at DaihyosenSubPosition // of subs names, empty when there is no such row. func repPicksOf(subs []SubMatchResult) (sideA, sideB string) { diff --git a/internal/state/match_groups_table_test.go b/internal/state/match_groups_table_test.go index 1f76e1e95..045262f09 100644 --- a/internal/state/match_groups_table_test.go +++ b/internal/state/match_groups_table_test.go @@ -291,3 +291,27 @@ func TestMaterializedGroupStamps_RepPicksOnlyWithARepresentativeRow(t *testing.T assert.NotContains(t, m.GroupStamps, GroupRepPickB, "and only that side") }) } + +// TestSubResultAt_IsTheFirstRowAtThePositionAsAValue pins what the self-run +// member judge relies on when it compares a landed row with the stored one at +// the same position: the FIRST row at the position, copied (the caller reads +// it, never edits the stored list), and the empty row when the match lacks the +// position, since a position a match lacks holds no member id. +func TestSubResultAt_IsTheFirstRowAtThePositionAsAValue(t *testing.T) { + t.Parallel() + + subs := []SubMatchResult{ + {Position: 1, SideAMemberID: "first"}, + {Position: DaihyosenSubPosition, SideAMemberID: "rep"}, + {Position: 1, SideAMemberID: "second"}, + } + + assert.Equal(t, "first", SubResultAt(subs, 1).SideAMemberID, "the first row at a position wins") + assert.Equal(t, "rep", SubResultAt(subs, DaihyosenSubPosition).SideAMemberID) + assert.Equal(t, SubMatchResult{}, SubResultAt(subs, 7), "a position the match lacks is the empty row") + assert.Equal(t, SubMatchResult{}, SubResultAt(nil, 1)) + + got := SubResultAt(subs, 1) + got.SideAMemberID = "edited" + assert.Equal(t, "first", subs[0].SideAMemberID, "the answer is a copy: editing it leaves the stored list alone") +} diff --git a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx index ada175bd7..c0b00430e 100644 --- a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx @@ -16,6 +16,7 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } import { installWindowStubs } from '../helpers/stub_globals.js'; import { CLOCK_SKEW_REASON_TEXT, startWhileStartingMessage, startWasBlockedByStartMessage, markToasted } from '../../write_result.jsx'; import { scoreRowMatchName } from '../../pool_ids.jsx'; +import { START_SUPERSEDED_MESSAGE } from '../../start_match.jsx'; const side = (id, name) => ({ id, name }); @@ -171,13 +172,13 @@ describe('Finish + Start Next on the Scores tab says why the next match did not expect(showToast).not.toHaveBeenCalled(); }); - // PIN (green by design). A superseded start (HTTP 200 applied:false, reason - // superseded) wrote nothing, but it is not a refusal either: this page keeps no - // "the start went out" record (the court console's `landed` words a past-tense - // notice from one), so nothing here depends on telling it from a landed start. - // The operator lands on the next match in pre-match, still scheduled with its - // Start button, which is where they want to be. - it('a superseded start raises no notice and no toast, and the editor lands on the next match', async () => { + // PR #463 round 18 (this REPLACES the round-17 pin that asserted "no notice and + // no toast", which encoded the opposite rule). A superseded start (HTTP 200 + // applied:false, reason superseded) wrote nothing: a newer change to the match + // is stored. It is a REFUSED start, through the same path a thrown or + // clock_skew refusal takes: the sentence is stored on the refused match's row, + // toasted once, and the editor lands on that match in pre-match. + it('a superseded start is a refused start: its notice on the next match\'s row, one toast, the editor on that match', async () => { const onEditScore = vi.fn(async (_c, id, patch) => (id === 'm-2' && patch.startOnly ? { applied: false, reason: 'superseded' } : { status: 'ok' })); @@ -187,8 +188,12 @@ describe('Finish + Start Next on the Scores tab says why the next match did not await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-1', 'm-2']); - expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); - expect(showToast).not.toHaveBeenCalled(); + const note = noticeIn(rowOf(utils, 'Alice')); + expect(note, 'the refused match says why on its own row').toBeTruthy(); + expect(note.textContent).toBe(START_SUPERSEDED_MESSAGE); + expect(note.getAttribute('role')).toBe('status'); + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(1); + expect(showToast.mock.calls).toEqual([[START_SUPERSEDED_MESSAGE, 'error']]); expect(probe.props.match.id).toBe('m-2'); expect(probe.props.match.status).toBe('scheduled'); }); @@ -261,6 +266,33 @@ describe('a start that was waited for and then refused', () => { expect(noticeIn(rowOf(utils, 'Alice'))?.textContent, 'the failed start keeps its own refusal').toBe(WITHDREW); expect(noticeIn(rowOf(utils, 'Carol')), 'the court is free: no past-tense notice').toBeFalsy(); }); + + // PR #463 round 18: a SUPERSEDED start is a start that wrote nothing, so it + // ends like the thrown one above: the court is free, "was being started" would + // be false, and its own refusal (the superseded sentence) is stored for it, + // which replaces the refusal that waited on it (refuseStart keeps a single + // slot and clears `waitingOn`, so the finally has nothing left to rewrite). + it('a start that is superseded keeps its own refusal on its row, and the waiting row gets no past-tense notice', async () => { + let releaseStart; + const onEditScore = vi.fn((_c, id, patch) => { + if (id === 'm-2' && patch.startOnly) return new Promise((resolve) => { releaseStart = resolve; }); + return Promise.resolve({ status: 'ok' }); + }); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { probe.props.onSubmitAndNext({ status: 'completed' }); }); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + expect(noticeIn(rowOf(utils, 'Carol'))?.textContent).toBe(startWhileStartingMessage({ label: scoreRowMatchName(M2) })); + + await act(async () => { releaseStart({ applied: false, reason: 'superseded' }); }); + expect(noticeIn(rowOf(utils, 'Alice'))?.textContent, 'the superseded start keeps its own refusal').toBe(START_SUPERSEDED_MESSAGE); + expect(noticeIn(rowOf(utils, 'Carol')), 'nothing was started: no past-tense notice on the waiting row').toBeFalsy(); + expect(utils.container.textContent).not.toContain('was being started'); + expect(onEditScore.mock.calls.filter(isStart).map((c) => c[1]), 'and m-3 was not started on its own').toEqual(['m-2']); + expect(showToast.mock.calls.map((c) => c[0])).toEqual([startWhileStartingMessage({ label: scoreRowMatchName(M2) }), START_SUPERSEDED_MESSAGE]); + expect(probe.props.match.id, 'the editor lands on the match that did not start').toBe('m-2'); + }); }); describe('the start after a decision says why too', () => { @@ -293,10 +325,11 @@ describe('the start after a decision says why too', () => { expect(probe.props.match.id).toBe('m-2'); }); - // PIN (green by design): see the superseded case above. onAfterDecision is the - // one place this page reads startNext's answer, to open the next match, and a - // superseded start still opens it. - it('a superseded start after a decision still opens the next match, with no notice', async () => { + // See the superseded case above (round 18: it replaces the round-17 pin that + // asserted no notice and no toast). refuseStart opens the refused match, so + // onAfterDecision lands on m-2 either way; what changed is that the operator is + // told why it is still scheduled. + it('a superseded start after a decision lands on the next match with its notice and one toast', async () => { const onEditScore = vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }); const showToast = vi.fn(); const utils = await mountAndOpenRunning(onEditScore, showToast); @@ -304,8 +337,11 @@ describe('the start after a decision says why too', () => { await act(async () => { await probe.props.onAfterDecision({ winner: side('m-1-a', 'Yamada') }); }); expect(onEditScore).toHaveBeenCalledTimes(1); - expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); - expect(showToast).not.toHaveBeenCalled(); + const note = noticeIn(rowOf(utils, 'Alice')); + expect(note, 'the decision path shows the refusal too').toBeTruthy(); + expect(note.textContent).toBe(START_SUPERSEDED_MESSAGE); + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(1); + expect(showToast.mock.calls).toEqual([[START_SUPERSEDED_MESSAGE, 'error']]); expect(probe.props.match.id).toBe('m-2'); }); }); diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 344b60c02..a9c535029 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } import { installWindowStubs } from '../helpers/stub_globals.js'; import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, startWhileCorrectingMessage, startWhileStartingMessage, correctWhileRunningMessage, CLOCK_SKEW_REASON_TEXT, markToasted, startWasBlockedByStartMessage } from '../../write_result.jsx'; import { scoreRowMatchName } from '../../pool_ids.jsx'; +import { START_SUPERSEDED_MESSAGE } from '../../start_match.jsx'; // Window globals required by admin_shiaijo.jsx. // MODULE-EVAL-TIME entries (e.g. `const AdminTopbar = window.AdminTopbar;`) // must be set before the dynamic import, or the module captures undefined. @@ -2181,9 +2182,12 @@ describe('a Start tapped while another start is still out is refused before anyt }); // A superseded start is HTTP 200 {applied:false, reason:'superseded'}: a newer - // change to the match's result is already stored, nothing of the start was - // written and the court is as it was. "m3 was being started" would be false, - // and the pick must not pin the panel on a match the server did not start. + // change to the match's result is already stored and nothing of the start was + // written. For a PICK that is not "as it was": the running bout was sent back + // to the queue before the start, so the court is IDLE (and m3 shows the + // superseded refusal like any refused start; see the describe below). "m3 was + // being started" would be false, and the pick must not pin the panel on a + // match the server did not start. const supersededStartOfM3 = () => vi.fn((_compId, matchId) => Promise.resolve( matchId === 'm3' ? { applied: false, reason: 'superseded' } : { applied: true } )); @@ -2424,6 +2428,63 @@ describe('the console toasts a thrown Start refusal once', () => { }); }); +// PR #463 round 18: a Start the server answered SUPERSEDED (HTTP 200 +// {applied:false, reason:'superseded'}: a newer change to the match is stored, +// nothing of the start was written) is a REFUSED start on the console, through +// the path a thrown or clock_skew refusal takes: the sentence is stored on the +// refused match (its Up next card or its queue row, shown while it is still +// scheduled), toasted once, and the panel is not pinned on a match the server +// did not start. Before this it said nothing at all. +describe('the console reports a superseded Start as a refused one', () => { + const startButton = (el) => [...el.querySelectorAll('button')].find((b) => /^start/i.test(b.textContent.trim())); + const rowOf = (c, text) => [...c.utils.container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes(text)); + const mountSuperseded = () => mountCourt( + [courtMatch('m1', 'scheduled'), courtMatch('m2', 'scheduled')], + { onEditScore: vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }) }, + ); + + it('the Up next card: the superseded sentence on the card, one toast, the panel not pinned', async () => { + const c = await mountSuperseded(); + try { + const card = c.utils.container.querySelector('.shiaijo-upnext__card'); + await act(async () => { startButton(card).click(); }); + await act(async () => {}); + expect(card.querySelector('.shiaijo-upnext__error[role="alert"]')?.textContent).toBe(START_SUPERSEDED_MESSAGE); + expect(c.showToast.mock.calls).toEqual([[START_SUPERSEDED_MESSAGE, 'error']]); + expect(c.editorMatch(), 'nothing was started: the panel is not pinned on m1').toBeNull(); + } finally { c.restore(); } + }); + + it('a queue row: the superseded sentence on that row, one toast, the panel not pinned', async () => { + const c = await mountSuperseded(); + try { + await act(async () => { startButton(rowOf(c, 'Aka m2')).click(); }); + await act(async () => {}); + expect(rowOf(c, 'Aka m2').querySelector('.shiaijo-upnext__error[role="alert"]')?.textContent).toBe(START_SUPERSEDED_MESSAGE); + expect(c.showToast.mock.calls).toEqual([[START_SUPERSEDED_MESSAGE, 'error']]); + expect(c.editorMatch()).toBeNull(); + } finally { c.restore(); } + }); + + // The notice is bound to the match like every stored Start refusal: when the + // other device's change that superseded the start leaves the match running (it + // started there), the notice drops (startRefusalStands) and the toast was the + // only thing the operator saw. + it('the notice goes when the match is no longer scheduled in the live data', async () => { + const c = await mountSuperseded(); + try { + const card = c.utils.container.querySelector('.shiaijo-upnext__card'); + await act(async () => { startButton(card).click(); }); + await act(async () => {}); + expect(c.utils.container.textContent).toContain(START_SUPERSEDED_MESSAGE); + c.feed.current = [courtMatch('m1', 'running'), courtMatch('m2', 'scheduled')]; + await c.refresh(); + await act(async () => {}); + expect(c.utils.container.textContent).not.toContain(START_SUPERSEDED_MESSAGE); + } finally { c.restore(); } + }); +}); + // PR #463 batch 14, item 4 (bc-aadv): a stored Start refusal belongs to the match // it was refused for. It stays on that match's card or queue row for as long as // the match is still scheduled on this court, and goes when the match leaves diff --git a/web-mobile/js/__tests__/start_match.test.jsx b/web-mobile/js/__tests__/start_match.test.jsx index 5ea1642ea..40760c6c4 100644 --- a/web-mobile/js/__tests__/start_match.test.jsx +++ b/web-mobile/js/__tests__/start_match.test.jsx @@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest'; import { CLOCK_SKEW_REASON_TEXT } from '../write_result.jsx'; import { classifyStartOutcome, startFailureMessage, createStartGuard, startPatch, startRefusalStands, - START_CLOCK_SKEW_MESSAGE, START_FAILED_MESSAGE, + START_CLOCK_SKEW_MESSAGE, START_FAILED_MESSAGE, START_SUPERSEDED_MESSAGE, } from '../start_match.jsx'; describe('classifyStartOutcome', () => { @@ -31,15 +31,27 @@ describe('classifyStartOutcome', () => { expect(classifyStartOutcome(null)).toEqual({ ok: true }); }); - it('a superseded start is not a clock refusal to report, but it is not a start that went out either', () => { - // Nothing of it was written: a newer change to the match's result is - // already stored, so a host that records "the start went out" must not. - expect(classifyStartOutcome({ applied: false, reason: 'superseded' })).toEqual({ ok: true, superseded: true }); + it('a superseded start is a refused start, in its own words: nothing was written and nothing will land later', () => { + // A newer change to the match's result is already stored. The start is + // refused like a clock_skew one (a stored notice and a toast on both + // hosts), not waved through: a host that took it for a start that went + // out would pin a panel on a match the server did not start, say nothing + // to the operator, and word a past-tense "was being started" for a start + // that wrote nothing (PR #463 round 18). + const out = classifyStartOutcome({ applied: false, reason: 'superseded' }); + expect(out.ok).toBe(false); + expect(out.msg).toBe(START_SUPERSEDED_MESSAGE); + expect(out.msg).toBe('Not started: a newer change to this match was recorded first. Check the match before starting it.'); + }); + + it('a superseded answer that also holds groups (heldGroups) is the same refusal', () => { + expect(classifyStartOutcome({ applied: false, reason: 'superseded', heldGroups: ['result'] })) + .toEqual({ ok: false, msg: START_SUPERSEDED_MESSAGE }); }); - it('only a superseded answer carries the flag: landed, queued and body-less starts did go out', () => { + it('only a superseded or clock_skew answer is a refusal: landed, queued and body-less starts did go out', () => { for (const res of [{ applied: true }, { queued: true }, { status: 'ok' }, undefined, null]) { - expect(classifyStartOutcome(res).superseded, JSON.stringify(res)).toBeUndefined(); + expect(classifyStartOutcome(res).ok, JSON.stringify(res)).toBe(true); } }); }); @@ -59,10 +71,20 @@ describe('startFailureMessage', () => { describe('the start copy follows the house words', () => { it('carries no em-dash and never says mat', () => { - const all = START_CLOCK_SKEW_MESSAGE + START_FAILED_MESSAGE; + const all = START_CLOCK_SKEW_MESSAGE + START_FAILED_MESSAGE + START_SUPERSEDED_MESSAGE; expect(all).not.toMatch(/—/); expect(all).not.toMatch(/\bmats?\b/i); }); + + // The two refusals give opposite advice: after a clock refusal the clock has + // been resynced and a second tap normally succeeds, but a superseded start + // means a NEWER change to the match is stored, and starting it again blind + // is what the sentence must not invite. + it('the superseded sentence never says "try again": it sends the operator to the match', () => { + expect(START_SUPERSEDED_MESSAGE).not.toMatch(/try again/i); + expect(START_SUPERSEDED_MESSAGE).toMatch(/Check the match/); + expect(START_CLOCK_SKEW_MESSAGE).toMatch(/try again/); + }); }); describe('createStartGuard', () => { diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx index ec42150fe..a26f570dd 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -184,7 +184,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // `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 answer, "still being started") + // refusal only this page sees (a clock_skew or superseded answer, "still being started") // carries no mark and is toasted here, once. const refuseStart = (next, msg, waitingOn = null, toasted = false) => { if (mountedRef.current) { @@ -198,11 +198,15 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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 (classifyStartOutcome: nothing written, a - // newer change to the match's result is stored) also resolves true, on purpose: - // the court console records "the start went out" and words a past-tense notice - // from it, so it must tell the two apart; this page keeps no such record, and - // the answer only decides whether the decision flow opens the next match, which - // is right either way (still scheduled, with its Start button). + // newer change to the match's 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. // Every start-gating rule is the server's (StartMatchTx), so a thrown 409 only // needs reporting. const startNext = async (next) => { @@ -234,6 +238,11 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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 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 } @@ -575,8 +584,9 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // (honest to the label). If the // next match is already running/completed, just open it. Start // gating runs server-side (StartMatchTx): startNext reports a - // refusal (a thrown 409, or a clock_skew answer) on that match's - // row and by toast, and the operator stays on it in pre-match to + // refusal (a thrown 409, or a clock_skew or superseded answer) on + // that match's row and by toast, and the operator stays on it in + // pre-match to // resolve the issue. The live lookup shows a started match: // editMatchScore awaits its refresh. setOpenKey(scoreKeyOf(nextActiveMatch)); diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index cfc80dcf1..4f4503dd5 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1326,8 +1326,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // // 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) is what the operator needs. It is stored for the blocker before this - // runs (startMatch's catch, ahead of its finally), and startMatch clears + // or superseded answer) 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. @@ -1377,28 +1378,29 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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. + // A clock_skew or a superseded answer 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). + // 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 + // 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; } - // A superseded start wrote nothing (a newer change to the match's - // result is already stored, and the refetch editMatchScore awaited - // shows what that change made of the match): the court is as it was. - // It is not a start that went out, so no refusal waiting on it is - // worded as "was being started", and a pick must not pin the panel on - // a match the server did not start. Nothing is toasted or stored for - // it, as before. - if (outcome.superseded) 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; diff --git a/web-mobile/js/start_match.jsx b/web-mobile/js/start_match.jsx index 23f140f40..4c3b49204 100644 --- a/web-mobile/js/start_match.jsx +++ b/web-mobile/js/start_match.jsx @@ -65,6 +65,16 @@ export function startPatch() { // 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 (HTTP 200 applied:false, +// reason superseded): a newer change to this match is already stored, so nothing +// of the start was written. 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. +// It is the same sentence whether the newer change was a held write or another +// device's start that landed first (the row then reads running after the refetch, +// the stored notice drops, startRefusalStands, and only the toast is seen). +export const START_SUPERSEDED_MESSAGE = "Not started: a newer change to this match was recorded first. Check the match before starting it."; + // 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."; @@ -72,27 +82,31 @@ export const START_FAILED_MESSAGE = "Could not start the match: check eligibilit // { 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. -// { ok: true, superseded: true } -// the server held the start (applied:false, reason: -// superseded): a newer change to the match's result is -// already stored, nothing of the start was written and -// the court is as it was. There is no refusal to report -// (the live data the host re-reads shows whatever that -// change made of the match), but it is NOT a start that -// went out: a host that records "the start went out" -// (the console's `landed`, which words the past-tense -// notice on a row refused behind it) or pins its panel -// on the match must not, and the Scores tab, which keeps -// no such record, may carry on. -// { ok: false, msg } the start was refused for the clock: nothing was -// stored and nothing will land later, so a host that -// called it started would pin a panel on a match that -// never started while the tap looked like it worked. +// { 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: a newer change to the match 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. Held as the same refusal whether +// or not groups were held (an echo-hold has empty +// heldGroups): when another device's start landed +// first the row reads running after the refetch, the +// stored notice drops (startRefusalStands) and only +// the toast is seen, which is still true. +// 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. export function classifyStartOutcome(res) { if (writeWasRefusedForClock(res)) return { ok: false, msg: START_CLOCK_SKEW_MESSAGE }; - if (writeWasSuperseded(res)) return { ok: true, superseded: true }; + if (writeWasSuperseded(res)) return { ok: false, msg: START_SUPERSEDED_MESSAGE }; return { ok: true }; } From a9b8946b4cbb571955a094a92eb9808216660421 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 22:39:44 +0100 Subject: [PATCH 37/38] docs(CLAUDE.md): every member id the write lands unasked is a stored one The post-write member gate's invariant listed only two id fillers; the merge's copies of held groups and keepQueuedScore land stored ids too. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ded71163f..f5c653254 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -331,7 +331,7 @@ 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 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`: the only id fillers inside the write are `preserveKachinukiMemberIDs` and `ResolveMemberWinnerID`/`ReconcileWinnerMemberID`, while `MaybeAdvanceKachinuki`'s lineup ids are added after the transaction, in the handler). 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. +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`: 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 held groups through `CopyGroup`/`SetRepPick`, `keepQueuedScore` on a start) or a winner id derived from the row's own landed side ids (`ResolveMemberWinnerID`/`ReconcileWinnerMemberID`), while `MaybeAdvanceKachinuki`'s lineup ids are added after the transaction, in the handler). 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, 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; From 5b0c7d419d07f63b9fd425ca923763b9ff4e2a22 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sat, 10 Oct 2026 23:08:12 +0100 Subject: [PATCH 38/38] fix: an echo of another device's start is a start that went out (PR #463 round 19) - A start the server holds only as an echo (superseded with no heldGroups: another device's start of the same match landed first, the match is running) now counts as a start that went out on the court console and the Scores tab: no "Not started" sentence or toast, a pick pins the panel, a refusal waiting on it reads in the past tense. A start superseded by a real newer change (heldGroups names result) stays a refused start. - That refusal's sentence is composed by notSavedText from write_result.jsx's SUPERSEDED_REASON and SUPERSEDED_ADVICE under the "Not started" lead, instead of a second hand-written superseded copy. - The engine pin test now runs kachinuki bouts on both store branches and the representative bout on a knockout match, with stamped and older-stamped writes (held groups copied back) and saved lineups and team members whose ids no row carries; mutation-checked against every stored-id copy path. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- internal/engine/landed_member_ids_pin_test.go | 363 +++++++++++++----- ..._scores_auto_start_refusal.render.test.jsx | 54 ++- .../render/admin_shiaijo.render.test.jsx | 85 +++- web-mobile/js/__tests__/start_match.test.jsx | 43 ++- web-mobile/js/admin_schedule_score_editor.jsx | 18 +- web-mobile/js/admin_shiaijo.jsx | 24 +- web-mobile/js/start_match.jsx | 76 ++-- 8 files changed, 503 insertions(+), 164 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5c653254..3eb098791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ tournament-data/ - **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. 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 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 (a newer change to the match is stored and nothing of the start was written). `classifyStartOutcome` answers both `{ok: false, msg}`, so both hosts take them as refused starts: the sentence (`START_CLOCK_SKEW_MESSAGE`, or `START_SUPERSEDED_MESSAGE`, "Not started: a newer change to this match was recorded first. Check the match before starting it.", 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 after a decision lands on that match with its notice. 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. +- **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. 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. @@ -331,7 +331,7 @@ 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 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`: 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 held groups through `CopyGroup`/`SetRepPick`, `keepQueuedScore` on a start) or a winner id derived from the row's own landed side ids (`ResolveMemberWinnerID`/`ReconcileWinnerMemberID`), while `MaybeAdvanceKachinuki`'s lineup ids are added after the transaction, in the handler). 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. +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, 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; diff --git a/internal/engine/landed_member_ids_pin_test.go b/internal/engine/landed_member_ids_pin_test.go index 323850bfc..f2abd86e4 100644 --- a/internal/engine/landed_member_ids_pin_test.go +++ b/internal/engine/landed_member_ids_pin_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/gitrgoliveira/bracket-creator/internal/state" ) @@ -14,18 +15,35 @@ import ( // 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. -// These are PINS, green by design: they hold the invariant from this side, so a +// 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 only id fillers inside the write are preserveKachinukiMemberIDs (the -// stored row's id at the same position and name) and ResolveMemberWinnerID / +// 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). The lineup-sourced ids of -// MaybeAdvanceKachinuki are added AFTER the transaction, in the handler, and -// moving that step inside the write is exactly what these pins catch. The load -// repair (state.resolveSubMemberIDs) runs once per competition per process, not -// on the in-transaction read-back, and fills only a row's own team's members. +// 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 @@ -55,11 +73,104 @@ func assertNoIntroducedMemberID(t *testing.T, stored, payload, landed []state.Su } } +// 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) { - const ( - teamK = "team-k" - teamO = "team-o" - ) + // 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", @@ -67,15 +178,34 @@ func TestEngineLandsNoMemberIDThePayloadDidNotIntroduce(t *testing.T) { {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: "K1", SideAMemberID: "k1", SideB: "O1", SideBMemberID: "o1", - IpponsA: []string{"M"}, Winner: "K1", WinnerMemberID: "k1", Decision: "fought"}, - {Position: state.DaihyosenSubPosition, SideA: "Kyoto", SideAMemberID: "k-rep", SideB: "Osaka", SideBMemberID: "o-rep", - Decision: "daihyosen"}, + {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 payload that names its fighters and sends no member id. + // 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 { @@ -84,98 +214,141 @@ func TestEngineLandsNoMemberIDThePayloadDidNotIntroduce(t *testing.T) { } 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 - comp state.Competition - stored func() []state.SubMatchResult - payload func() []state.SubMatchResult - // inherited are the (position, side A id, side B id) the landed rows must - // still hold, 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][2]string + 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", - comp: state.Competition{ID: "pin-k-echo", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki}, - stored: kachinukiRows, - payload: func() []state.SubMatchResult { - rows := kachinukiRows() - rows[1].IpponsB = []string{"K"} - rows[1].Winner, rows[1].WinnerMemberID, rows[1].Decision = "Ito", "o-ito", "fought" - return rows - }, - inherited: map[int][2]string{1: {"k-sato", "o-tanaka"}, 2: {"k-sato", "o-ito"}}, + 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", - comp: state.Competition{ID: "pin-k-names", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki}, - stored: kachinukiRows, - payload: func() []state.SubMatchResult { - rows := stripIDs(kachinukiRows()) - rows[1].IpponsB = []string{"K"} - rows[1].Winner, rows[1].Decision = "Ito", "fought" - return append(rows, state.SubMatchResult{Position: 3, SideA: "Ota", SideB: "Ito"}) - }, - inherited: map[int][2]string{1: {"k-sato", "o-tanaka"}, 2: {"k-sato", "o-ito"}, 3: {"", ""}}, + 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: "fixed order with a representative bout: a write echoing the stored member ids", - comp: state.Competition{ID: "pin-f-echo", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed}, - stored: fixedRows, - payload: func() []state.SubMatchResult { - rows := fixedRows() - rows[0].IpponsA = []string{"M", "K"} - rows[1].IpponsA, rows[1].Winner = []string{"M"}, "Kyoto" - return rows - }, - inherited: map[int][2]string{1: {"k1", "o1"}, state.DaihyosenSubPosition: {"k-rep", "o-rep"}}, + 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: "fixed order with a representative bout: a write whose rows carry names and no member ids", - comp: state.Competition{ID: "pin-f-names", Name: "Pin", Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeFixed}, - stored: fixedRows, - payload: func() []state.SubMatchResult { - rows := stripIDs(fixedRows()) - rows[0].IpponsA = []string{"M", "K"} - rows[1].IpponsA, rows[1].Winner = []string{"M"}, "Kyoto" - return append(rows, state.SubMatchResult{Position: 2, SideA: "K2", SideB: "O2"}) - }, + 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) { - eng, store, _ := setupTestEngine(t) - comp := tt.comp - require.NoError(t, store.SaveCompetition(&comp)) - sideA, sideB := "Kyoto", "Osaka" - require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ - ID: "P1-1", SideA: sideA, SideAID: teamK, SideB: sideB, SideBID: teamO, - Status: state.MatchStatusRunning, SubResults: tt.stored(), - }})) - - payload := tt.payload() - // The write reshapes the rows it is handed in place, so what the - // payload carried is read from a copy made before it. - sent := slices.Clone(payload) - patch := &state.MatchResult{ - SideA: sideA, SideAID: teamK, SideB: sideB, SideBID: teamO, - Status: state.MatchStatusRunning, SubResults: payload, - } - require.NoError(t, store.WithTransaction(comp.ID, func(tx state.StoreTx) error { - _, terr := eng.RecordMatchResultWithIneligibilityTx(tx, comp.ID, "P1-1", patch) - return terr - })) - - landed := loadPoolMatchByID(t, store, comp.ID, "P1-1") - require.NotNil(t, landed) - require.NotEmpty(t, landed.SubResults) - assertNoIntroducedMemberID(t, tt.stored(), sent, landed.SubResults) - - for position, ids := range tt.inherited { - row := state.SubResultAt(landed.SubResults, position) - assert.Equal(t, ids, [2]string{row.SideAMemberID, row.SideBMemberID}, - "position %d: the ids the write was meant to carry over are still there", position) + 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/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx index c0b00430e..632556ba5 100644 --- a/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scores_auto_start_refusal.render.test.jsx @@ -173,14 +173,14 @@ describe('Finish + Start Next on the Scores tab says why the next match did not }); // PR #463 round 18 (this REPLACES the round-17 pin that asserted "no notice and - // no toast", which encoded the opposite rule). A superseded start (HTTP 200 - // applied:false, reason superseded) wrote nothing: a newer change to the match - // is stored. It is a REFUSED start, through the same path a thrown or + // no toast", which encoded the opposite rule). A superseded start that holds + // the result group (HTTP 200 applied:false, reason superseded, heldGroups + // ['result']) wrote nothing: a different result is stored. It is a REFUSED start, through the same path a thrown or // clock_skew refusal takes: the sentence is stored on the refused match's row, // toasted once, and the editor lands on that match in pre-match. it('a superseded start is a refused start: its notice on the next match\'s row, one toast, the editor on that match', async () => { const onEditScore = vi.fn(async (_c, id, patch) => (id === 'm-2' && patch.startOnly - ? { applied: false, reason: 'superseded' } + ? { applied: false, reason: 'superseded', heldGroups: ['result'] } : { status: 'ok' })); const showToast = vi.fn(); const utils = await mountAndOpenRunning(onEditScore, showToast); @@ -197,6 +197,25 @@ describe('Finish + Start Next on the Scores tab says why the next match did not expect(probe.props.match.id).toBe('m-2'); expect(probe.props.match.status).toBe('scheduled'); }); + + // PR #463 round 19 (S1). The same answer WITHOUT heldGroups is an echo-hold: + // another device's start of m-2 landed first, so m-2 IS running. That is a start + // that went out, treated as a landed one ('a start that lands raises no notice + // and no toast' above): no notice, no toast, the editor on the next match. + it('a superseded start with no held group is an echo of a start that landed: no notice, no toast', async () => { + const onEditScore = vi.fn(async (_c, id, patch) => (id === 'm-2' && patch.startOnly + ? { applied: false, reason: 'superseded' } + : { status: 'ok' })); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m-1', 'm-2']); + expect(utils.container.querySelectorAll('[data-testid="start-refusal-notice"]')).toHaveLength(0); + expect(showToast).not.toHaveBeenCalled(); + expect(probe.props.match.id).toBe('m-2'); + }); }); describe('one start at a time on the Scores tab', () => { @@ -285,7 +304,7 @@ describe('a start that was waited for and then refused', () => { await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); expect(noticeIn(rowOf(utils, 'Carol'))?.textContent).toBe(startWhileStartingMessage({ label: scoreRowMatchName(M2) })); - await act(async () => { releaseStart({ applied: false, reason: 'superseded' }); }); + await act(async () => { releaseStart({ applied: false, reason: 'superseded', heldGroups: ['result'] }); }); expect(noticeIn(rowOf(utils, 'Alice'))?.textContent, 'the superseded start keeps its own refusal').toBe(START_SUPERSEDED_MESSAGE); expect(noticeIn(rowOf(utils, 'Carol')), 'nothing was started: no past-tense notice on the waiting row').toBeFalsy(); expect(utils.container.textContent).not.toContain('was being started'); @@ -293,6 +312,29 @@ describe('a start that was waited for and then refused', () => { expect(showToast.mock.calls.map((c) => c[0])).toEqual([startWhileStartingMessage({ label: scoreRowMatchName(M2) }), START_SUPERSEDED_MESSAGE]); expect(probe.props.match.id, 'the editor lands on the match that did not start').toBe('m-2'); }); + + // S1: the echo-hold is a start that went out, so the refusal that waited on it + // is still accurate and is worded in the past tense, as for a landed start. + it('a start held as an echo went out, so the waiting row gets the past-tense notice', async () => { + let releaseStart; + const onEditScore = vi.fn((_c, id, patch) => { + if (id === 'm-2' && patch.startOnly) return new Promise((resolve) => { releaseStart = resolve; }); + return Promise.resolve({ status: 'ok' }); + }); + const showToast = vi.fn(); + const utils = await mountAndOpenRunning(onEditScore, showToast); + + await act(async () => { probe.props.onSubmitAndNext({ status: 'completed' }); }); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed' }); }); + expect(noticeIn(rowOf(utils, 'Carol'))?.textContent).toBe(startWhileStartingMessage({ label: scoreRowMatchName(M2) })); + + await act(async () => { releaseStart({ applied: false, reason: 'superseded' }); }); + expect(noticeIn(rowOf(utils, 'Carol'))?.textContent, 'the waiting refusal was accurate: it is rewritten, not cleared') + .toBe(startWasBlockedByStartMessage({ label: scoreRowMatchName(M2) })); + expect(noticeIn(rowOf(utils, 'Alice')), 'm-2 started: it has no refusal of its own').toBeFalsy(); + expect(utils.container.textContent).not.toContain(START_SUPERSEDED_MESSAGE); + expect(showToast.mock.calls.map((c) => c[0])).toEqual([startWhileStartingMessage({ label: scoreRowMatchName(M2) })]); + }); }); describe('the start after a decision says why too', () => { @@ -330,7 +372,7 @@ describe('the start after a decision says why too', () => { // onAfterDecision lands on m-2 either way; what changed is that the operator is // told why it is still scheduled. it('a superseded start after a decision lands on the next match with its notice and one toast', async () => { - const onEditScore = vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }); + const onEditScore = vi.fn().mockResolvedValue({ applied: false, reason: 'superseded', heldGroups: ['result'] }); const showToast = vi.fn(); const utils = await mountAndOpenRunning(onEditScore, showToast); diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index a9c535029..353b0e8aa 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -2181,15 +2181,18 @@ describe('a Start tapped while another start is still out is refused before anyt } finally { c.restore(); } }); - // A superseded start is HTTP 200 {applied:false, reason:'superseded'}: a newer - // change to the match's result is already stored and nothing of the start was - // written. For a PICK that is not "as it was": the running bout was sent back + // A superseded start that HOLDS its result group is HTTP 200 + // {applied:false, reason:'superseded', heldGroups:['result']}: a different + // result is already stored (a send-back, a correction) and nothing of the start + // was written. (Round 19: the same answer WITHOUT heldGroups is an echo of a + // start that already landed, a start that went out; see the echo tests below.) + // For a PICK that is not "as it was": the running bout was sent back // to the queue before the start, so the court is IDLE (and m3 shows the // superseded refusal like any refused start; see the describe below). "m3 was // being started" would be false, and the pick must not pin the panel on a // match the server did not start. const supersededStartOfM3 = () => vi.fn((_compId, matchId) => Promise.resolve( - matchId === 'm3' ? { applied: false, reason: 'superseded' } : { applied: true } + matchId === 'm3' ? { applied: false, reason: 'superseded', heldGroups: ['result'] } : { applied: true } )); it('g) a pick whose start is superseded wrote nothing, so the refused row gets no past-tense notice', async () => { @@ -2199,7 +2202,7 @@ describe('a Start tapped while another start is still out is refused before anyt await act(async () => { settleRevert().resolve(true); }); await act(async () => {}); expect(onEditScore.mock.calls.map((x) => x[1]), 'the pick did send its start').toEqual(['m3']); - expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]'), 'the court is as it was: no sentence about a start').toBeNull(); + expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]'), 'the start wrote nothing: no sentence about a start').toBeNull(); expect(c.utils.container.textContent).not.toContain('was being started'); } finally { c.restore(); } }); @@ -2219,6 +2222,37 @@ describe('a Start tapped while another start is still out is refused before anyt expect(c.editorMatch(), 'nothing was started: the panel stays where it was').not.toBe('m3'); } finally { c.restore(); } }); + + // PR #463 round 19 (S1). The same answer WITHOUT heldGroups is an echo-hold: + // the stored result already equals the start, so another device's start of m3 + // landed first and m3 IS running. That start went out, exactly like a landed + // one (tests f and the landed half of h): the refused row waiting on it gets + // the past-tense notice, and the pick pins the panel on m3. + const echoStartOfM3 = () => vi.fn((_compId, matchId) => Promise.resolve( + matchId === 'm3' ? { applied: false, reason: 'superseded' } : { applied: true } + )); + + it('i) a pick whose start is held as an echo went out, so the refused row gets the past-tense notice', async () => { + const onEditScore = echoStartOfM3(); + const { c, settleRevert } = await pickBlocker(onEditScore); + try { + await act(async () => { settleRevert().resolve(true); }); + await act(async () => {}); + expect(onEditScore.mock.calls.map((x) => x[1])).toEqual(['m3']); + expect(rowOf(c, 'Aka m4').querySelector('[role="alert"]').textContent) + .toBe(startWasBlockedByStartMessage({ label: scoreRowMatchName(courtMatch('m3', 'scheduled')) })); + expect(c.utils.container.textContent).not.toContain(START_SUPERSEDED_MESSAGE); + } finally { c.restore(); } + }); + + it('j) a pick whose start is held as an echo pins the panel on the match, which is running', async () => { + const { c, settleRevert } = await pickBlocker(echoStartOfM3()); + try { + await act(async () => { settleRevert().resolve(true); }); + await act(async () => {}); + expect(c.editorMatch(), 'a start that went out pins the panel on the picked match').toBe('m3'); + } finally { c.restore(); } + }); }); }); @@ -2435,12 +2469,12 @@ describe('the console toasts a thrown Start refusal once', () => { // refused match (its Up next card or its queue row, shown while it is still // scheduled), toasted once, and the panel is not pinned on a match the server // did not start. Before this it said nothing at all. -describe('the console reports a superseded Start as a refused one', () => { +describe('the console reports a superseded Start that holds its result as a refused one', () => { const startButton = (el) => [...el.querySelectorAll('button')].find((b) => /^start/i.test(b.textContent.trim())); const rowOf = (c, text) => [...c.utils.container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes(text)); const mountSuperseded = () => mountCourt( [courtMatch('m1', 'scheduled'), courtMatch('m2', 'scheduled')], - { onEditScore: vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }) }, + { onEditScore: vi.fn().mockResolvedValue({ applied: false, reason: 'superseded', heldGroups: ['result'] }) }, ); it('the Up next card: the superseded sentence on the card, one toast, the panel not pinned', async () => { @@ -2483,6 +2517,43 @@ describe('the console reports a superseded Start as a refused one', () => { expect(c.utils.container.textContent).not.toContain(START_SUPERSEDED_MESSAGE); } finally { c.restore(); } }); + + // PR #463 round 19 (S1). A superseded start WITHOUT heldGroups is an echo-hold: + // another device's start of this match landed first with a later stamp, so the + // match IS running. It is a start that went out, like a landed one: no sentence, + // no toast, and the panel pins on the match. (A PICK's echo is tests i and j in + // the past-tense describe above.) + describe('an echo-held superseded Start (no heldGroups) is a start that went out', () => { + const mountEcho = () => mountCourt( + [courtMatch('m1', 'scheduled'), courtMatch('m2', 'scheduled')], + { onEditScore: vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }) }, + ); + + it('the Up next card: no sentence, no toast, the panel pinned on the match', async () => { + const c = await mountEcho(); + try { + const card = c.utils.container.querySelector('.shiaijo-upnext__card'); + await act(async () => { startButton(card).click(); }); + await act(async () => {}); + expect(card.querySelector('.shiaijo-upnext__error'), 'no refusal sentence on the card').toBeNull(); + expect(c.utils.container.textContent).not.toContain('Not started'); + expect(c.showToast, 'no toast').not.toHaveBeenCalled(); + expect(c.editorMatch(), 'the start went out: the panel is pinned on m1').toBe('m1'); + } finally { c.restore(); } + }); + + it('a queue row: no sentence, no toast, the panel pinned on the match', async () => { + const c = await mountEcho(); + try { + await act(async () => { startButton(rowOf(c, 'Aka m2')).click(); }); + await act(async () => {}); + expect(rowOf(c, 'Aka m2').querySelector('.shiaijo-upnext__error'), 'no refusal sentence on the row').toBeNull(); + expect(c.utils.container.textContent).not.toContain('Not started'); + expect(c.showToast, 'no toast').not.toHaveBeenCalled(); + expect(c.editorMatch(), 'the start went out: the panel is pinned on m2').toBe('m2'); + } finally { c.restore(); } + }); + }); }); // PR #463 batch 14, item 4 (bc-aadv): a stored Start refusal belongs to the match diff --git a/web-mobile/js/__tests__/start_match.test.jsx b/web-mobile/js/__tests__/start_match.test.jsx index 40760c6c4..da77294a2 100644 --- a/web-mobile/js/__tests__/start_match.test.jsx +++ b/web-mobile/js/__tests__/start_match.test.jsx @@ -6,7 +6,7 @@ // second start while one was out. import { describe, it, expect } from 'vitest'; -import { CLOCK_SKEW_REASON_TEXT } from '../write_result.jsx'; +import { CLOCK_SKEW_REASON_TEXT, SUPERSEDED_REASON, SUPERSEDED_ADVICE, notSavedText } from '../write_result.jsx'; import { classifyStartOutcome, startFailureMessage, createStartGuard, startPatch, startRefusalStands, START_CLOCK_SKEW_MESSAGE, START_FAILED_MESSAGE, START_SUPERSEDED_MESSAGE, @@ -31,26 +31,39 @@ describe('classifyStartOutcome', () => { expect(classifyStartOutcome(null)).toEqual({ ok: true }); }); - it('a superseded start is a refused start, in its own words: nothing was written and nothing will land later', () => { - // A newer change to the match's result is already stored. The start is - // refused like a clock_skew one (a stored notice and a toast on both - // hosts), not waved through: a host that took it for a start that went - // out would pin a panel on a match the server did not start, say nothing - // to the operator, and word a past-tense "was being started" for a start - // that wrote nothing (PR #463 round 18). - const out = classifyStartOutcome({ applied: false, reason: 'superseded' }); + // PR #463 round 19 (S1). A start names the `result` group alone, and the server + // lists a held group in `heldGroups` only when its incoming value DIFFERS from + // the stored one (reportHeld: an equal one is a HeldEcho, neither listed nor + // kept), and omits `heldGroups` from the answer when it would be empty + // (respondSuperseded). So a superseded start with NO heldGroups is an echo-hold: + // the stored result already equals the start, which is another device's start of + // this same match having landed first with a later stamp. The match IS running. + it('a superseded start that holds no group is an echo-hold: the start went out', () => { + expect(classifyStartOutcome({ applied: false, reason: 'superseded' })).toEqual({ ok: true }); + expect(classifyStartOutcome({ applied: false, reason: 'superseded', heldGroups: [] })).toEqual({ ok: true }); + }); + + // PIN (green by design since round 18): a superseded start that DID hold its + // result group means a different result is stored (a send-back, a correction), + // so nothing of the start was written and the match is not running. + it('PIN: a superseded start that holds the result group is a refused start, in its own words', () => { + const out = classifyStartOutcome({ applied: false, reason: 'superseded', heldGroups: ['result'] }); expect(out.ok).toBe(false); expect(out.msg).toBe(START_SUPERSEDED_MESSAGE); - expect(out.msg).toBe('Not started: a newer change to this match was recorded first. Check the match before starting it.'); }); - it('a superseded answer that also holds groups (heldGroups) is the same refusal', () => { - expect(classifyStartOutcome({ applied: false, reason: 'superseded', heldGroups: ['result'] })) - .toEqual({ ok: false, msg: START_SUPERSEDED_MESSAGE }); + // S2: the refusal's words are write_result.jsx's (SUPERSEDED_REASON and + // SUPERSEDED_ADVICE, composed by notSavedText as the clock sentence composes + // CLOCK_SKEW_REASON_TEXT), under the start sentences' own "Not started" lead. + it('the superseded sentence is composed from the write_result.jsx owner, with the start lead', () => { + expect(START_SUPERSEDED_MESSAGE).toBe(notSavedText({ lead: 'Not started', reason: SUPERSEDED_REASON, advice: SUPERSEDED_ADVICE })); + expect(START_SUPERSEDED_MESSAGE).toBe( + '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.'); }); - it('only a superseded or clock_skew answer is a refusal: landed, queued and body-less starts did go out', () => { - for (const res of [{ applied: true }, { queued: true }, { status: 'ok' }, undefined, null]) { + it('only a clock_skew or a held-group superseded answer is a refusal: landed, queued, echo-held and body-less starts did go out', () => { + for (const res of [{ applied: true }, { queued: true }, { status: 'ok' }, { applied: false, reason: 'superseded' }, undefined, null]) { expect(classifyStartOutcome(res).ok, JSON.stringify(res)).toBe(true); } }); diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx index a26f570dd..87e7c8438 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -184,7 +184,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // `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 superseded answer, "still being started") + // 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) { @@ -197,16 +197,20 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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 (classifyStartOutcome: nothing written, a - // newer change to the match's result is stored) is a REFUSED start like a - // clock_skew one: refuseStart stores its sentence on the refused match's row, + // 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. + // 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) => { @@ -239,7 +243,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // 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 superseded answer) has + // 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. @@ -584,7 +588,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // (honest to the label). If the // next match is already running/completed, just open it. Start // gating runs server-side (StartMatchTx): startNext reports a - // refusal (a thrown 409, or a clock_skew or superseded answer) on + // refusal (a thrown 409, or a clock_skew or held-group superseded answer) on // that match's row and by toast, and the operator stays on it in // pre-match to // resolve the issue. The live lookup shows a started match: diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 4f4503dd5..c04dea50f 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1326,7 +1326,8 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // // 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 - // or superseded answer) is what the operator needs. It is stored for the + // 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 @@ -1353,7 +1354,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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 must not steal the panel). + // 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 } = {}) => { @@ -1378,18 +1381,21 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // 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 or a superseded answer 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 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 - // superseded one the operator checks the match first), which is why + // 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 diff --git a/web-mobile/js/start_match.jsx b/web-mobile/js/start_match.jsx index 4c3b49204..a1d5c3283 100644 --- a/web-mobile/js/start_match.jsx +++ b/web-mobile/js/start_match.jsx @@ -22,12 +22,16 @@ // 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 words) and +// 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, CLOCK_SKEW_REASON_TEXT } from './write_result.jsx'; +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' @@ -65,15 +69,21 @@ export function startPatch() { // 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 (HTTP 200 applied:false, -// reason superseded): a newer change to this match is already stored, so nothing -// of the start was written. 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. -// It is the same sentence whether the newer change was a held write or another -// device's start that landed first (the row then reads running after the refetch, -// the stored notice drops, startRefusalStands, and only the toast is seen). -export const START_SUPERSEDED_MESSAGE = "Not started: a newer change to this match was recorded first. Check the match before starting it."; +// 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."; @@ -81,7 +91,8 @@ export const START_FAILED_MESSAGE = "Could not start the match: check eligibilit // 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. +// 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 @@ -90,23 +101,42 @@ export const START_FAILED_MESSAGE = "Could not start the match: check eligibilit // - 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: a newer change to the match 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. Held as the same refusal whether -// or not groups were held (an echo-hold has empty -// heldGroups): when another device's start landed -// first the row reads running after the refetch, the -// stored notice drops (startRefusalStands) and only -// the toast is seen, which is still true. +// - 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)) return { ok: false, msg: START_SUPERSEDED_MESSAGE }; + if (writeWasSuperseded(res) && writeHeldGroups(res).length > 0) return { ok: false, msg: START_SUPERSEDED_MESSAGE }; return { ok: true }; }