From 273ab85d6972dd240dd540f54a1d1589e8a51e90 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:53:54 +0300 Subject: [PATCH] Dedupe logic --- .../scanner/components/EventTypeIcon.tsx | 2 +- app/features/scanner/components/LivePage.tsx | 3 +- app/features/scanner/components/VodPage.tsx | 3 +- .../scanner/components/dedupe-events.ts | 42 +++++++++ app/features/scanner/components/styles.css | 39 ++++++-- .../scanner/tests/dedupe-events.test.ts | 90 +++++++++++++++++++ 6 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 app/features/scanner/components/dedupe-events.ts create mode 100644 app/features/scanner/tests/dedupe-events.test.ts diff --git a/app/features/scanner/components/EventTypeIcon.tsx b/app/features/scanner/components/EventTypeIcon.tsx index 27bf04a3b..4ed4845b6 100644 --- a/app/features/scanner/components/EventTypeIcon.tsx +++ b/app/features/scanner/components/EventTypeIcon.tsx @@ -37,5 +37,5 @@ export function EventTypeIcon({ size?: number; }) { const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp; - return ; + return ; } diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx index eb07d2367..077245aeb 100644 --- a/app/features/scanner/components/LivePage.tsx +++ b/app/features/scanner/components/LivePage.tsx @@ -21,6 +21,7 @@ import { updateEventsSend, } from "../store/events"; import { AnalyzerClient } from "../worker/client"; +import { withoutRepeatEvents } from "./dedupe-events"; import { EventCard } from "./EventCard"; import { EventsSummary } from "./EventsSummary"; import { downloadEventsCsv } from "./events-csv"; @@ -334,7 +335,7 @@ export function LivePage({ : undefined } > - {built.sources.map((e) => ( + {withoutRepeatEvents(built.sources).map((e) => ( - {built.sources.map((e, i) => { + {withoutRepeatEvents(built.sources).map((e, i) => { const vodMatch = vodMatchByEvent.get(e); return ( ( + events: readonly T[], +): T[] { + const result: T[] = []; + let previousMinimap: MinimapData | null = null; + for (const event of events) { + if (event.type === MINIMAP_EVENT_TYPE) { + const data = event.data as MinimapData; + const repeat = + previousMinimap !== null && + isDeepEqual(comparable(previousMinimap), comparable(data)); + previousMinimap = data; + if (repeat) continue; + } + result.push(event); + } + return result; +} + +function comparable(data: MinimapData) { + return { + ...data, + teammates: data.teammates.map((player) => omit(player, ["name"])), + enemies: data.enemies.map((player) => omit(player, ["name"])), + }; +} diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css index 6c6cc33fd..d6ed275fc 100644 --- a/app/features/scanner/components/styles.css +++ b/app/features/scanner/components/styles.css @@ -179,6 +179,25 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). font-variant-numeric: tabular-nums; margin-bottom: 8px; align-items: center; + + /* timestamp leads the row */ + & > span:first-child { + color: var(--color-text); + font-weight: var(--weight-bold); + } + + /* event-type chip: neutral like the match chips, the icon carries the accent */ + & .status.detected { + gap: 5px; + padding: 2px 10px; + color: var(--color-text); + border-color: var(--color-border); + background: var(--color-bg); + + & .event-type-icon { + color: var(--color-text-accent); + } + } } .scanner-app .card img.thumb { @@ -248,10 +267,13 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). font-weight: var(--weight-body); } +/* lighter than a solid slab: translucent fill + hairline so the boxes read + as grouping, not chrome */ .scanner-app .team { border-radius: var(--radius-field); - padding: 8px; - background: var(--color-bg); + padding: 8px 10px; + background: color-mix(in oklab, var(--color-bg) 45%, transparent); + border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent); min-width: 0; overflow-x: auto; } @@ -301,7 +323,8 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). font-size: var(--font-2xs); font-weight: var(--weight-bold); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.06em; + color: var(--color-text-high); } .scanner-app .team.win h3 { @@ -332,12 +355,14 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). width: auto; } +/* round black pucks, same language as the match header weapon row */ .scanner-app img.weapon-icon { width: 28px; height: 28px; vertical-align: middle; background: #000; - border-radius: 4px; + border-radius: var(--radius-full); + padding: var(--s-0-5); } .scanner-app .minimap-player img.weapon-icon { @@ -647,8 +672,10 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). } & img.weapon-icon { - width: 22px; - height: 22px; + width: 32px; + height: 32px; + border-radius: 100%; + padding: var(--s-0-5); &.pov { outline: 2px solid var(--color-text-accent); diff --git a/app/features/scanner/tests/dedupe-events.test.ts b/app/features/scanner/tests/dedupe-events.test.ts new file mode 100644 index 000000000..4f82faa37 --- /dev/null +++ b/app/features/scanner/tests/dedupe-events.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import { withoutRepeatEvents } from "../components/dedupe-events"; +import type { DeathData } from "../core/detectors/death/index"; +import type { + MinimapData, + MinimapEnemy, + MinimapTeammate, +} from "../core/detectors/minimap/index"; +import { SPECTATOR_SLOTS } from "../core/detectors/minimap/rois"; +import type { DetectedEvent } from "../core/detectors/types"; +import type { ScannerAbility } from "../scanner-types"; +import test from "./node-test-compat"; + +const ALPHA: MainWeaponId[] = [40, 1001, 2010, 3030]; +const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000]; + +function teammate( + weaponId: MainWeaponId | null, + i: number, + { + name = null as string | null, + abilities = [] as (ScannerAbility | null)[], + } = {}, +): MinimapTeammate { + return { slot: SPECTATOR_SLOTS[i]!, name, weaponId, abilities }; +} + +function enemy( + weaponId: MainWeaponId | null, + { name = null as string | null } = {}, +): MinimapEnemy { + return { name, weaponId, abilities: [] }; +} + +function minimap( + t: number, + { + stage = 0 as StageId | null, + teammates = ALPHA.map((id, i) => teammate(id, i)), + enemies = BRAVO.map((id) => enemy(id)), + } = {}, +): DetectedEvent { + const data: MinimapData = { stage, spectator: true, teammates, enemies }; + return { type: "Minimap", t, confidence: 0.8, data }; +} + +function death(t: number): DetectedEvent { + const data: DeathData = { + weaponId: null, + weaponType: "MAIN", + abilities: [], + name: null, + }; + return { type: "Death", t, confidence: 0.9, data }; +} + +test("collapses a repeated minimap into the first occurrence", () => { + const kept = withoutRepeatEvents([minimap(70), minimap(73), minimap(76)]); + assert.equal(kept.length, 1); + assert.equal(kept[0]!.t, 70); +}); + +test("a name-only difference is still a repeat (OCR wobble)", () => { + const kept = withoutRepeatEvents([ + minimap(70, { enemies: BRAVO.map((id) => enemy(id, { name: "クレー" })) }), + minimap(73, { enemies: BRAVO.map((id) => enemy(id, { name: "グレー" })) }), + ]); + assert.equal(kept.length, 1); +}); + +test("a changed ability read keeps both minimaps", () => { + const kept = withoutRepeatEvents([ + minimap(70), + minimap(73, { + teammates: ALPHA.map((id, i) => + teammate(id, i, { abilities: i === 0 ? ["ISM"] : [] }), + ), + }), + ]); + assert.equal(kept.length, 2); +}); + +test("dedupes across interleaved events of other types, dropping none of them", () => { + const kept = withoutRepeatEvents([minimap(70), death(72), minimap(74)]); + assert.deepEqual( + kept.map((e) => e.type), + ["Minimap", "Death"], + ); +});