Show abilities on match card
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-08-23 20:13:42 +03:00
parent 611aed4880
commit dcbe6a8730
7 changed files with 374 additions and 35 deletions

View File

@@ -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;

View File

@@ -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<SendStatus["state"], string> = {
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 (
<div className={styles.weapons}>
{leftWeapons.length > 0 ? <WeaponRow weapons={leftWeapons} /> : null}
<div
className={clsx(styles.weapons, {
[styles.withAbilities]: withAbilities,
})}
>
{leftWeapons.length > 0 ? (
<WeaponRow weapons={leftWeapons} withAbilities={withAbilities} />
) : null}
{leftWeapons.length > 0 && rightWeapons.length > 0 ? (
<span className={styles.vs}>vs</span>
) : null}
{rightWeapons.length > 0 ? <WeaponRow weapons={rightWeapons} /> : null}
{rightWeapons.length > 0 ? (
<WeaponRow weapons={rightWeapons} withAbilities={withAbilities} />
) : null}
</div>
);
}
/**
* 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 (
<div className={styles.weaponRow}>
{weapons.map((weapon, i) => (
<WeaponImage
<div
key={i}
weaponSplId={weapon.weaponId}
variant="build"
size={22}
className={clsx(styles.weapon, { [styles.pov]: weapon.pov })}
/>
className={clsx(styles.weaponSlot, {
[styles.withAbilities]: withAbilities,
})}
>
<WeaponImage
weaponSplId={weapon.weaponId}
variant="build"
size={28}
className={clsx(styles.weapon, { [styles.pov]: weapon.pov })}
/>
{withAbilities ? (
<AbilityArc
abilities={weapon.mainAbilities ?? UNKNOWN_MAIN_ABILITIES}
/>
) : null}
</div>
))}
</div>
);
}
/** Gear mains laid out as a half-moon hugging the weapon's lower edge. */
function AbilityArc({ abilities }: { abilities: AbilityWithUnknown[] }) {
return (
<div className={styles.abilityArc}>
{abilities.map((ability, i) => (
<span
key={i}
className={styles.arcSlot}
style={
{ "--arc-angle": ABILITY_ARC_ANGLES[i] } as React.CSSProperties
}
>
<Ability
ability={ability}
size="TINY"
className={styles.arcAbility}
/>
</span>
))}
</div>
);

View File

@@ -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<number, AbilityWithUnknown[][]>;
/** 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<number, GearMains> {
const mains = new Map<number, GearMains>();
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

View File

@@ -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<E extends DetectedEvent>(
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<number, GearMains>();
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<Array<{ name: string | null; weaponId: MainWeaponId | null }>>,
frames: Array<Array<MinimapCardRead>>,
): 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<number, GearMains> {
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<number, GearMains>();
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)];
});
}

View File

@@ -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[][];
}

View File

@@ -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))),

View File

@@ -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);
});