mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-25 12:35:18 -05:00
Show abilities on match card
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[][];
|
||||
}
|
||||
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user