From dcbe6a87303ec3872193f006d0d2b19fa3e0193a Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:13:42 +0300 Subject: [PATCH] Show abilities on match card --- .../scanner/components/MatchCard.module.css | 58 ++++++++- app/features/scanner/components/MatchCard.tsx | 110 +++++++++++++++-- app/features/scanner/core/ability-harvest.ts | 79 +++++++++++-- app/features/scanner/core/match-builder.ts | 111 +++++++++++++++++- app/features/scanner/core/scanner-match.ts | 5 +- app/features/scanner/scanner-schemas.ts | 2 +- .../scanner/tests/ability-harvest.test.ts | 44 ++++++- 7 files changed, 374 insertions(+), 35 deletions(-) diff --git a/app/features/scanner/components/MatchCard.module.css b/app/features/scanner/components/MatchCard.module.css index c47591083..b768b9992 100644 --- a/app/features/scanner/components/MatchCard.module.css +++ b/app/features/scanner/components/MatchCard.module.css @@ -153,19 +153,69 @@ color: var(--color-text-high); margin: 0 5px; } + + /* stay level with the weapons, which the arcs pushed up */ + &.withAbilities .vs { + margin-block-end: 18px; + } } /* a team is one wrapping unit: the eight never break up mid-team */ .weaponRow { display: flex; align-items: center; - gap: var(--s-0-5); + gap: var(--s-1-5); +} + +.weaponSlot { + --arc-ability-size: 20px; + --arc-radius: 27px; + /* how far under the weapon the arc's lowest point reaches */ + --arc-overhang: 18px; + + position: relative; + display: flex; + flex-shrink: 0; + + /* room for the ability arc hanging under and beside the weapon */ + &.withAbilities { + margin-inline: var(--s-2); + margin-block-end: var(--arc-overhang); + } +} + +/* zero-sized anchor at the weapon's centre, the arc's origin */ +.abilityArc { + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; +} + +.arcSlot { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + margin: calc(var(--arc-ability-size) / -2); + /* out along the arc, then back upright */ + transform: rotate(var(--arc-angle)) translateY(var(--arc-radius)) + rotate(calc(-1 * var(--arc-angle))); +} + +.arcAbility { + width: var(--arc-ability-size); + height: var(--arc-ability-size); + + & img { + display: block; + width: 100%; + height: 100%; + } } .weapon { flex-shrink: 0; - width: 32px; - height: 32px; + width: 38px; + height: 38px; vertical-align: middle; background: var(--color-bg-badge); border-radius: var(--radius-full); @@ -368,7 +418,7 @@ button.expand { .weapons { flex-direction: column; align-items: flex-start; - row-gap: var(--s-1); + row-gap: var(--s-3); & .vs { display: none; diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index f3625d00b..49f77114e 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -9,15 +9,19 @@ import clsx from "clsx"; import { ChevronDown } from "lucide-react"; import type * as React from "react"; import { useState } from "react"; +import { Ability } from "~/components/Ability"; import { SendouButton } from "~/components/elements/Button"; import { ModeImage, WeaponImage } from "~/components/Image"; import { matchScoresFromObjective } from "~/components/objective-timeline-utils"; import { StageBannerBox } from "~/components/StageBannerBox"; import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import type { + AbilityWithUnknown, + MainWeaponId, +} from "~/modules/in-game-lists/types"; import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls"; import type { IngestSkipReason } from "../core/match-builder"; -import type { ScannerMatch } from "../core/scanner-match"; +import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match"; import type { SendStatus } from "../store/events"; import { formatTime, useEventTimeFormatter } from "./format"; import { lobbyLabel, modeLabel, stageLabel } from "./labels"; @@ -26,6 +30,20 @@ import styles from "./MatchCard.module.css"; /** the game score a knockout wins at */ const KO_MATCH_SCORE = 100; +/** one per gear slot: [head, clothes, shoes], the arc's left-to-right order */ +const UNKNOWN_MAIN_ABILITIES: AbilityWithUnknown[] = [ + "UNKNOWN", + "UNKNOWN", + "UNKNOWN", +]; + +/** + * Where each main sits on the half-moon under the weapon. A positive CSS + * rotation swings the arc's downward offset to the *left*, so the angles + * descend to read head, clothes, shoes left to right. + */ +const ABILITY_ARC_ANGLES = ["44deg", "0deg", "-44deg"]; + const SEND_STATE_CLASS: Record = { queued: styles.queued, sending: styles.sending, @@ -263,6 +281,8 @@ interface TeamWeapon { weaponId: MainWeaponId; /** the scan's own player — highlighted among the eight */ pov: boolean; + /** head/clothes/shoes mains; null when no death screen revealed the build */ + mainAbilities: AbilityWithUnknown[] | null; } function TeamWeapons({ match }: { match: ScannerMatch }) { @@ -271,36 +291,102 @@ function TeamWeapons({ match }: { match: ScannerMatch }) { .map((player, index) => ({ weaponId: player.weaponId, pov: match.pov?.team === team && match.pov.index === index, + mainAbilities: mainAbilities(player), })) .filter((weapon): weapon is TeamWeapon => weapon.weaponId !== null); const [left, right] = displayOrder(match); const leftWeapons = weaponsOf(left); const rightWeapons = weaponsOf(right); if (leftWeapons.length + rightWeapons.length === 0) return null; + // one read build is enough to show the arcs; the rest fall back to unknowns + const withAbilities = [...leftWeapons, ...rightWeapons].some( + (weapon) => weapon.mainAbilities !== null, + ); return ( -
- {leftWeapons.length > 0 ? : null} +
+ {leftWeapons.length > 0 ? ( + + ) : null} {leftWeapons.length > 0 && rightWeapons.length > 0 ? ( vs ) : null} - {rightWeapons.length > 0 ? : null} + {rightWeapons.length > 0 ? ( + + ) : null}
); } +/** + * The three gear mains of a build, or null when the scan read none of them — + * a partially read build keeps its unknown slots. + */ +function mainAbilities( + player: ScannerMatchPlayer, +): AbilityWithUnknown[] | null { + const mains = UNKNOWN_MAIN_ABILITIES.map( + (unknown, slot) => player.abilities?.[slot]?.[0] ?? unknown, + ); + return mains.some((ability) => ability !== "UNKNOWN") ? mains : null; +} + /** One team's weapons, kept together when the card is too narrow for both. */ -function WeaponRow({ weapons }: { weapons: TeamWeapon[] }) { +function WeaponRow({ + weapons, + withAbilities, +}: { + weapons: TeamWeapon[]; + withAbilities: boolean; +}) { return (
{weapons.map((weapon, i) => ( - + className={clsx(styles.weaponSlot, { + [styles.withAbilities]: withAbilities, + })} + > + + {withAbilities ? ( + + ) : null} +
+ ))} +
+ ); +} + +/** Gear mains laid out as a half-moon hugging the weapon's lower edge. */ +function AbilityArc({ abilities }: { abilities: AbilityWithUnknown[] }) { + return ( +
+ {abilities.map((ability, i) => ( + + + ))}
); diff --git a/app/features/scanner/core/ability-harvest.ts b/app/features/scanner/core/ability-harvest.ts index 8c8ef049d..d6e632700 100644 --- a/app/features/scanner/core/ability-harvest.ts +++ b/app/features/scanner/core/ability-harvest.ts @@ -19,34 +19,42 @@ import type { DetectedEvent } from "./detectors/types"; /** player row index (0-7) → [head, clothes, shoes] ability-id rows */ export type PlayerAbilityMap = Map; -/** Any player row a death can be attributed against (scoreboard, minimap). */ +/** one build's three gear mains; null per slot nothing identified */ +export type GearMains = (AbilityWithUnknown | null)[]; + +/** Any player row a read can be attributed against (scoreboard, minimap). */ interface HarvestablePlayer { name: string | null; weaponId: MainWeaponId | null; } +/** A read revealing one player's build: a death overlay or a minimap card. */ +interface BuildRead { + name: string | null; + /** the player's main weapon, or null when the read proves no main */ + weaponId: MainWeaponId | null; +} + /** - * Match a death's killer to a player row. Both signals are OCR output, so + * Match a build read to a player row. Both signals are OCR output, so * neither is trusted alone unless it is unambiguous: a combined name+weapon * hit wins, then a unique name hit, then a unique weapon hit (two players on * the same weapon with a misread name stay unattributed). */ function matchPlayer( players: readonly HarvestablePlayer[], - death: DeathData, + read: BuildRead, ): number | null { - const name = death.name?.trim().toLowerCase() || null; + const name = read.name?.trim().toLowerCase() || null; const indices = players.map((_, i) => i); const byName = name ? indices.filter( (i) => (players[i]!.name ?? "").trim().toLowerCase() === name, ) : []; - // scoreboard rows carry main-weapon ids; a sub/special credit says - // nothing about which main the killer holds const byWeapon = - death.weaponId !== null && death.weaponType === "MAIN" - ? indices.filter((i) => players[i]!.weaponId === death.weaponId) + read.weaponId !== null + ? indices.filter((i) => players[i]!.weaponId === read.weaponId) : []; const both = byName.filter((i) => byWeapon.includes(i)); if (both.length > 0) return both[0]!; @@ -66,12 +74,65 @@ export function harvestAbilities( const abilities: PlayerAbilityMap = new Map(); for (const death of deaths) { if (death.abilities.length === 0) continue; - const index = matchPlayer(players, death); + const index = matchPlayer(players, deathRead(death)); if (index !== null) abilities.set(index, death.abilities); } return abilities; } +/** + * A death as a build read: scoreboard rows carry main-weapon ids, so a + * sub/special kill credit says nothing about which main the killer holds. + */ +function deathRead(death: DeathData): BuildRead { + return { + name: death.name, + weaponId: + death.weaponType === "MAIN" + ? (death.weaponId as MainWeaponId | null) + : null, + }; +} + +/** + * Harvest the gear mains one side's minimap cards reveal: a card shows the + * three mains only, and is attributed to a player row the way a death is — + * refusing an ambiguous read rather than guessing a seat. Cards come and go + * across a match's frames (cross-outs, absent slots), so each gear slot + * keeps the first badge that identified it. + */ +export function harvestCardMains( + players: readonly HarvestablePlayer[], + cards: readonly (BuildRead & { abilities: GearMains })[], +): Map { + const mains = new Map(); + for (const card of cards) { + if (card.abilities.length === 0) continue; + const index = matchPlayer(players, card); + if (index === null) continue; + const known = mains.get(index) ?? [null, null, null]; + mains.set( + index, + known.map((ability, slot) => betterRead(ability, card.abilities[slot])), + ); + } + for (const [index, build] of mains) { + if (build.every((ability) => ability === null)) mains.delete(index); + } + return mains; +} + +function betterRead( + known: AbilityWithUnknown | null, + incoming: AbilityWithUnknown | null | undefined, +): AbilityWithUnknown | null { + if (known !== null && known !== "UNKNOWN") return known; + if (incoming !== null && incoming !== undefined && incoming !== "UNKNOWN") { + return incoming; + } + return known ?? incoming ?? null; +} + /** * For each scoreboard/replay event, harvest abilities from the death events * since the previous scoreboard. Keyed by event object identity; events diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index 22aa5f99b..82a08615e 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -10,8 +10,16 @@ * `ingestSkipReasons` filters those. Deaths are harvested onto player rows * as enemy builds (ability-harvest.ts). */ -import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; -import { harvestAbilities } from "./ability-harvest"; +import type { + AbilityWithUnknown, + MainWeaponId, + StageId, +} from "~/modules/in-game-lists/types"; +import { + type GearMains, + harvestAbilities, + harvestCardMains, +} from "./ability-harvest"; import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index"; import { MAP_START_EVENT_TYPE, @@ -509,7 +517,7 @@ function toBuiltMatch( objective: progress.objective, playerStatus: progress.playerStatus, teams: board - ? teamsFromScoreboard(board, deaths) + ? teamsFromScoreboard(board, deaths, minimaps, progress.minimapEnemySide) : teamsFromMinimaps(minimaps, deaths), winner: board ? 0 : null, pov, @@ -577,6 +585,8 @@ function buildProgress( ): { objective: ScannerMatchObjective | null; playerStatus: ScannerMatchPlayerStatus | null; + /** the `teams` side the minimap's enemy column is; null with no scoreboard */ + minimapEnemySide: 0 | 1 | null; } { const dominant = dominantAnchorOf([...objectives, ...playerStatuses]); const live = withoutReplayReads(objectives, dominant); @@ -663,7 +673,11 @@ function buildProgress( ), }; - return { objective, playerStatus }; + return { + objective, + playerStatus, + minimapEnemySide: board ? (minimapSwapped ? 0 : 1) : null, + }; } /** The slot→row permutations of a scoreboard-closed match, per source. */ @@ -1247,10 +1261,16 @@ function playedAt( function teamsFromScoreboard( board: ScoreboardData, deaths: readonly DeathData[], + minimaps: readonly MinimapData[], + minimapEnemySide: 0 | 1 | null, ): [ScannerMatchTeam, ScannerMatchTeam] { const abilities = harvestAbilities(board.players, deaths); + const cardMains = + minimapEnemySide !== null + ? minimapMainsByRow(board, minimaps, minimapEnemySide) + : new Map(); const players = board.players.map((player, i): ScannerMatchPlayer => { - const build = abilities.get(i); + const build = mergeBuild(abilities.get(i), cardMains.get(i)); return { name: player.name.trim() || null, weaponId: player.weaponId, @@ -1294,7 +1314,7 @@ function teamsFromMinimaps( /** For each slot index, the first frame's non-null read of each field. */ function mergeSlots( - frames: Array>, + frames: Array>, ): ScannerMatchPlayer[] { const width = Math.max(0, ...frames.map((frame) => frame.length)); const out: ScannerMatchPlayer[] = []; @@ -1302,6 +1322,7 @@ function mergeSlots( const reads = frames .map((frame) => frame[i]) .filter((read) => read !== undefined); + const mains = mergeMains(reads.map((read) => read.abilities)); out.push({ name: reads @@ -1313,7 +1334,85 @@ function mergeSlots( ka: null, d: null, s: null, + ...(mains ? { abilities: mainsAsRows(mains) } : null), }); } return out; } + +/** the gear slots a build has, in card/death-screen order */ +const GEAR_SLOTS = [0, 1, 2]; + +interface MinimapCardRead { + name: string | null; + weaponId: MainWeaponId | null; + abilities: GearMains; +} + +/** + * Gear mains per scoreboard row, harvested from the match's minimap cards. + * A card's drawn position is no seat — a frame leaves absent and + * evidence-less cards out of its columns — so cards identify their row by + * name and weapon instead, within their own column's side (own/enemy is + * camera-stable, unlike the HUD plates). + */ +function minimapMainsByRow( + board: ScoreboardData, + minimaps: readonly MinimapData[], + enemySide: 0 | 1, +): Map { + const cards: [MinimapCardRead[], MinimapCardRead[]] = [[], []]; + for (const frame of minimaps) { + cards[enemySide].push(...frame.enemies); + cards[enemySide === 0 ? 1 : 0].push(...frame.teammates); + } + const mains = new Map(); + for (const side of [0, 1] as const) { + const rows = board.players.slice( + side * PLAYERS_PER_TEAM, + (side + 1) * PLAYERS_PER_TEAM, + ); + for (const [row, build] of harvestCardMains(rows, cards[side])) { + mains.set(side * PLAYERS_PER_TEAM + row, build); + } + } + return mains; +} + +/** + * The gear mains a set of card reads agree on: badges come and go with + * cross-outs and camo surfaces, so each slot takes its first identified + * read. Null when no read identified any of the three. + */ +function mergeMains(reads: readonly GearMains[]): GearMains | null { + const mains = GEAR_SLOTS.map((slot) => { + const read = reads + .map((abilities) => abilities[slot] ?? null) + .filter((ability) => ability !== null); + return read.find((ability) => ability !== "UNKNOWN") ?? read[0] ?? null; + }); + return mains.some((ability) => ability !== null) ? mains : null; +} + +/** Minimap cards show no sub slots, so each gear row holds its main alone. */ +function mainsAsRows(mains: GearMains): AbilityWithUnknown[][] { + return mains.map((main) => [main ?? "UNKNOWN"]); +} + +/** + * One player's gear rows: death screens read whole rows (main and subs), + * minimap cards only mains — so a death row stands and the cards fill in + * the mains it left unread. + */ +function mergeBuild( + death: AbilityWithUnknown[][] | undefined, + mains: GearMains | undefined, +): AbilityWithUnknown[][] | undefined { + if (!mains) return death; + if (!death) return mainsAsRows(mains); + return GEAR_SLOTS.map((slot) => { + const row = death[slot] ?? []; + if (row.length > 0 && row[0] !== "UNKNOWN") return row; + return [mains[slot] ?? row[0] ?? "UNKNOWN", ...row.slice(1)]; + }); +} diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts index 697cf017f..5905106d7 100644 --- a/app/features/scanner/core/scanner-match.ts +++ b/app/features/scanner/core/scanner-match.ts @@ -22,7 +22,10 @@ export interface ScannerMatchPlayer { ka: number | null; d: number | null; s: number | null; - /** [head, clothes, shoes] ability rows harvested from death screens */ + /** + * [head, clothes, shoes] ability rows: death screens read whole rows + * (main and subs), minimap cards their mains alone + */ abilities?: AbilityWithUnknown[][]; } diff --git a/app/features/scanner/scanner-schemas.ts b/app/features/scanner/scanner-schemas.ts index 503ae3575..07235a5b5 100644 --- a/app/features/scanner/scanner-schemas.ts +++ b/app/features/scanner/scanner-schemas.ts @@ -45,7 +45,7 @@ const scannerMatchPlayerSchema = v.object({ ka: v.nullable(v.number()), d: v.nullable(v.number()), s: v.nullable(v.number()), - /** [head, clothes, shoes] ability rows harvested from death screens */ + /** [head, clothes, shoes] ability rows; a row may hold its main alone */ abilities: v.optional( v.pipe( v.array(v.pipe(v.array(scannerAbilitySchema), v.maxLength(4))), diff --git a/app/features/scanner/tests/ability-harvest.test.ts b/app/features/scanner/tests/ability-harvest.test.ts index c758e5fd8..c8909bf62 100644 --- a/app/features/scanner/tests/ability-harvest.test.ts +++ b/app/features/scanner/tests/ability-harvest.test.ts @@ -2,7 +2,8 @@ * Unit tests for connectAbilities: deaths are attributed to the next * scoreboard event and matched to a player row by name/weapon, with * ambiguous matches (two players on the same weapon, misread name) - * left unattributed. + * left unattributed. harvestCardMains puts minimap cards through the same + * matching, merging their gear mains across the match's frames. */ import assert from "node:assert/strict"; @@ -10,7 +11,11 @@ import type { AbilityWithUnknown, MainWeaponId, } from "~/modules/in-game-lists/types"; -import { connectAbilities } from "../core/ability-harvest"; +import { + connectAbilities, + type GearMains, + harvestCardMains, +} from "../core/ability-harvest"; import { DEATH_EVENT_TYPE, type DeathData, @@ -131,3 +136,38 @@ test("unsorted input is handled and trailing deaths are dropped", () => { assert.deepEqual(abilities.get(7), GRID_B); assert.equal(abilities.size, 1); }); + +function card( + name: string | null, + weaponId: MainWeaponId | null, + abilities: GearMains, +) { + return { name, weaponId, abilities }; +} + +test("cards match rows by name and weapon like deaths do", () => { + const mains = harvestCardMains(PLAYERS, [ + card("Bravo", 50, ["ISM", "RSU", "SSU"]), // name+weapon → 1, not the 50 at 3 + card(null, 70, ["QR", "QSJ", "IRU"]), // unique weapon, nameless enemy card → 4 + card("garbled", 50, ["LDE", "LDE", "LDE"]), // ambiguous weapon → dropped + ]); + assert.deepEqual(mains.get(1), ["ISM", "RSU", "SSU"]); + assert.deepEqual(mains.get(4), ["QR", "QSJ", "IRU"]); + assert.equal(mains.size, 2); +}); + +test("each gear slot keeps the first identified badge across frames", () => { + const mains = harvestCardMains(PLAYERS, [ + card("Alpha", 40, [null, "UNKNOWN", "SSU"]), + card("Alpha", 40, ["ISM", "RSU", "QR"]), + ]); + assert.deepEqual(mains.get(0), ["ISM", "RSU", "SSU"]); +}); + +test("cards that identified no badge at all are left out", () => { + const mains = harvestCardMains(PLAYERS, [ + card("Alpha", 40, []), + card("Echo", 70, [null, null, null]), + ]); + assert.equal(mains.size, 0); +});