From 1d4c9a3b899b487e7da7af39cda5c93a0a9bdf83 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:20:34 +0300 Subject: [PATCH] Add set dividers --- app/features/scanner/components/LivePage.tsx | 77 ++++++----- app/features/scanner/components/MatchCard.tsx | 5 + app/features/scanner/components/VodPage.tsx | 74 ++++++----- app/features/scanner/components/styles.css | 23 +++- app/features/scanner/core/match-sets.ts | 66 ++++++++++ app/features/scanner/tests/match-sets.test.ts | 123 ++++++++++++++++++ 6 files changed, 304 insertions(+), 64 deletions(-) create mode 100644 app/features/scanner/core/match-sets.ts create mode 100644 app/features/scanner/tests/match-sets.test.ts diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx index 0ca736126..fcc069f2f 100644 --- a/app/features/scanner/components/LivePage.tsx +++ b/app/features/scanner/components/LivePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { listVideoInputs, openVirtualCamera, @@ -11,6 +11,7 @@ import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry"; import type { DetectedEvent, GateResult } from "../core/detectors/types"; import type { BuiltMatch } from "../core/match-builder"; import { buildScannerMatches, isIngestableMatch } from "../core/match-builder"; +import { assignMatchSets } from "../core/match-sets"; import { TimelineBuilder } from "../core/timeline/index"; import { clearEvents, @@ -27,7 +28,7 @@ import { EventsSummary } from "./EventsSummary"; import { downloadEventsCsv } from "./events-csv"; import { type FixtureData, saveFixture } from "./fixture-export"; import { SENDOU_UPLOAD_ENABLED } from "./flags"; -import { MatchCard } from "./MatchCard"; +import { MatchCard, SetDivider } from "./MatchCard"; import { aggregateSendStatus, matchContaining, @@ -208,6 +209,8 @@ export function LivePage({ }, [deviceId, refreshFeed, send]); const builtMatches = buildScannerMatches(feed); + const setNumbers = assignMatchSets(builtMatches.map((b) => b.match)); + const showSetDividers = (setNumbers.at(-1) ?? 1) > 1; const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources)); const ungroupedFeed = feed.filter((e) => !groupedEvents.has(e)); @@ -319,41 +322,47 @@ export function LivePage({

No detections yet.

) : null} {[...builtMatches].reverse().map((built, reverseIndex) => { + const index = builtMatches.length - 1 - reverseIndex; const id = built.sources[0]!.id!; const ingestable = isIngestableMatch(built.match); return ( - void send(matchContaining(id), { manual: true }) - : undefined - } - > - {withoutRepeatEvents(built.sources).map((e) => ( - loadEventFrame(e.id!) - : undefined - } - /> - ))} - + + {showSetDividers && + setNumbers[index + 1] !== setNumbers[index] ? ( + + ) : null} + void send(matchContaining(id), { manual: true }) + : undefined + } + > + {withoutRepeatEvents(built.sources).map((e) => ( + loadEventFrame(e.id!) + : undefined + } + /> + ))} + + ); })} {ungroupedFeed.length > 0 ? ( diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index b7fd31788..0ba6c4286 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -145,6 +145,11 @@ export function MatchCard({ ); } +/** Labeled rule above the newest card of each set in the feed. */ +export function SetDivider({ number }: { number: number }) { + return
Set {number}
; +} + function timeRangeLabel(match: ScannerMatch): string { const start = formatTime(match.startsAt!); return match.endsAt !== null && match.endsAt !== match.startsAt diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx index 1215dac78..7023b80c6 100644 --- a/app/features/scanner/components/VodPage.tsx +++ b/app/features/scanner/components/VodPage.tsx @@ -12,12 +12,20 @@ * Completed scans are persisted to IndexedDB keyed by file name * (src/store/vods.ts); the default view lists them for reinspection. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Link } from "react-router"; import { openVodScan } from "../capture/vod-frames"; import { connectAbilities } from "../core/ability-harvest"; import type { DetectedEvent } from "../core/detectors/types"; import { buildScannerMatches, isIngestableMatch } from "../core/match-builder"; +import { assignMatchSets } from "../core/match-sets"; import { TimelineBuilder } from "../core/timeline/index"; import type { SendStatus } from "../store/events"; import { @@ -36,7 +44,7 @@ import { downloadEventsCsv } from "./events-csv"; import type { FixtureData } from "./fixture-export"; import { SENDOU_UPLOAD_ENABLED } from "./flags"; import { formatTime } from "./format"; -import { MatchCard } from "./MatchCard"; +import { MatchCard, SetDivider } from "./MatchCard"; import { countIngestableMatches, type SendouUser, @@ -131,6 +139,8 @@ export function VodPage({ ); const builtMatches = buildScannerMatches(matches.map((m) => m.event)); + const setNumbers = assignMatchSets(builtMatches.map((b) => b.match)); + const showSetDividers = (setNumbers.at(-1) ?? 1) > 1; const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const)); const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources)); const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event)); @@ -578,35 +588,41 @@ export function VodPage({ ) : null} {/* newest match on top; the builder keeps ascending video-time order */} {[...builtMatches].reverse().map((built, reverseIndex) => { + const index = builtMatches.length - 1 - reverseIndex; const ingestable = isIngestableMatch(built.match); return ( - - {withoutRepeatEvents(built.sources).map((e, i) => { - const vodMatch = vodMatchByEvent.get(e); - return ( - - ); - })} - + + {showSetDividers && + setNumbers[index + 1] !== setNumbers[index] ? ( + + ) : null} + + {withoutRepeatEvents(built.sources).map((e, i) => { + const vodMatch = vodMatchByEvent.get(e); + return ( + + ); + })} + + ); })} {ungroupedMatches.length > 0 ? ( diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css index aacf93e22..6a503c708 100644 --- a/app/features/scanner/components/styles.css +++ b/app/features/scanner/components/styles.css @@ -626,6 +626,26 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). /* ── ingested matches feed ─────────────────────────────────────────── */ +.scanner-app .set-divider { + display: flex; + align-items: center; + gap: 10px; + margin-top: 6px; + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + text-transform: uppercase; + letter-spacing: 0.08em; + + &::before, + &::after { + content: ""; + flex: 1; + height: var(--border-width); + background-color: var(--color-border); + } +} + .scanner-app .match-card { position: sticky; top: var(--layout-sticky-top); @@ -770,7 +790,8 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). padding: var(--s-0-5); &.pov { - outline: 2px solid var(--color-text-accent); + outline: var(--border-style-high); + background-color: var(--color-bg-high); outline-offset: 1px; } } diff --git a/app/features/scanner/core/match-sets.ts b/app/features/scanner/core/match-sets.ts new file mode 100644 index 000000000..98cfd14b5 --- /dev/null +++ b/app/features/scanner/core/match-sets.ts @@ -0,0 +1,66 @@ +/** + * Divide a chronological run of ScannerMatches into sets — consecutive games + * played by the same eight players (a Bo3/Bo5 between two teams). Rosters + * are compared by in-game names pooled across both teams (scoreboards list + * winners first, so team sides swap between games): names match fuzzily + * because two OCR reads of the same name can differ by a glyph or two, and + * one differing player is tolerated as a sub between games. A match with no + * readable names is inconclusive and never opens a new set. + */ +import type { ScannerMatch } from "./scanner-match"; +import { closestBy } from "./text"; + +/** + * closestBy score two reads of the same name must reach — 0.7 forgives one + * bad glyph on a four-letter name, two on longer ones. + */ +const SAME_NAME_SCORE = 0.7; + +/** Players allowed to differ between consecutive games of one set. */ +const MAX_SUBS_PER_GAME = 1; + +/** + * For each match its 1-based set number, aligned by index with the input; + * matches must be in chronological order. Numbers only ever step up by one: + * a match whose roster disagrees with the current set's opens the next set. + */ +export function assignMatchSets(matches: readonly ScannerMatch[]): number[] { + const setNumbers: number[] = []; + let setNumber = 1; + let roster: string[] | null = null; + for (const match of matches) { + const names = rosterNames(match); + if (names.length > 0) { + if (roster !== null && !sameRoster(roster, names)) setNumber++; + roster = names; + } + setNumbers.push(setNumber); + } + return setNumbers; +} + +function rosterNames(match: ScannerMatch): string[] { + return match.teams + .flatMap((team) => team.players) + .map((player) => player.name) + .filter((name): name is string => name !== null); +} + +/** + * Whether two rosters read as the same eight players: every name of the + * smaller roster must find a fuzzy partner in the other, short of + * MAX_SUBS_PER_GAME misses. Partial reads compare only what both saw, so a + * minimap-sourced half-roster still chains a set together. + */ +function sameRoster(a: readonly string[], b: readonly string[]): boolean { + const remaining = [...b]; + let matched = 0; + for (const name of a) { + const best = closestBy(name, remaining, (entry) => entry); + if (best && best.score >= SAME_NAME_SCORE) { + matched++; + remaining.splice(remaining.indexOf(best.entry), 1); + } + } + return Math.min(a.length, b.length) - matched <= MAX_SUBS_PER_GAME; +} diff --git a/app/features/scanner/tests/match-sets.test.ts b/app/features/scanner/tests/match-sets.test.ts new file mode 100644 index 000000000..30128eccf --- /dev/null +++ b/app/features/scanner/tests/match-sets.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { assignMatchSets } from "../core/match-sets"; +import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match"; +import test from "./node-test-compat"; + +const TEAM_A = ["Sendou", "Kiver", "Brian", "Zed"]; +const TEAM_B = ["Gos", "Noah", "Alice", "Bob"]; +const TEAM_C = ["Totoro", "Miso", "Ramen", "Udon"]; +const TEAM_D = ["Pearl", "Marina", "Callie", "Marie"]; + +function player(name: string | null): ScannerMatchPlayer { + return { name, weaponId: null, paint: null, ka: null, d: null, s: null }; +} + +function match( + alpha: (string | null)[], + bravo: (string | null)[], +): ScannerMatch { + return { + startsAt: null, + endsAt: null, + playedAt: null, + lobby: null, + mode: null, + stage: null, + matchScores: null, + replayCode: null, + cast: false, + teams: [{ players: alpha.map(player) }, { players: bravo.map(player) }], + winner: null, + pov: null, + }; +} + +test("assignMatchSets", async (t) => { + await t.test("empty input", () => { + assert.deepEqual(assignMatchSets([]), []); + }); + + await t.test("same eight players stay in one set", () => { + const games = [ + match(TEAM_A, TEAM_B), + match(TEAM_A, TEAM_B), + match(TEAM_A, TEAM_B), + ]; + assert.deepEqual(assignMatchSets(games), [1, 1, 1]); + }); + + await t.test("team sides swapping keeps the set together", () => { + const games = [match(TEAM_A, TEAM_B), match(TEAM_B, TEAM_A)]; + assert.deepEqual(assignMatchSets(games), [1, 1]); + }); + + await t.test("a fully different lobby opens a new set", () => { + const games = [ + match(TEAM_A, TEAM_B), + match(TEAM_A, TEAM_B), + match(TEAM_C, TEAM_D), + match(TEAM_C, TEAM_D), + ]; + assert.deepEqual(assignMatchSets(games), [1, 1, 2, 2]); + }); + + await t.test("OCR stutter on a couple of names is tolerated", () => { + const games = [ + match(TEAM_A, TEAM_B), + match( + ["Send0u", "Kiver", "Brian", "Zed"], + ["Gos", "N0ah", "Alice", "Bob"], + ), + ]; + assert.deepEqual(assignMatchSets(games), [1, 1]); + }); + + await t.test("one sub is tolerated, two are a new set", () => { + const oneSub = [ + match(TEAM_A, TEAM_B), + match(["Sendou", "Kiver", "Brian", "NewGuy"], TEAM_B), + ]; + assert.deepEqual(assignMatchSets(oneSub), [1, 1]); + + const twoSubs = [ + match(TEAM_A, TEAM_B), + match(["Sendou", "Kiver", "Sub1", "Sub2"], TEAM_B), + ]; + assert.deepEqual(assignMatchSets(twoSubs), [1, 2]); + }); + + await t.test("a nameless match never breaks the chain", () => { + const games = [ + match(TEAM_A, TEAM_B), + match([null, null, null, null], [null, null, null, null]), + match(TEAM_A, TEAM_B), + ]; + assert.deepEqual(assignMatchSets(games), [1, 1, 1]); + }); + + await t.test("a partial roster read chains on what both saw", () => { + const games = [ + match(TEAM_A, TEAM_B), + match(["Sendou", "Kiver", null, null], [null, null, null, null]), + match(TEAM_A, TEAM_B), + ]; + assert.deepEqual(assignMatchSets(games), [1, 1, 1]); + }); + + await t.test("a partial read of different players opens a new set", () => { + const games = [ + match(TEAM_A, TEAM_B), + match(["Totoro", "Miso", "Ramen", null], [null, null, null, null]), + ]; + assert.deepEqual(assignMatchSets(games), [1, 2]); + }); + + await t.test("comparison follows the latest roster, not the first", () => { + const games = [ + match(TEAM_A, TEAM_B), + match(["Sendou", "Kiver", "Brian", "SubA"], TEAM_B), + match(["Sendou", "Kiver", "Brian", "SubB"], TEAM_B), + ]; + assert.deepEqual(assignMatchSets(games), [1, 1, 1]); + }); +});