mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-09 23:25:30 -05:00
Death/Special
This commit is contained in:
parent
d9d0237c85
commit
a56f49042e
87
app/components/PlayerStatusTimeline.module.css
Normal file
87
app/components/PlayerStatusTimeline.module.css
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-2-5) var(--s-3);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
justify-content: center;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.legendSwatchDead,
|
||||
.legendSwatchSpecial {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.legendSwatchDead {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
.legendSwatchSpecial {
|
||||
background-color: var(--color-info);
|
||||
}
|
||||
|
||||
.team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.teamLabel {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.spanDead,
|
||||
.spanSpecial {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
min-width: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.spanDead {
|
||||
background-color: var(--color-error);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.spanSpecial {
|
||||
background-color: var(--color-info);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.unknownWeapon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
158
app/components/PlayerStatusTimeline.tsx
Normal file
158
app/components/PlayerStatusTimeline.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Per-player status bands over a game's scanned icon-strip reads: one row
|
||||
* per player (weapon icon as the label), a band while the player was
|
||||
* splatted and another while they held their special, both teams stacked.
|
||||
* Rendered above the ObjectiveTimeline chart on the same `t` seconds axis —
|
||||
* pass `domain` so both span the same range. Reads re-confirm an unchanged
|
||||
* state every few seconds; a longer sample gap means the HUD was not
|
||||
* observed, so bands never bridge across one (the state there is unknown,
|
||||
* not continued).
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { abilityImageUrl } from "~/utils/urls";
|
||||
import { Image, WeaponImage } from "./Image";
|
||||
import styles from "./PlayerStatusTimeline.module.css";
|
||||
|
||||
/** Consecutive reads further apart than this leave an unknown gap. */
|
||||
const MAX_BRIDGE_SECONDS = 15;
|
||||
|
||||
/** Trailing open band drawn this long past its last confirming read. */
|
||||
const TAIL_SECONDS = 1;
|
||||
|
||||
type PlayerFlags = readonly [boolean, boolean, boolean, boolean];
|
||||
|
||||
/** One icon-strip read, sides in `[alpha, bravo]` order. */
|
||||
export interface PlayerStatusTimelineSample {
|
||||
/** seconds into the source (video, stream or game) the read was made at */
|
||||
t: number;
|
||||
special: readonly [PlayerFlags, PlayerFlags];
|
||||
dead: readonly [PlayerFlags, PlayerFlags];
|
||||
}
|
||||
|
||||
export interface PlayerStatusTimelineTeam {
|
||||
label: string;
|
||||
/** weapon per slot in row order; null/absent slots render a placeholder */
|
||||
weapons: (MainWeaponId | null)[];
|
||||
}
|
||||
|
||||
export function PlayerStatusTimeline({
|
||||
samples,
|
||||
teams,
|
||||
domain,
|
||||
}: {
|
||||
samples: readonly PlayerStatusTimelineSample[];
|
||||
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
|
||||
/** x-axis range override, to share the objective chart's axis */
|
||||
domain?: [number, number];
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const sorted = samples.toSorted((a, b) => a.t - b.t);
|
||||
if (sorted.length === 0) return null;
|
||||
|
||||
const min = Math.min(domain?.[0] ?? Number.POSITIVE_INFINITY, sorted[0]!.t);
|
||||
const max = Math.max(
|
||||
domain?.[1] ?? 0,
|
||||
sorted[sorted.length - 1]!.t + TAIL_SECONDS,
|
||||
);
|
||||
const range = Math.max(1, max - min);
|
||||
const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`;
|
||||
const widthOf = (span: StatusSpan) =>
|
||||
`${((span.end - span.start) / range) * 100}%`;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.legend}>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={styles.legendSwatchDead} />
|
||||
{t("common:playerStatusTimeline.splatted")}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={styles.legendSwatchSpecial} />
|
||||
{t("common:playerStatusTimeline.specialReady")}
|
||||
</span>
|
||||
</div>
|
||||
{([0, 1] as const).map((side) => (
|
||||
<div key={side} className={styles.team}>
|
||||
<div className={styles.teamLabel}>{teams[side].label}</div>
|
||||
{[0, 1, 2, 3].map((slot) => (
|
||||
<div key={slot} className={styles.row}>
|
||||
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
|
||||
<div className={styles.track}>
|
||||
{statusSpans(sorted, (sample) => sample.dead[side][slot]!).map(
|
||||
(span, i) => (
|
||||
<div
|
||||
key={`d${i}`}
|
||||
className={styles.spanDead}
|
||||
style={{ left: leftOf(span), width: widthOf(span) }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{statusSpans(
|
||||
sorted,
|
||||
(sample) => sample.special[side][slot]!,
|
||||
).map((span, i) => (
|
||||
<div
|
||||
key={`s${i}`}
|
||||
className={styles.spanSpecial}
|
||||
style={{ left: leftOf(span), width: widthOf(span) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotWeapon({ weaponSplId }: { weaponSplId: MainWeaponId | null }) {
|
||||
if (weaponSplId === null) {
|
||||
return (
|
||||
<Image
|
||||
path={abilityImageUrl("UNKNOWN")}
|
||||
alt="?"
|
||||
size={22}
|
||||
className={styles.unknownWeapon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <WeaponImage weaponSplId={weaponSplId} variant="badge" size={22} />;
|
||||
}
|
||||
|
||||
interface StatusSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contiguous stretches where the flag held true: a span opens at its first
|
||||
* true read and closes at the read that shows false — or one second past
|
||||
* its last confirmation when the next read is too far away (or the series
|
||||
* ends) to know what happened in between.
|
||||
*/
|
||||
function statusSpans(
|
||||
sorted: readonly PlayerStatusTimelineSample[],
|
||||
flagOf: (sample: PlayerStatusTimelineSample) => boolean,
|
||||
): StatusSpan[] {
|
||||
const spans: StatusSpan[] = [];
|
||||
let start: number | null = null;
|
||||
let lastTrueT = 0;
|
||||
for (const sample of sorted) {
|
||||
const flag = flagOf(sample);
|
||||
if (start !== null && sample.t - lastTrueT > MAX_BRIDGE_SECONDS) {
|
||||
spans.push({ start, end: lastTrueT + TAIL_SECONDS });
|
||||
start = null;
|
||||
}
|
||||
if (flag) {
|
||||
start ??= sample.t;
|
||||
lastTrueT = sample.t;
|
||||
} else if (start !== null) {
|
||||
spans.push({ start, end: sample.t });
|
||||
start = null;
|
||||
}
|
||||
}
|
||||
if (start !== null) spans.push({ start, end: lastTrueT + TAIL_SECONDS });
|
||||
return spans;
|
||||
}
|
||||
|
|
@ -36,6 +36,10 @@ import {
|
|||
type ObjectiveTimelineEvent,
|
||||
} from "../ObjectiveTimeline";
|
||||
import { matchScoresFromObjective } from "../objective-timeline-utils";
|
||||
import {
|
||||
PlayerStatusTimeline,
|
||||
type PlayerStatusTimelineSample,
|
||||
} from "../PlayerStatusTimeline";
|
||||
import styles from "./MatchTimeline.module.css";
|
||||
import { type InferredSubstitution, inferSubstitutions } from "./utils";
|
||||
import type { WeaponPoolWeapon } from "./WeaponPool";
|
||||
|
|
@ -93,6 +97,8 @@ export interface TimelineMap {
|
|||
bravo: TimelineScoreboardPlayer[];
|
||||
/** Objective-counter reads ([alpha, bravo] values) charted above the stats tables. */
|
||||
objective?: ObjectiveTimelineEvent[];
|
||||
/** Per-player splat/special bands ([alpha, bravo]) charted above the objective chart. */
|
||||
playerStatus?: PlayerStatusTimelineSample[];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +448,22 @@ function TimelineScoreboardSection({
|
|||
</button>
|
||||
{isExpanded ? (
|
||||
<div className={styles.scoreboardPanel}>
|
||||
{scoreboard.playerStatus && scoreboard.playerStatus.length > 0 ? (
|
||||
<PlayerStatusTimeline
|
||||
samples={scoreboard.playerStatus}
|
||||
teams={[
|
||||
{
|
||||
label: teams.alpha.name,
|
||||
weapons: scoreboard.alpha.map((player) => player.weaponSplId),
|
||||
},
|
||||
{
|
||||
label: teams.bravo.name,
|
||||
weapons: scoreboard.bravo.map((player) => player.weaponSplId),
|
||||
},
|
||||
]}
|
||||
domain={objectiveEventsDomain(scoreboard.objective)}
|
||||
/>
|
||||
) : null}
|
||||
{scoreboard.objective && scoreboard.objective.length > 0 ? (
|
||||
<ObjectiveTimeline
|
||||
events={scoreboard.objective}
|
||||
|
|
@ -464,6 +486,14 @@ function TimelineScoreboardSection({
|
|||
);
|
||||
}
|
||||
|
||||
function objectiveEventsDomain(
|
||||
events: ObjectiveTimelineEvent[] | undefined,
|
||||
): [number, number] | undefined {
|
||||
if (!events || events.length === 0) return undefined;
|
||||
const ts = events.map((event) => event.t);
|
||||
return [Math.min(...ts), Math.max(...ts)];
|
||||
}
|
||||
|
||||
function ScoreboardTable({
|
||||
name,
|
||||
players,
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
|
|||
replayCode: null,
|
||||
cast: false,
|
||||
objective: null,
|
||||
playerStatus: null,
|
||||
teams: [
|
||||
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
|
||||
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
|
|||
replayCode: null,
|
||||
cast: false,
|
||||
objective: null,
|
||||
playerStatus: null,
|
||||
teams: [
|
||||
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
|
||||
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },
|
||||
|
|
@ -283,3 +284,53 @@ describe("mergeMatches", () => {
|
|||
expect(merged.matchScores).toEqual([100, 52]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("playerStatus", () => {
|
||||
const STATUS: NonNullable<ScannerMatch["playerStatus"]> = {
|
||||
samples: [
|
||||
{
|
||||
t: 60,
|
||||
time: 240,
|
||||
special: [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false],
|
||||
],
|
||||
dead: [
|
||||
[false, false, false, false],
|
||||
[false, true, false, false],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("merges whole-series first-ingest-wins", () => {
|
||||
const filled = Matches.mergeMatches(
|
||||
testMatch(),
|
||||
testMatch({ playerStatus: STATUS }),
|
||||
);
|
||||
expect(filled.merged.playerStatus).toEqual(STATUS);
|
||||
expect(filled.changed).toBe(true);
|
||||
|
||||
const kept = Matches.mergeMatches(
|
||||
testMatch({ playerStatus: STATUS }),
|
||||
testMatch({ playerStatus: { samples: [] } }),
|
||||
);
|
||||
expect(kept.merged.playerStatus).toEqual(STATUS);
|
||||
});
|
||||
|
||||
it("side-aligning an incoming match swaps its status samples too", () => {
|
||||
const incoming = sideSwapped(testMatch({ playerStatus: STATUS }));
|
||||
const { merged } = Matches.mergeMatches(
|
||||
testMatch({ winner: null, matchScores: null, playerStatus: null }),
|
||||
incoming,
|
||||
);
|
||||
expect(merged.playerStatus!.samples[0]!.dead).toEqual([
|
||||
[false, true, false, false],
|
||||
[false, false, false, false],
|
||||
]);
|
||||
expect(merged.playerStatus!.samples[0]!.special).toEqual([
|
||||
[false, false, false, false],
|
||||
[true, false, false, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
ScannerMatch,
|
||||
ScannerMatchObjective,
|
||||
ScannerMatchPlayer,
|
||||
ScannerMatchPlayerStatus,
|
||||
ScannerMatchTeam,
|
||||
} from "~/features/scanner/core/scanner-match";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
|
|
@ -52,6 +53,11 @@ export function canonicalMatch(match: ScannerMatch): ScannerMatch {
|
|||
cast: match.cast,
|
||||
objective:
|
||||
match.objective === null ? null : canonicalObjective(match.objective),
|
||||
// `?? null` also normalizes rows stored before the field existed
|
||||
playerStatus:
|
||||
match.playerStatus == null
|
||||
? null
|
||||
: canonicalPlayerStatus(match.playerStatus),
|
||||
teams: [canonicalTeam(match.teams[0]), canonicalTeam(match.teams[1])],
|
||||
winner: match.winner,
|
||||
pov:
|
||||
|
|
@ -128,6 +134,7 @@ export function mergeMatches(
|
|||
// whole-series first-ingest-wins: interleaving two partial sample
|
||||
// series from different scans is not attempted
|
||||
objective: existing.objective ?? oriented.objective,
|
||||
playerStatus: existing.playerStatus ?? oriented.playerStatus,
|
||||
teams: [
|
||||
mergeTeam(existing.teams[0], oriented.teams[0]),
|
||||
mergeTeam(existing.teams[1], oriented.teams[1]),
|
||||
|
|
@ -167,6 +174,19 @@ function canonicalObjective(
|
|||
};
|
||||
}
|
||||
|
||||
function canonicalPlayerStatus(
|
||||
playerStatus: ScannerMatchPlayerStatus,
|
||||
): ScannerMatchPlayerStatus {
|
||||
return {
|
||||
samples: playerStatus.samples.map((sample) => ({
|
||||
t: sample.t,
|
||||
time: sample.time,
|
||||
special: [[...sample.special[0]], [...sample.special[1]]],
|
||||
dead: [[...sample.dead[0]], [...sample.dead[1]]],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalTeam(team: ScannerMatchTeam): ScannerMatchTeam {
|
||||
return {
|
||||
players: team.players.map(canonicalPlayer),
|
||||
|
|
@ -291,6 +311,16 @@ function swapSides(match: ScannerMatch): ScannerMatch {
|
|||
control: [sample.control[1], sample.control[0]],
|
||||
})),
|
||||
},
|
||||
playerStatus:
|
||||
match.playerStatus == null
|
||||
? null
|
||||
: {
|
||||
samples: match.playerStatus.samples.map((sample) => ({
|
||||
...sample,
|
||||
special: [sample.special[1], sample.special[0]],
|
||||
dead: [sample.dead[1], sample.dead[0]],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
ScannerMatch,
|
||||
ScannerMatchObjective,
|
||||
ScannerMatchPlayer,
|
||||
ScannerMatchPlayerStatus,
|
||||
} from "~/features/scanner/core/scanner-match";
|
||||
import type { ScannerLobby } from "~/features/scanner/scanner-types";
|
||||
import type {
|
||||
|
|
@ -58,6 +59,7 @@ function testMatch({
|
|||
abilities = {},
|
||||
povIndex = null,
|
||||
objective = null,
|
||||
playerStatus = null,
|
||||
}: {
|
||||
t?: number;
|
||||
mode?: ModeShort | null;
|
||||
|
|
@ -68,6 +70,7 @@ function testMatch({
|
|||
abilities?: Record<number, AbilityWithUnknown[][]>;
|
||||
povIndex?: number | null;
|
||||
objective?: ScannerMatchObjective | null;
|
||||
playerStatus?: ScannerMatchPlayerStatus | null;
|
||||
} = {}): ScannerMatch {
|
||||
const players = names.map(
|
||||
(name, i): ScannerMatchPlayer => ({
|
||||
|
|
@ -91,6 +94,7 @@ function testMatch({
|
|||
replayCode: null,
|
||||
cast: false,
|
||||
objective,
|
||||
playerStatus,
|
||||
teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }],
|
||||
winner: 0,
|
||||
pov:
|
||||
|
|
@ -122,6 +126,25 @@ function testObjective(): ScannerMatchObjective {
|
|||
};
|
||||
}
|
||||
|
||||
function testPlayerStatus(): ScannerMatchPlayerStatus {
|
||||
return {
|
||||
samples: [
|
||||
{
|
||||
t: 595,
|
||||
time: 305,
|
||||
special: [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false],
|
||||
],
|
||||
dead: [
|
||||
[false, false, false, false],
|
||||
[false, true, false, false],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The same game reported with sides in the other on-screen order. */
|
||||
function swapSides(match: ScannerMatch): ScannerMatch {
|
||||
return {
|
||||
|
|
@ -139,6 +162,16 @@ function swapSides(match: ScannerMatch): ScannerMatch {
|
|||
control: [sample.control[1], sample.control[0]],
|
||||
})),
|
||||
},
|
||||
playerStatus:
|
||||
match.playerStatus === null
|
||||
? null
|
||||
: {
|
||||
samples: match.playerStatus.samples.map((sample) => ({
|
||||
...sample,
|
||||
special: [sample.special[1], sample.special[0]],
|
||||
dead: [sample.dead[1], sample.dead[0]],
|
||||
})),
|
||||
},
|
||||
winner: match.winner === null ? null : match.winner === 0 ? 1 : 0,
|
||||
matchScores:
|
||||
match.matchScores === null
|
||||
|
|
@ -482,6 +515,43 @@ describe("deriveScoreboardData", () => {
|
|||
expect(swapped!.objective).toEqual(straight!.objective);
|
||||
});
|
||||
|
||||
it("rebases status samples onto the same origin as the counter's", () => {
|
||||
const data = derive([
|
||||
{
|
||||
data: testMatch({
|
||||
objective: testObjective(),
|
||||
playerStatus: testPlayerStatus(),
|
||||
}),
|
||||
povUserId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
// the status read at 595 came first, so it is the shared origin
|
||||
expect(data!.playerStatus!.samples[0]!.t).toBe(0);
|
||||
expect(data!.objective!.samples.map((sample) => sample.t)).toEqual([5, 35]);
|
||||
});
|
||||
|
||||
it("derives status samples winner-first", () => {
|
||||
const straight = derive([
|
||||
{
|
||||
data: testMatch({ playerStatus: testPlayerStatus() }),
|
||||
povUserId: null,
|
||||
},
|
||||
]);
|
||||
const swapped = derive([
|
||||
{
|
||||
data: swapSides(testMatch({ playerStatus: testPlayerStatus() })),
|
||||
povUserId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(straight!.playerStatus!.samples[0]!.dead).toEqual([
|
||||
[false, false, false, false],
|
||||
[false, true, false, false],
|
||||
]);
|
||||
expect(swapped!.playerStatus).toEqual(straight!.playerStatus);
|
||||
});
|
||||
|
||||
it("leaves out the objective of a match with no counter reads", () => {
|
||||
const data = derive([{ data: testMatch(), povUserId: null }]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
ScannerMatch,
|
||||
ScannerMatchObjective,
|
||||
ScannerMatchPlayerStatus,
|
||||
} from "~/features/scanner/core/scanner-match";
|
||||
import type { ScannerLobby } from "~/features/scanner/scanner-types";
|
||||
import type {
|
||||
|
|
@ -219,6 +220,12 @@ export interface IngestedScoreboardData {
|
|||
* was read.
|
||||
*/
|
||||
objective?: ScannerMatchObjective;
|
||||
/**
|
||||
* per-player special/death samples, teams winner-first and `t` rebased
|
||||
* onto the same origin as `objective` so both chart on one axis. Absent
|
||||
* when the icon strip was never read.
|
||||
*/
|
||||
playerStatus?: ScannerMatchPlayerStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -271,6 +278,7 @@ export function deriveScoreboardData({
|
|||
scores: view.scores,
|
||||
players,
|
||||
...(view.objective ? { objective: view.objective } : null),
|
||||
...(view.playerStatus ? { playerStatus: view.playerStatus } : null),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +306,8 @@ interface WinnerFirstView {
|
|||
players: WinnerFirstPlayer[];
|
||||
/** counter progress with both the sides and `t` already winner-first */
|
||||
objective: ScannerMatchObjective | null;
|
||||
/** status samples winner-first, on the same rebased `t` axis */
|
||||
playerStatus: ScannerMatchPlayerStatus | null;
|
||||
povIndex: number | null;
|
||||
/** chronological walk key: wall-clock, else video time, else input order */
|
||||
order: number;
|
||||
|
|
@ -331,6 +341,8 @@ function winnerFirstView(
|
|||
return null;
|
||||
}
|
||||
|
||||
const progressFirstT = firstProgressT(match);
|
||||
|
||||
return {
|
||||
lobby: match.lobby,
|
||||
mode: match.mode,
|
||||
|
|
@ -343,7 +355,16 @@ function winnerFirstView(
|
|||
...player,
|
||||
name: player.name ?? "",
|
||||
})),
|
||||
objective: winnerFirstObjective(match.objective, match.winner),
|
||||
objective: winnerFirstObjective(
|
||||
match.objective,
|
||||
match.winner,
|
||||
progressFirstT,
|
||||
),
|
||||
playerStatus: winnerFirstPlayerStatus(
|
||||
match.playerStatus ?? null,
|
||||
match.winner,
|
||||
progressFirstT,
|
||||
),
|
||||
povIndex:
|
||||
match.pov === null
|
||||
? null
|
||||
|
|
@ -354,20 +375,34 @@ function winnerFirstView(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared `t` origin of a match's progress series: the earliest counter
|
||||
* or status read, so both rebase onto one axis and stay aligned without
|
||||
* the source video.
|
||||
*/
|
||||
function firstProgressT(match: ScannerMatch): number {
|
||||
const ts = [
|
||||
...(match.objective?.samples ?? []).map((sample) => sample.t),
|
||||
...(match.playerStatus?.samples ?? []).map((sample) => sample.t),
|
||||
];
|
||||
return ts.length > 0 ? Math.min(...ts) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a match's counter samples in derived-scoreboard shape: per-team
|
||||
* values winner-first like `scores` and `players`, and `t` rebased to the
|
||||
* game's first read so the samples stay meaningful without the source video.
|
||||
* game's first progress read so the samples stay meaningful without the
|
||||
* source video.
|
||||
*/
|
||||
function winnerFirstObjective(
|
||||
objective: ScannerMatchObjective | null,
|
||||
winner: 0 | 1,
|
||||
firstT: number,
|
||||
): ScannerMatchObjective | null {
|
||||
if (!objective || objective.samples.length === 0) return null;
|
||||
|
||||
const winnerFirst = <T>(pair: [T, T]): [T, T] =>
|
||||
winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]];
|
||||
const firstT = Math.min(...objective.samples.map((sample) => sample.t));
|
||||
|
||||
return {
|
||||
mode: objective.mode,
|
||||
|
|
@ -381,6 +416,27 @@ function winnerFirstObjective(
|
|||
};
|
||||
}
|
||||
|
||||
/** The status samples winner-first on the shared rebased `t` axis. */
|
||||
function winnerFirstPlayerStatus(
|
||||
playerStatus: ScannerMatchPlayerStatus | null,
|
||||
winner: 0 | 1,
|
||||
firstT: number,
|
||||
): ScannerMatchPlayerStatus | null {
|
||||
if (!playerStatus || playerStatus.samples.length === 0) return null;
|
||||
|
||||
const winnerFirst = <T>(pair: [T, T]): [T, T] =>
|
||||
winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]];
|
||||
|
||||
return {
|
||||
samples: playerStatus.samples.map((sample) => ({
|
||||
t: sample.t - firstT,
|
||||
time: sample.time,
|
||||
special: winnerFirst(sample.special),
|
||||
dead: winnerFirst(sample.dead),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attributes each linked match's POV seat to its POV user on the merged
|
||||
* rows: the seat's read name picks the row (unique name match), falling
|
||||
|
|
|
|||
|
|
@ -83,7 +83,13 @@ sequenceDiagram
|
|||
(respawn overlay), `map-start` (match intro), `minimap` (in-match overlay
|
||||
+ casted 8-player spectator variant), `objective` (ranked counter overlay:
|
||||
counts, penalties, holder, match timer — a mode-discriminated union with
|
||||
only the SZ member so far). Objective reads land on `ScannerMatch` as
|
||||
only the SZ member so far). The objective parse also emits a second
|
||||
event type per read: `PlayerStatus`
|
||||
(`core/detectors/objective/player-status.ts`), per-player special/dead
|
||||
flags off the icon strip flanking the timer (POV and casted spectator
|
||||
geometries; the cast layout is picked by its D-pad camera badges), with
|
||||
the same `time` value so the two reads pair downstream; its fixtures
|
||||
live under `tests/fixtures/player-status/`. Objective reads land on `ScannerMatch` as
|
||||
progress samples anchored to the game clock; broadcast replay wipes re-run
|
||||
an earlier moment with the counter intact, so the builder keeps only the
|
||||
dominant cluster of clock-zero projections (`t + time`) and drops replay
|
||||
|
|
@ -99,7 +105,12 @@ sequenceDiagram
|
|||
whose detected mode is not SZ are lookalike misreads: the builder nulls
|
||||
that match's `objective` and callers discard the events
|
||||
(`invalidObjectiveEvents`; Live also stops collecting once a MapStart
|
||||
reveals a non-SZ mode). Parsing details are in each detector's module
|
||||
reveals a non-SZ mode). PlayerStatus reads follow the objective pipeline
|
||||
wholesale: same replay-wipe anchor, cast orientation inherited from the
|
||||
nearest counter read, nulled together on non-SZ matches, and rendered as
|
||||
per-player splat/special bands (`~/components/PlayerStatusTimeline.tsx`,
|
||||
shared with the match page) above the objective chart. Parsing details
|
||||
are in each detector's module
|
||||
header; accuracy-critical matching internals in `core/glyphs.ts` and
|
||||
`core/detectors/scoreboard/weapons.ts` — read those before touching
|
||||
recognition code.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ import {
|
|||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
} from "../core/detectors/objective/index";
|
||||
import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
type PlayerStatusData,
|
||||
} from "../core/detectors/objective/player-status";
|
||||
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
|
||||
import {
|
||||
SCOREBOARD_OWN_EVENT_TYPE,
|
||||
|
|
@ -40,6 +44,7 @@ import { useEventTimeFormatter } from "./format";
|
|||
import { MapStartCard } from "./MapStartCard";
|
||||
import { MinimapCard } from "./MinimapCard";
|
||||
import { ObjectiveCard } from "./ObjectiveCard";
|
||||
import { PlayerStatusCard } from "./PlayerStatusCard";
|
||||
import { ScoreboardCard } from "./ScoreboardCard";
|
||||
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
|
||||
|
||||
|
|
@ -147,6 +152,8 @@ function renderCard(
|
|||
<MinimapCard {...shared} data={data as MinimapData} />
|
||||
) : type === OBJECTIVE_EVENT_TYPE ? (
|
||||
<ObjectiveCard {...shared} data={data as ObjectiveData} />
|
||||
) : type === PLAYER_STATUS_EVENT_TYPE ? (
|
||||
<PlayerStatusCard {...shared} data={data as PlayerStatusData} />
|
||||
) : (
|
||||
<ScoreboardCard
|
||||
{...shared}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
|
||||
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
|
||||
import { PlayerStatusTimeline } from "~/components/PlayerStatusTimeline";
|
||||
import {
|
||||
listVideoInputs,
|
||||
openVirtualCamera,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
} from "../core/detectors/map-start/index";
|
||||
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
|
||||
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
|
||||
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
|
||||
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
|
||||
import type { DetectedEvent, GateResult } from "../core/detectors/types";
|
||||
import type { BuiltMatch } from "../core/match-builder";
|
||||
|
|
@ -43,6 +45,7 @@ import { downloadEventsCsv } from "./events-csv";
|
|||
import { type FixtureData, saveFixture } from "./fixture-export";
|
||||
import { MatchCard } from "./MatchCard";
|
||||
import { MatchLobbyTabs } from "./MatchLobbyTabs";
|
||||
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
|
||||
import {
|
||||
aggregateSendStatus,
|
||||
matchContaining,
|
||||
|
|
@ -197,7 +200,8 @@ export function LivePage({
|
|||
for (const event of result.events as DetectedEvent<FixtureData>[]) {
|
||||
latestParseRef.current = { type: event.type, data: event.data };
|
||||
if (
|
||||
event.type === OBJECTIVE_EVENT_TYPE &&
|
||||
(event.type === OBJECTIVE_EVENT_TYPE ||
|
||||
event.type === PLAYER_STATUS_EVENT_TYPE) &&
|
||||
objectiveBlockedRef.current
|
||||
) {
|
||||
continue;
|
||||
|
|
@ -407,8 +411,11 @@ export function LivePage({
|
|||
const objectiveEvents = (
|
||||
built.match.objective?.samples ?? []
|
||||
).map((sample) => ({ t: sample.t, data: sample }));
|
||||
const statusSamples = built.match.playerStatus?.samples ?? [];
|
||||
const cardEvents = withoutRepeatEvents(built.sources).filter(
|
||||
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
|
||||
(e) =>
|
||||
e.type !== OBJECTIVE_EVENT_TYPE &&
|
||||
e.type !== PLAYER_STATUS_EVENT_TYPE,
|
||||
);
|
||||
const newest = built === builtMatches.at(-1);
|
||||
return (
|
||||
|
|
@ -425,6 +432,16 @@ export function LivePage({
|
|||
: undefined
|
||||
}
|
||||
>
|
||||
{statusSamples.length > 0 ? (
|
||||
<PlayerStatusTimeline
|
||||
samples={statusSamples}
|
||||
teams={playerStatusTeams(
|
||||
built.match,
|
||||
SCANNER_TEAM_LABELS,
|
||||
)}
|
||||
domain={objectiveDomain(objectiveEvents)}
|
||||
/>
|
||||
) : null}
|
||||
{objectiveEvents.length > 0 ? (
|
||||
<ObjectiveTimeline
|
||||
events={objectiveEvents}
|
||||
|
|
|
|||
51
app/features/scanner/components/PlayerStatusCard.tsx
Normal file
51
app/features/scanner/components/PlayerStatusCard.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
type PlayerStatusData,
|
||||
} from "../core/detectors/objective/player-status";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import { formatClock, useEventTimeFormatter } from "./format";
|
||||
import { MetaPills } from "./MetaChips";
|
||||
|
||||
export function PlayerStatusCard(props: {
|
||||
t: number;
|
||||
confidence: number;
|
||||
data: PlayerStatusData;
|
||||
thumbnail?: string;
|
||||
detectedAt?: number;
|
||||
/** lazy loader for the exact analyzed frame — enables fixture export */
|
||||
getFrame?: () => Promise<Blob | null | undefined>;
|
||||
onInspect?: () => void;
|
||||
}) {
|
||||
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
|
||||
props;
|
||||
const side = (index: 0 | 1) =>
|
||||
data.dead[index]
|
||||
.map((dead, slot) => (dead ? "✕" : data.special[index][slot] ? "★" : "·"))
|
||||
.join("");
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
type={PLAYER_STATUS_EVENT_TYPE}
|
||||
label={`players (${data.layout})`}
|
||||
/>
|
||||
<span>
|
||||
{data.time !== null ? `${formatClock(data.time)} · ` : null}
|
||||
<b>
|
||||
{side(0)} – {side(1)}
|
||||
</b>
|
||||
</span>
|
||||
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
|
||||
<FrameThumb
|
||||
thumbnail={thumbnail}
|
||||
getFrame={getFrame}
|
||||
onInspect={onInspect}
|
||||
fixture={{ data, type: PLAYER_STATUS_EVENT_TYPE }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,10 +22,12 @@ import { SendouButton } from "~/components/elements/Button";
|
|||
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
|
||||
import { PlayerStatusTimeline } from "~/components/PlayerStatusTimeline";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
|
||||
import { connectAbilities } from "../core/ability-harvest";
|
||||
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
|
||||
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
|
||||
import {
|
||||
mergeScanTelemetry,
|
||||
type ScanTelemetry,
|
||||
|
|
@ -62,6 +64,7 @@ import type { FixtureData } from "./fixture-export";
|
|||
import { formatTime, useEventDateTimeFormatter } from "./format";
|
||||
import { MatchCard } from "./MatchCard";
|
||||
import { MatchLobbyTabs } from "./MatchLobbyTabs";
|
||||
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
|
||||
import {
|
||||
countIngestableMatches,
|
||||
type SendouUser,
|
||||
|
|
@ -739,8 +742,11 @@ export function VodPage({
|
|||
const objectiveEvents = (
|
||||
built.match.objective?.samples ?? []
|
||||
).map((sample) => ({ t: sample.t, data: sample }));
|
||||
const statusSamples = built.match.playerStatus?.samples ?? [];
|
||||
const cardEvents = withoutRepeatEvents(built.sources).filter(
|
||||
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
|
||||
(e) =>
|
||||
e.type !== OBJECTIVE_EVENT_TYPE &&
|
||||
e.type !== PLAYER_STATUS_EVENT_TYPE,
|
||||
);
|
||||
return (
|
||||
<MatchCard
|
||||
|
|
@ -756,6 +762,16 @@ export function VodPage({
|
|||
send?.state === "sent" && link ? { ...send, link } : send
|
||||
}
|
||||
>
|
||||
{statusSamples.length > 0 ? (
|
||||
<PlayerStatusTimeline
|
||||
samples={statusSamples}
|
||||
teams={playerStatusTeams(
|
||||
built.match,
|
||||
SCANNER_TEAM_LABELS,
|
||||
)}
|
||||
domain={objectiveDomain(objectiveEvents)}
|
||||
/>
|
||||
) : null}
|
||||
{objectiveEvents.length > 0 ? (
|
||||
<ObjectiveTimeline
|
||||
events={objectiveEvents}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import {
|
|||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
} from "../core/detectors/objective/index";
|
||||
import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
type PlayerStatusData,
|
||||
} from "../core/detectors/objective/player-status";
|
||||
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
|
||||
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import {
|
||||
|
|
@ -39,7 +43,8 @@ export type FixtureData =
|
|||
| MapStartData
|
||||
| ScoreboardOwnData
|
||||
| MinimapData
|
||||
| ObjectiveData;
|
||||
| ObjectiveData
|
||||
| PlayerStatusData;
|
||||
|
||||
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
|
||||
return eventType === DEATH_EVENT_TYPE;
|
||||
|
|
@ -148,6 +153,22 @@ function buildExpectedJson(
|
|||
2,
|
||||
)}\n`;
|
||||
}
|
||||
if (eventType === PLAYER_STATUS_EVENT_TYPE) {
|
||||
const status = data as PlayerStatusData;
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
event: eventType,
|
||||
data: {
|
||||
layout: status.layout,
|
||||
time: status.time,
|
||||
special: status.special,
|
||||
dead: status.dead,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
// NB: not a type-predicate helper — CardData is structurally assignable to
|
||||
// MapStartData, so a predicate would narrow the fall-through case to never
|
||||
if (eventType === MAP_START_EVENT_TYPE) {
|
||||
|
|
|
|||
27
app/features/scanner/components/player-status-view.ts
Normal file
27
app/features/scanner/components/player-status-view.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Prop derivation for rendering a ScannerMatch's status samples with the
|
||||
* shared <PlayerStatusTimeline />, used by the Live and VoD tabs.
|
||||
*/
|
||||
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
|
||||
import type { ScannerMatch } from "../core/scanner-match";
|
||||
|
||||
/** Row weapons per team from the match's known players, slots by index. */
|
||||
export function playerStatusTeams(
|
||||
match: ScannerMatch,
|
||||
labels: readonly [string, string],
|
||||
): [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam] {
|
||||
return [0, 1].map((side) => ({
|
||||
label: labels[side]!,
|
||||
weapons: [0, 1, 2, 3].map(
|
||||
(slot) => match.teams[side as 0 | 1].players[slot]?.weaponId ?? null,
|
||||
),
|
||||
})) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
|
||||
}
|
||||
|
||||
/** The objective chart's x-range, so both timelines share one axis. */
|
||||
export function objectiveDomain(
|
||||
events: readonly { t: number }[],
|
||||
): [number, number] | undefined {
|
||||
if (events.length === 0) return undefined;
|
||||
return [events[0]!.t, events[events.length - 1]!.t];
|
||||
}
|
||||
|
|
@ -15,6 +15,11 @@
|
|||
* `ObjectiveData` is a discriminated union on `mode`; only SZ exists so
|
||||
* far. Identifying mode from the badge between the plates awaits TC/RM/CB
|
||||
* fixtures.
|
||||
*
|
||||
* Every successful counter read additionally emits a PlayerStatus event
|
||||
* (player-status.ts) parsed off the per-player icon strip of the same
|
||||
* frame — the counter parse carries the lookalike rejection for both, and
|
||||
* the shared timer value pairs the two events downstream.
|
||||
*/
|
||||
import { getCV, type Mat, minMaxLoc } from "../../cv";
|
||||
import {
|
||||
|
|
@ -39,6 +44,7 @@ import {
|
|||
} from "../scoreboard/banner";
|
||||
import type { ScoreboardResources } from "../scoreboard/index";
|
||||
import type { DetectedEvent, Detector, GateResult } from "../types";
|
||||
import { type PlayerStatusData, parsePlayerStatus } from "./player-status";
|
||||
import {
|
||||
CONTROL_PLATE_MIN_SATURATION,
|
||||
GATE_PLATE_MAX_STD,
|
||||
|
|
@ -61,7 +67,7 @@ import {
|
|||
TIMER_DIGIT_MIN_CONF,
|
||||
TIMER_DIGIT_MIN_HEIGHT_RATIO,
|
||||
TIMER_DIGIT_ROI,
|
||||
TIMER_TEXT_HEIGHT,
|
||||
TIMER_TEXT_HEIGHTS,
|
||||
} from "./rois";
|
||||
|
||||
export type ObjectiveData = SplatZonesObjectiveData;
|
||||
|
|
@ -126,7 +132,7 @@ interface SideRead {
|
|||
|
||||
export function createObjectiveDetector(
|
||||
resources: ScoreboardResources,
|
||||
): Detector<ObjectiveData> {
|
||||
): Detector<ObjectiveData | PlayerStatusData> {
|
||||
const cv = getCV();
|
||||
|
||||
const scoreSets: GlyphSet[] = resources.paintDigits
|
||||
|
|
@ -143,12 +149,14 @@ export function createObjectiveDetector(
|
|||
PENALTY_TEXT_HEIGHT / resources.paintDigits.height,
|
||||
)
|
||||
: null;
|
||||
const timerSet: GlyphSet | null = resources.paintDigits
|
||||
? scaleGlyphSet(
|
||||
resources.paintDigits,
|
||||
TIMER_TEXT_HEIGHT / resources.paintDigits.height,
|
||||
const timerSets: GlyphSet[] = resources.paintDigits
|
||||
? TIMER_TEXT_HEIGHTS.map((h) =>
|
||||
scaleGlyphSet(
|
||||
resources.paintDigits!,
|
||||
h / resources.paintDigits!.height,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
: [];
|
||||
|
||||
/** Mean and standard deviation of a grayscale ROI. */
|
||||
function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } {
|
||||
|
|
@ -236,34 +244,47 @@ export function createObjectiveDetector(
|
|||
* The match timer's M:SS over TIMER_DIGIT_ROI: white digits on the
|
||||
* near-black box the gate already anchored on. The colon's two dots stack
|
||||
* to well under the digit height floor, so a valid read is exactly three
|
||||
* full-height digits — the minute, then the two second digits.
|
||||
* full-height digits — the minute, then the two second digits. Each glyph
|
||||
* size is tried (the digits render bigger on upscaled 720p footage) and
|
||||
* the valid read with the most confident digits wins.
|
||||
*/
|
||||
function readTimer(gray: Mat): { value: number | null; reading: string } {
|
||||
if (!timerSet) return { value: null, reading: "" };
|
||||
const band = copyRoi(gray, TIMER_DIGIT_ROI);
|
||||
const raw = recognizeText(band, timerSet, {
|
||||
binThreshold: TIMER_BIN_THRESHOLD,
|
||||
spaceGap: Number.POSITIVE_INFINITY,
|
||||
minCharScore: 0.3,
|
||||
});
|
||||
band.delete();
|
||||
const isTimerDigit = (c: RecognizedChar) =>
|
||||
c.score >= TIMER_DIGIT_MIN_CONF &&
|
||||
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
|
||||
const digits = raw.chars.filter(isTimerDigit).map((c) => Number(c.char));
|
||||
if (digits.length !== 3 || digits.some(Number.isNaN)) {
|
||||
return { value: null, reading: raw.text };
|
||||
}
|
||||
const [minutes, secondsTens, secondsOnes] = digits as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (secondsTens >= 6) return { value: null, reading: raw.text };
|
||||
return {
|
||||
value: minutes * 60 + secondsTens * 10 + secondsOnes,
|
||||
reading: raw.text,
|
||||
let best: { value: number | null; reading: string; score: number } = {
|
||||
value: null,
|
||||
reading: "",
|
||||
score: 0,
|
||||
};
|
||||
for (const timerSet of timerSets) {
|
||||
const raw = recognizeText(band, timerSet, {
|
||||
binThreshold: TIMER_BIN_THRESHOLD,
|
||||
spaceGap: Number.POSITIVE_INFINITY,
|
||||
minCharScore: 0.3,
|
||||
});
|
||||
if (!best.reading) best = { ...best, reading: raw.text };
|
||||
const isTimerDigit = (c: RecognizedChar) =>
|
||||
c.score >= TIMER_DIGIT_MIN_CONF &&
|
||||
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
|
||||
const chars = raw.chars.filter(isTimerDigit);
|
||||
const digits = chars.map((c) => Number(c.char));
|
||||
if (digits.length !== 3 || digits.some(Number.isNaN)) continue;
|
||||
const [minutes, secondsTens, secondsOnes] = digits as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (secondsTens >= 6) continue;
|
||||
const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length;
|
||||
if (score > best.score) {
|
||||
best = {
|
||||
value: minutes * 60 + secondsTens * 10 + secondsOnes,
|
||||
reading: raw.text,
|
||||
score,
|
||||
};
|
||||
}
|
||||
}
|
||||
band.delete();
|
||||
return { value: best.value, reading: best.reading };
|
||||
}
|
||||
|
||||
/** Penalty pill: presence probes first, then the white "+N" digits. */
|
||||
|
|
@ -315,7 +336,10 @@ export function createObjectiveDetector(
|
|||
return { mean: sum / count, saturation: satSum / count };
|
||||
}
|
||||
|
||||
function parse(frame: Mat, t: number): DetectedEvent<ObjectiveData>[] {
|
||||
function parse(
|
||||
frame: Mat,
|
||||
t: number,
|
||||
): DetectedEvent<ObjectiveData | PlayerStatusData>[] {
|
||||
const gray = new cv.Mat();
|
||||
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
|
||||
|
||||
|
|
@ -349,6 +373,9 @@ export function createObjectiveDetector(
|
|||
...(side.penalty?.value != null ? [side.penalty.confidence] : []),
|
||||
]);
|
||||
return [
|
||||
// the icon-strip statuses ride along with every counter read: the
|
||||
// count confirmation above is the shared lookalike rejection, and
|
||||
// the shared timer value is what pairs the two events downstream
|
||||
{
|
||||
type: OBJECTIVE_EVENT_TYPE,
|
||||
t,
|
||||
|
|
@ -372,6 +399,7 @@ export function createObjectiveDetector(
|
|||
plateFills: sides.map((side) => side.fill),
|
||||
},
|
||||
},
|
||||
parsePlayerStatus(frame, t, timer.value),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
208
app/features/scanner/core/detectors/objective/player-status.ts
Normal file
208
app/features/scanner/core/detectors/objective/player-status.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* PlayerStatus: the per-player state read off the eight squid/octo icons
|
||||
* flanking the match timer, emitted by the ObjectiveDetector alongside each
|
||||
* Objective read (same frame, same `time`, so callers can pair the two).
|
||||
*
|
||||
* Per slot, two pixel-class fractions decide the state (rois.ts documents
|
||||
* the calibration): an alive icon's body is saturated team ink; holding
|
||||
* special washes the upper body into a bright pale glow (the shoulder
|
||||
* probe); a splatted icon is an unsaturated grey/dark X with neither. The
|
||||
* casted spectator HUD draws the same strip at its own geometry — the
|
||||
* white D-pad camera badges under the right team pick the layout.
|
||||
*/
|
||||
import type { Mat } from "../../cv";
|
||||
import { copyRoi, type Roi } from "../../image";
|
||||
import type { DetectedEvent } from "../types";
|
||||
import {
|
||||
STATUS_BODY_BOX_CAST,
|
||||
STATUS_BODY_BOX_POV,
|
||||
STATUS_CAST_MIN_DPAD_WHITE,
|
||||
STATUS_DEAD_MAX_BODY_INK,
|
||||
STATUS_DEAD_MAX_SHOULDER_GLOW,
|
||||
STATUS_DPAD_PROBES,
|
||||
STATUS_GLOW_MIN_VALUE,
|
||||
STATUS_INK_MIN_SPREAD,
|
||||
STATUS_INK_MIN_VALUE,
|
||||
STATUS_READY_MIN_SHOULDER_GLOW,
|
||||
STATUS_SHOULDER_BOX_CAST,
|
||||
STATUS_SHOULDER_BOX_POV,
|
||||
STATUS_SLOT_CENTERS_CAST,
|
||||
STATUS_SLOT_CENTERS_POV,
|
||||
STATUS_WHITE_MAX_SPREAD,
|
||||
STATUS_WHITE_MIN_VALUE,
|
||||
} from "./rois";
|
||||
|
||||
export const PLAYER_STATUS_EVENT_TYPE = "PlayerStatus";
|
||||
|
||||
export type PlayerStatusFlags = [boolean, boolean, boolean, boolean];
|
||||
|
||||
export type PlayerStatusLayout = "pov" | "cast";
|
||||
|
||||
export interface PlayerStatusData {
|
||||
/**
|
||||
* seconds shown on the match timer at the read, same value as the
|
||||
* Objective event from the same frame — the key for pairing the two
|
||||
*/
|
||||
time: number | null;
|
||||
/** special held per slot, [left team, right team], slots left-to-right */
|
||||
special: [PlayerStatusFlags, PlayerStatusFlags];
|
||||
/** splatted per slot, same arrangement */
|
||||
dead: [PlayerStatusFlags, PlayerStatusFlags];
|
||||
/** which icon-strip geometry the frame showed */
|
||||
layout: PlayerStatusLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline content guard: reads merge only while every slot state matches,
|
||||
* so each death/respawn/special flip becomes its own event. `time` is not
|
||||
* compared (it ticks every second) and neither is `layout` (a camera-style
|
||||
* change with identical states is the same state).
|
||||
*/
|
||||
export function samePlayerStatusData(a: unknown, b: unknown): boolean {
|
||||
const da = a as PlayerStatusData;
|
||||
const db = b as PlayerStatusData;
|
||||
for (const side of [0, 1] as const) {
|
||||
for (let slot = 0; slot < 4; slot++) {
|
||||
if (da.special[side][slot] !== db.special[side][slot]) return false;
|
||||
if (da.dead[side][slot] !== db.dead[side][slot]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface SlotRead {
|
||||
dead: boolean;
|
||||
special: boolean;
|
||||
confidence: number;
|
||||
bodyInk: number;
|
||||
shoulderGlow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the icon strip of a frame the objective gate already anchored as
|
||||
* the in-match counter HUD. Callers emit the event only alongside a
|
||||
* successful Objective read — the counter parse carries the lookalike
|
||||
* rejection for both.
|
||||
*/
|
||||
export function parsePlayerStatus(
|
||||
frame: Mat,
|
||||
t: number,
|
||||
time: number | null,
|
||||
): DetectedEvent<PlayerStatusData> {
|
||||
const layout: PlayerStatusLayout = isCastLayout(frame) ? "cast" : "pov";
|
||||
const centers =
|
||||
layout === "cast" ? STATUS_SLOT_CENTERS_CAST : STATUS_SLOT_CENTERS_POV;
|
||||
const shoulderBox =
|
||||
layout === "cast" ? STATUS_SHOULDER_BOX_CAST : STATUS_SHOULDER_BOX_POV;
|
||||
const bodyBox =
|
||||
layout === "cast" ? STATUS_BODY_BOX_CAST : STATUS_BODY_BOX_POV;
|
||||
|
||||
const sides = centers.map((sideCenters) =>
|
||||
sideCenters.map((cx): SlotRead => {
|
||||
const shoulder = classFractions(frame, {
|
||||
x: cx + shoulderBox.dx,
|
||||
y: shoulderBox.y,
|
||||
w: shoulderBox.w,
|
||||
h: shoulderBox.h,
|
||||
});
|
||||
const body = classFractions(frame, {
|
||||
x: cx + bodyBox.dx,
|
||||
y: bodyBox.y,
|
||||
w: bodyBox.w,
|
||||
h: bodyBox.h,
|
||||
});
|
||||
return classifySlot(body.ink, shoulder.glow);
|
||||
}),
|
||||
) as [SlotRead[], SlotRead[]];
|
||||
|
||||
const reads = sides.flat();
|
||||
return {
|
||||
type: PLAYER_STATUS_EVENT_TYPE,
|
||||
t,
|
||||
confidence:
|
||||
reads.reduce((sum, read) => sum + read.confidence, 0) / reads.length,
|
||||
data: {
|
||||
time,
|
||||
special: sides.map((side) =>
|
||||
side.map((read) => read.special),
|
||||
) as PlayerStatusData["special"],
|
||||
dead: sides.map((side) =>
|
||||
side.map((read) => read.dead),
|
||||
) as PlayerStatusData["dead"],
|
||||
layout,
|
||||
},
|
||||
debug: {
|
||||
layout,
|
||||
bodyInk: reads.map((read) => Number(read.bodyInk.toFixed(2))),
|
||||
shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* State from the two fractions, with a confidence scaled by the distance
|
||||
* to the nearest decision boundary (1 at twice the threshold / at zero).
|
||||
*/
|
||||
function classifySlot(bodyInk: number, shoulderGlow: number): SlotRead {
|
||||
const dead =
|
||||
bodyInk <= STATUS_DEAD_MAX_BODY_INK &&
|
||||
shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW;
|
||||
const special = !dead && shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW;
|
||||
const confidence = dead
|
||||
? Math.min(
|
||||
1,
|
||||
(STATUS_DEAD_MAX_BODY_INK - bodyInk) / STATUS_DEAD_MAX_BODY_INK,
|
||||
)
|
||||
: special
|
||||
? Math.min(1, shoulderGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2))
|
||||
: Math.min(1, bodyInk / (STATUS_DEAD_MAX_BODY_INK * 2));
|
||||
return { dead, special, confidence, bodyInk, shoulderGlow };
|
||||
}
|
||||
|
||||
/** Ink and glow pixel fractions of a ROI (see rois.ts for the classes). */
|
||||
function classFractions(frame: Mat, roi: Roi): { ink: number; glow: number } {
|
||||
const crop = copyRoi(frame, roi);
|
||||
const { data } = crop;
|
||||
const channels = crop.channels();
|
||||
let ink = 0;
|
||||
let glow = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += channels) {
|
||||
const r = data[i]!;
|
||||
const g = data[i + 1]!;
|
||||
const b = data[i + 2]!;
|
||||
const value = Math.max(r, g, b);
|
||||
const spread = value - Math.min(r, g, b);
|
||||
if (spread >= STATUS_INK_MIN_SPREAD && value >= STATUS_INK_MIN_VALUE) ink++;
|
||||
if (value >= STATUS_GLOW_MIN_VALUE) glow++;
|
||||
count++;
|
||||
}
|
||||
crop.delete();
|
||||
return { ink: ink / count, glow: glow / count };
|
||||
}
|
||||
|
||||
/** All four D-pad probes reading white = the casted spectator layout. */
|
||||
function isCastLayout(frame: Mat): boolean {
|
||||
return STATUS_DPAD_PROBES.every((roi) => {
|
||||
const crop = copyRoi(frame, roi);
|
||||
const { data } = crop;
|
||||
const channels = crop.channels();
|
||||
let white = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += channels) {
|
||||
const r = data[i]!;
|
||||
const g = data[i + 1]!;
|
||||
const b = data[i + 2]!;
|
||||
const value = Math.max(r, g, b);
|
||||
if (
|
||||
value >= STATUS_WHITE_MIN_VALUE &&
|
||||
value - Math.min(r, g, b) <= STATUS_WHITE_MAX_SPREAD
|
||||
) {
|
||||
white++;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
crop.delete();
|
||||
return white / count >= STATUS_CAST_MIN_DPAD_WHITE;
|
||||
});
|
||||
}
|
||||
|
|
@ -83,10 +83,13 @@ export const GATE_TIMER_MAX_MEAN = 70;
|
|||
export const GATE_TIMER_MIN_MAX_BRIGHTNESS = 240;
|
||||
|
||||
/**
|
||||
* Timer's white M:SS digits measure 34px; the colon's dots stack under
|
||||
* the digit height floor, so a plain height filter drops the colon.
|
||||
* Timer's white M:SS digits measure 34px on native 1080p footage; upscaled
|
||||
* 720p captures draw them at ~40px. Every height is tried and the best
|
||||
* valid read wins — the wrong-scale set scores well under a clean read's
|
||||
* confidence. The colon's dots stack under the digit height floor at
|
||||
* either size, so a plain height filter drops the colon.
|
||||
*/
|
||||
export const TIMER_TEXT_HEIGHT = 34;
|
||||
export const TIMER_TEXT_HEIGHTS = [34, 40] as const;
|
||||
export const TIMER_BIN_THRESHOLD = 160;
|
||||
export const TIMER_DIGIT_MIN_CONF = 0.75;
|
||||
export const TIMER_DIGIT_MIN_HEIGHT_RATIO = 0.82;
|
||||
|
|
@ -126,3 +129,87 @@ export const PENALTY_PROBE_MAX_STD = 30;
|
|||
* (attested fills >=112 vs <=19).
|
||||
*/
|
||||
export const CONTROL_PLATE_MIN_SATURATION = 60;
|
||||
|
||||
// ---- player-status icon strips (the PlayerStatus event) ----
|
||||
//
|
||||
// Eight per-player squid/octo icons flank the timer. An alive icon's body
|
||||
// is drawn in team ink; holding special washes the upper body out into a
|
||||
// bright pale glow; a splatted player's icon turns an unsaturated grey/dark
|
||||
// X'd shape. Two layouts share the band: POV (small icons) and the casted
|
||||
// spectator HUD (bigger icons, gauge digits hanging over each icon's
|
||||
// top-RIGHT and white camera-button badges below) — slot centers are
|
||||
// measured per side off the fixtures; neither layout is mirror-symmetric
|
||||
// (POV inner icons sit 108px left / 130px right of screen center) and the
|
||||
// cast sides don't even share a pitch (~103 left vs ~88 right).
|
||||
|
||||
/** Per-side slot center x's, slots left-to-right. */
|
||||
export const STATUS_SLOT_CENTERS_POV: readonly [
|
||||
readonly number[],
|
||||
readonly number[],
|
||||
] = [
|
||||
[554, 653, 752, 852],
|
||||
[1090, 1190, 1288, 1388],
|
||||
];
|
||||
export const STATUS_SLOT_CENTERS_CAST: readonly [
|
||||
readonly number[],
|
||||
readonly number[],
|
||||
] = [
|
||||
[542, 646, 750, 852],
|
||||
[1085, 1173, 1261, 1348],
|
||||
];
|
||||
|
||||
/**
|
||||
* Shoulder probe: the icon's upper-left body, clear of the weapon
|
||||
* silhouette (drawn center/lower), the cast gauge digits (hanging top-right)
|
||||
* and the POV sub/special trinkets (bottom). The special-ready glow is
|
||||
* detected here. Boxes are relative to a slot center.
|
||||
*/
|
||||
export const STATUS_SHOULDER_BOX_POV = { dx: -30, y: 38, w: 24, h: 20 };
|
||||
export const STATUS_SHOULDER_BOX_CAST = { dx: -30, y: 35, w: 24, h: 30 };
|
||||
|
||||
/**
|
||||
* Body probe: the widest band of the icon that dodges the cast camera
|
||||
* badges below (y>=100) and the POV coin trinkets (y>=95). Team ink
|
||||
* presence here separates alive icons from the grey/dark splatted ones.
|
||||
*/
|
||||
export const STATUS_BODY_BOX_POV = { dx: -32, y: 44, w: 64, h: 48 };
|
||||
export const STATUS_BODY_BOX_CAST = { dx: -32, y: 55, w: 64, h: 43 };
|
||||
|
||||
/**
|
||||
* An ink pixel: saturated and bright enough to be team color. The value
|
||||
* floor keeps dark saturated stage backdrops (deep blue arena walls behind
|
||||
* the translucent dead icons measure v<=90) from counting as ink.
|
||||
*/
|
||||
export const STATUS_INK_MIN_SPREAD = 70;
|
||||
export const STATUS_INK_MIN_VALUE = 105;
|
||||
|
||||
/**
|
||||
* A glow pixel of the special-ready wash. 225 splits the attested ready
|
||||
* shoulders (fractions >=0.40) from the brightest alive team color — POV
|
||||
* lime peaks between 215 and 225 (glow fraction 0.97 at 215, 0.00 at 225).
|
||||
*/
|
||||
export const STATUS_GLOW_MIN_VALUE = 225;
|
||||
|
||||
/**
|
||||
* Splatted: body ink under the floor (attested dead <=0.09 vs alive
|
||||
* >=0.40) with the shoulder-glow guard keeping the near-white ready wash
|
||||
* (body ink as low as 0.03, glow >=0.40 vs dead <=0.03) out of it.
|
||||
*/
|
||||
export const STATUS_DEAD_MAX_BODY_INK = 0.22;
|
||||
export const STATUS_DEAD_MAX_SHOULDER_GLOW = 0.2;
|
||||
|
||||
/** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */
|
||||
export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25;
|
||||
|
||||
/**
|
||||
* Cast-layout discriminator: the spectator HUD always draws white D-pad
|
||||
* camera badges under the right team's icons; nothing fixed sits there on
|
||||
* POV footage. All four probes must read white (bright AND unsaturated —
|
||||
* bright sky is saturated cyan) to call the frame cast.
|
||||
*/
|
||||
export const STATUS_DPAD_PROBES: readonly Roi[] = [1105, 1180, 1256, 1332].map(
|
||||
(cx) => ({ x: cx - 8, y: 102, w: 16, h: 16 }),
|
||||
);
|
||||
export const STATUS_WHITE_MIN_VALUE = 215;
|
||||
export const STATUS_WHITE_MAX_SPREAD = 40;
|
||||
export const STATUS_CAST_MIN_DPAD_WHITE = 0.35;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import {
|
|||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
} from "./detectors/objective/index";
|
||||
import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
type PlayerStatusData,
|
||||
} from "./detectors/objective/player-status";
|
||||
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
|
||||
import type { ScoreboardData } from "./detectors/scoreboard/index";
|
||||
import {
|
||||
|
|
@ -43,6 +47,8 @@ import type {
|
|||
ScannerMatchObjective,
|
||||
ScannerMatchObjectiveSample,
|
||||
ScannerMatchPlayer,
|
||||
ScannerMatchPlayerStatus,
|
||||
ScannerMatchPlayerStatusSample,
|
||||
ScannerMatchTeam,
|
||||
} from "./scanner-match";
|
||||
|
||||
|
|
@ -111,9 +117,10 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
|||
const nextStage = buildNextStageMap(sorted);
|
||||
|
||||
let open: OpenMatch<E> | null = null;
|
||||
// deaths/objective reads seen with no match open to anchor them yet
|
||||
// deaths/objective/status reads seen with no match open to anchor them yet
|
||||
let orphanDeaths: E[] = [];
|
||||
let orphanObjectives: E[] = [];
|
||||
let orphanPlayerStatuses: E[] = [];
|
||||
const finalize = (): void => {
|
||||
if (!open) return;
|
||||
if (open.scoreboard || open.minimaps.length > 0) {
|
||||
|
|
@ -131,6 +138,7 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
|||
vote(open.stageVotes, (event.data as MapStartData).stage);
|
||||
orphanDeaths = [];
|
||||
orphanObjectives = [];
|
||||
orphanPlayerStatuses = [];
|
||||
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
|
||||
if (!open) {
|
||||
open = startMatch();
|
||||
|
|
@ -140,12 +148,16 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
|||
open.objectives = orphanObjectives.filter(
|
||||
(objective) => event.t - objective.t <= FALLBACK_WINDOW_SECONDS,
|
||||
);
|
||||
open.playerStatuses = orphanPlayerStatuses.filter(
|
||||
(status) => event.t - status.t <= FALLBACK_WINDOW_SECONDS,
|
||||
);
|
||||
}
|
||||
open.scoreboard = event;
|
||||
vote(open.stageVotes, (event.data as ScoreboardData).stage);
|
||||
finalize();
|
||||
orphanDeaths = [];
|
||||
orphanObjectives = [];
|
||||
orphanPlayerStatuses = [];
|
||||
} else if (event.type === MINIMAP_EVENT_TYPE) {
|
||||
const stage = (event.data as MinimapData).stage;
|
||||
if (open) {
|
||||
|
|
@ -171,6 +183,8 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
|||
(open?.deaths ?? orphanDeaths).push(event);
|
||||
} else if (event.type === OBJECTIVE_EVENT_TYPE) {
|
||||
(open?.objectives ?? orphanObjectives).push(event);
|
||||
} else if (event.type === PLAYER_STATUS_EVENT_TYPE) {
|
||||
(open?.playerStatuses ?? orphanPlayerStatuses).push(event);
|
||||
}
|
||||
}
|
||||
finalize();
|
||||
|
|
@ -211,10 +225,12 @@ export function ingestSkipReasons<E extends DetectedEvent>(
|
|||
}
|
||||
|
||||
/**
|
||||
* Objective-counter reads that landed on a match whose detected mode is not
|
||||
* Splat Zones — the SZ parser (the only one so far) misreading another
|
||||
* mode's counter overlay. The builder already leaves such a match's
|
||||
* `objective` null; callers should delete these events from their stores.
|
||||
* Objective-counter and player-status reads that landed on a match whose
|
||||
* detected mode is not Splat Zones — the SZ parser (the only one so far)
|
||||
* misreading another mode's counter overlay, and the statuses that rode
|
||||
* along with those misreads. The builder already leaves such a match's
|
||||
* `objective`/`playerStatus` null; callers should delete these events from
|
||||
* their stores.
|
||||
*/
|
||||
export function invalidObjectiveEvents<E extends DetectedEvent>(
|
||||
built: readonly BuiltMatch<E>[],
|
||||
|
|
@ -222,7 +238,11 @@ export function invalidObjectiveEvents<E extends DetectedEvent>(
|
|||
return built
|
||||
.filter((b) => b.match.mode !== null && b.match.mode !== "SZ")
|
||||
.flatMap((b) =>
|
||||
b.sources.filter((event) => event.type === OBJECTIVE_EVENT_TYPE),
|
||||
b.sources.filter(
|
||||
(event) =>
|
||||
event.type === OBJECTIVE_EVENT_TYPE ||
|
||||
event.type === PLAYER_STATUS_EVENT_TYPE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -291,6 +311,8 @@ interface OpenMatch<E extends DetectedEvent> {
|
|||
deaths: E[];
|
||||
/** objective-counter reads; become the match's `objective` samples */
|
||||
objectives: E[];
|
||||
/** icon-strip reads; become the match's `playerStatus` samples */
|
||||
playerStatuses: E[];
|
||||
scoreboard: E | null;
|
||||
/**
|
||||
* per-stage read counts (a MapStart's stage seeds it); the plurality
|
||||
|
|
@ -308,6 +330,7 @@ function startMatch<E extends DetectedEvent>(): OpenMatch<E> {
|
|||
minimaps: [],
|
||||
deaths: [],
|
||||
objectives: [],
|
||||
playerStatuses: [],
|
||||
scoreboard: null,
|
||||
stageVotes: new Map(),
|
||||
lastMinimapT: null,
|
||||
|
|
@ -357,6 +380,7 @@ function toBuiltMatch<E extends DetectedEvent>(
|
|||
...open.minimaps,
|
||||
...open.deaths,
|
||||
...open.objectives,
|
||||
...open.playerStatuses,
|
||||
...(open.scoreboard ? [open.scoreboard] : []),
|
||||
].sort((a, b) => a.t - b.t);
|
||||
|
||||
|
|
@ -375,9 +399,25 @@ function toBuiltMatch<E extends DetectedEvent>(
|
|||
t: event.t,
|
||||
data: event.data as ObjectiveData,
|
||||
}));
|
||||
const playerStatuses = open.playerStatuses.map((event) => ({
|
||||
t: event.t,
|
||||
data: event.data as PlayerStatusData,
|
||||
}));
|
||||
const minimaps = open.minimaps.map((event) => event.data as MinimapData);
|
||||
|
||||
const mode = board?.mode ?? start?.mode ?? null;
|
||||
// only the SZ counter is parsed — reads on a known other-mode match are
|
||||
// misreads of a lookalike overlay, not progress data (statuses included:
|
||||
// they only ever ride along with counter reads)
|
||||
const progress =
|
||||
mode === null || mode === "SZ"
|
||||
? buildProgress(
|
||||
objectives,
|
||||
playerStatuses,
|
||||
board,
|
||||
minimapTeamColors(minimaps),
|
||||
)
|
||||
: { objective: null, playerStatus: null };
|
||||
|
||||
const match: ScannerMatch = {
|
||||
startsAt:
|
||||
|
|
@ -391,13 +431,11 @@ function toBuiltMatch<E extends DetectedEvent>(
|
|||
? board.matchScores
|
||||
: null,
|
||||
replayCode: timestamped?.replayCode ?? null,
|
||||
cast: open.minimaps.some((event) => (event.data as MinimapData).spectator),
|
||||
// only the SZ counter is parsed — reads on a known other-mode match
|
||||
// are misreads of a lookalike overlay, not progress data
|
||||
objective:
|
||||
mode === null || mode === "SZ"
|
||||
? buildObjective(objectives, board, minimapTeamColors(minimaps))
|
||||
: null,
|
||||
cast:
|
||||
open.minimaps.some((event) => (event.data as MinimapData).spectator) ||
|
||||
playerStatuses.some((read) => read.data.layout === "cast"),
|
||||
objective: progress.objective,
|
||||
playerStatus: progress.playerStatus,
|
||||
teams: board
|
||||
? teamsFromScoreboard(board, deaths)
|
||||
: teamsFromMinimaps(minimaps, deaths),
|
||||
|
|
@ -419,34 +457,45 @@ function floorOrNull(t: number | undefined): number | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* The counter reads as `objective` samples in `teams` order. On POV footage
|
||||
* the left plate is the POV/alpha side for the whole game, but casted
|
||||
* footage reorders the plates to follow the specced player — so each read
|
||||
* The counter reads as `objective` samples and the icon-strip reads as
|
||||
* `playerStatus` samples, both in `teams` order. On POV footage the left
|
||||
* plate is the POV/alpha side for the whole game, but casted footage
|
||||
* reorders the plates to follow the specced player — so each counter read
|
||||
* is first oriented by its sides' team ink hues (clustered against the
|
||||
* first read that saw both), making the series side-stable. Broadcast
|
||||
* replay wipes re-run an earlier moment with the counter overlay intact,
|
||||
* so reads are first anchored by their projected clock zero (`t + time`),
|
||||
* and only the dominant anchor cluster — the live game — survives;
|
||||
* timerless reads follow their preceding anchored neighbor. A displayed
|
||||
* count never increases (it shows the team's best remaining), so each
|
||||
* side's series then keeps only its longest non-increasing run of scores —
|
||||
* surviving OCR blips are voided rather than charted. The whole
|
||||
* series then goes into `teams` order: a scoreboard-closed match is
|
||||
* first read that saw both), making the series side-stable; a status read
|
||||
* carries no ink of its own and inherits the orientation of the counter
|
||||
* read nearest in time (they are emitted off the same frames). Broadcast
|
||||
* replay wipes re-run an earlier moment with the whole HUD intact, so both
|
||||
* series are anchored by their projected clock zero (`t + time`) against
|
||||
* one shared dominant projection — the live game — and reads off it are
|
||||
* dropped; timerless reads follow their preceding anchored neighbor. A
|
||||
* displayed count never increases (it shows the team's best remaining), so
|
||||
* each side's counter series then keeps only its longest non-increasing
|
||||
* run of scores — surviving OCR blips are voided rather than charted. Both
|
||||
* series then go into `teams` order: a scoreboard-closed match is
|
||||
* winner-first (POV seat when read; else in SZ the winner is the side
|
||||
* whose remaining count went furthest down), a minimap-grouped match
|
||||
* anchors on the minimap's own/alpha-vs-enemy/bravo ink colors, and with
|
||||
* no signal the first read's arrangement stands.
|
||||
* no signal the first read's arrangement stands. A side's four slots keep
|
||||
* their on-screen left-to-right order through a side swap — whether the
|
||||
* game mirrors slot order across sides is unattested so far.
|
||||
*/
|
||||
function buildObjective(
|
||||
function buildProgress(
|
||||
objectives: readonly { t: number; data: ObjectiveData }[],
|
||||
playerStatuses: readonly { t: number; data: PlayerStatusData }[],
|
||||
board: ScoreboardData | undefined,
|
||||
minimapColors: [InkRgb | null, InkRgb | null] | null,
|
||||
): ScannerMatchObjective | null {
|
||||
if (objectives.length === 0) return null;
|
||||
): {
|
||||
objective: ScannerMatchObjective | null;
|
||||
playerStatus: ScannerMatchPlayerStatus | null;
|
||||
} {
|
||||
const dominant = dominantAnchorOf([...objectives, ...playerStatuses]);
|
||||
const live = withoutReplayReads(objectives, dominant);
|
||||
const liveStatuses = withoutReplayReads(playerStatuses, dominant);
|
||||
|
||||
const live = withoutReplayReads(objectives);
|
||||
const clusterHues = seedClusterHues(live);
|
||||
const oriented = withMonotonicScores(orientByTeamColor(live, clusterHues));
|
||||
const swapFlags = readSwapFlags(live, clusterHues);
|
||||
const oriented = withMonotonicScores(orientObjectives(live, swapFlags));
|
||||
|
||||
const swap = board
|
||||
? board.povIndex !== null
|
||||
|
|
@ -454,17 +503,64 @@ function buildObjective(
|
|||
: bestCount(oriented, 1) < bestCount(oriented, 0)
|
||||
: minimapAnchorSwap(clusterHues, minimapColors);
|
||||
|
||||
const samples = oriented.map((read): ScannerMatchObjectiveSample => {
|
||||
const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t: Math.max(0, Math.floor(read.t)),
|
||||
time: read.time,
|
||||
score: [read.score[a], read.score[b]],
|
||||
penalty: [read.penalty[a], read.penalty[b]],
|
||||
control: [read.control[a], read.control[b]],
|
||||
};
|
||||
});
|
||||
return { mode: "SZ", samples };
|
||||
const objective =
|
||||
oriented.length === 0
|
||||
? null
|
||||
: {
|
||||
mode: "SZ" as const,
|
||||
samples: oriented.map((read): ScannerMatchObjectiveSample => {
|
||||
const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t: Math.max(0, Math.floor(read.t)),
|
||||
time: read.time,
|
||||
score: [read.score[a], read.score[b]],
|
||||
penalty: [read.penalty[a], read.penalty[b]],
|
||||
control: [read.control[a], read.control[b]],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const playerStatus =
|
||||
liveStatuses.length === 0
|
||||
? null
|
||||
: {
|
||||
samples: liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
|
||||
const clusterSwapped = nearestSwapFlag(live, swapFlags, read.t);
|
||||
const [a, b] =
|
||||
clusterSwapped !== swap ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t: Math.max(0, Math.floor(read.t)),
|
||||
time: read.data.time,
|
||||
special: [read.data.special[a], read.data.special[b]],
|
||||
dead: [read.data.dead[a], read.data.dead[b]],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return { objective, playerStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster-orientation flag of the counter read nearest in time —
|
||||
* status reads are emitted off the same frames as counter reads, so the
|
||||
* nearest one saw the same camera arrangement. False when no counter read
|
||||
* carried a usable flag (POV footage never swaps anyway).
|
||||
*/
|
||||
function nearestSwapFlag(
|
||||
objectives: readonly { t: number }[],
|
||||
swapFlags: readonly boolean[],
|
||||
t: number,
|
||||
): boolean {
|
||||
let best = -1;
|
||||
for (const [i, read] of objectives.entries()) {
|
||||
if (
|
||||
best === -1 ||
|
||||
Math.abs(read.t - t) < Math.abs(objectives[best]!.t - t)
|
||||
) {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best === -1 ? false : (swapFlags[best] ?? false);
|
||||
}
|
||||
|
||||
/** A counter read with its sides in cluster (first-read) order. */
|
||||
|
|
@ -494,23 +590,33 @@ function seedClusterHues(
|
|||
}
|
||||
|
||||
/**
|
||||
* Assign every read's sides to the color clusters: a read whose ink hues
|
||||
* sit closer to the clusters crosswise is swapped (the cast switched the
|
||||
* Per-read cluster assignment of the sides: a read whose ink hues sit
|
||||
* closer to the clusters crosswise is swapped (the cast switched the
|
||||
* specced side). Reads with no readable color inherit the previous read's
|
||||
* orientation — plate arrangement only changes with a camera change, which
|
||||
* leaves the colors readable once the plates are back.
|
||||
*/
|
||||
function orientByTeamColor(
|
||||
function readSwapFlags(
|
||||
objectives: readonly { t: number; data: ObjectiveData }[],
|
||||
clusterHues: [number, number] | null,
|
||||
): OrientedObjectiveRead[] {
|
||||
): boolean[] {
|
||||
let previousSwapped = false;
|
||||
return objectives.map(({ t, data }): OrientedObjectiveRead => {
|
||||
return objectives.map(({ data }) => {
|
||||
const swapped = clusterHues
|
||||
? readSwapped(data, clusterHues, previousSwapped)
|
||||
: false;
|
||||
previousSwapped = swapped;
|
||||
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return swapped;
|
||||
});
|
||||
}
|
||||
|
||||
/** The counter reads with their sides in cluster (first-read) order. */
|
||||
function orientObjectives(
|
||||
objectives: readonly { t: number; data: ObjectiveData }[],
|
||||
swapFlags: readonly boolean[],
|
||||
): OrientedObjectiveRead[] {
|
||||
return objectives.map(({ t, data }, i): OrientedObjectiveRead => {
|
||||
const [a, b] = swapFlags[i] ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t,
|
||||
time: data.time,
|
||||
|
|
@ -581,6 +687,20 @@ function minimapTeamColors(
|
|||
return sides[0] === null && sides[1] === null ? null : sides;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dominant clock-zero projection across every read that carried a
|
||||
* timer; null when none did. Counter and status reads project the same
|
||||
* live clock, so one shared anchor voids replay wipes from both series.
|
||||
*/
|
||||
function dominantAnchorOf(
|
||||
reads: readonly { t: number; data: { time: number | null } }[],
|
||||
): number | null {
|
||||
const anchors = reads.flatMap((read) =>
|
||||
read.data.time !== null ? [read.t + read.data.time] : [],
|
||||
);
|
||||
return anchors.length === 0 ? null : dominantAnchor(anchors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops reads taken off broadcast replay wipes: `t + time` projects the
|
||||
* wall-clock moment the match timer reaches zero, which stays constant
|
||||
|
|
@ -591,15 +711,15 @@ function minimapTeamColors(
|
|||
* following one for a timerless head), so an unreadable — or as yet
|
||||
* unattested overtime — timer display never voids live reads.
|
||||
*/
|
||||
function withoutReplayReads<T extends { t: number; data: ObjectiveData }>(
|
||||
objectives: readonly T[],
|
||||
): T[] {
|
||||
const anchored = objectives.flatMap((read, i) =>
|
||||
function withoutReplayReads<
|
||||
T extends { t: number; data: { time: number | null } },
|
||||
>(reads: readonly T[], dominant: number | null): T[] {
|
||||
if (dominant === null) return [...reads];
|
||||
const anchored = reads.flatMap((read, i) =>
|
||||
read.data.time !== null ? [{ i, anchor: read.t + read.data.time }] : [],
|
||||
);
|
||||
if (anchored.length === 0) return [...objectives];
|
||||
if (anchored.length === 0) return [...reads];
|
||||
|
||||
const dominant = dominantAnchor(anchored.map((read) => read.anchor));
|
||||
const keptAnchored = new Map(
|
||||
anchored.map(({ i, anchor }) => [
|
||||
i,
|
||||
|
|
@ -608,7 +728,7 @@ function withoutReplayReads<T extends { t: number; data: ObjectiveData }>(
|
|||
);
|
||||
|
||||
let previousKept = keptAnchored.get(anchored[0]!.i)!;
|
||||
return objectives.filter((_, i) => {
|
||||
return reads.filter((_, i) => {
|
||||
previousKept = keptAnchored.get(i) ?? previousKept;
|
||||
return previousKept;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,33 @@ export interface ScannerMatchObjective {
|
|||
samples: ScannerMatchObjectiveSample[];
|
||||
}
|
||||
|
||||
export type ScannerMatchPlayerFlags = [boolean, boolean, boolean, boolean];
|
||||
|
||||
export interface ScannerMatchPlayerStatusSample {
|
||||
/** whole seconds into the video/stream the icon strip was read at */
|
||||
t: number;
|
||||
/**
|
||||
* seconds shown on the match timer at the read — the same key the
|
||||
* objective samples carry, for charting both on one clock axis
|
||||
*/
|
||||
time: number | null;
|
||||
/** special held per player, teams in `teams` order, slots in row order */
|
||||
special: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags];
|
||||
/** splatted per player, same arrangement */
|
||||
dead: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-player special/death states over the match, read off the icon strip
|
||||
* next to the objective counter. Chronological and deduped to state
|
||||
* changes like the objective samples, with an unchanged state re-confirmed
|
||||
* every ~6s — render longer sample gaps as unknown, not as a continued
|
||||
* state.
|
||||
*/
|
||||
export interface ScannerMatchPlayerStatus {
|
||||
samples: ScannerMatchPlayerStatusSample[];
|
||||
}
|
||||
|
||||
export interface ScannerMatch {
|
||||
/** whole seconds into the video/stream the match starts at */
|
||||
startsAt: number | null;
|
||||
|
|
@ -89,6 +116,12 @@ export interface ScannerMatch {
|
|||
* counter was read
|
||||
*/
|
||||
objective: ScannerMatchObjective | null;
|
||||
/**
|
||||
* per-player special/death samples in `teams` order, oriented alongside
|
||||
* the objective samples (a status read pairs with the counter read of
|
||||
* its frame); null when the icon strip was never read
|
||||
*/
|
||||
playerStatus: ScannerMatchPlayerStatus | null;
|
||||
/**
|
||||
* on-screen order: scoreboard rows 0-3 are teams[0] (the winners),
|
||||
* minimap alpha/own side is teams[0]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {
|
|||
OBJECTIVE_EVENT_TYPE,
|
||||
sameObjectiveData,
|
||||
} from "../detectors/objective/index";
|
||||
import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
samePlayerStatusData,
|
||||
} from "../detectors/objective/player-status";
|
||||
import { SCOREBOARD_EVENT_TYPE } from "../detectors/scoreboard/index";
|
||||
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../detectors/scoreboard-battle-log/index";
|
||||
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../detectors/scoreboard-battle-log-replay/index";
|
||||
|
|
@ -42,13 +46,21 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
|
|||
// within one open
|
||||
// objective counter reads repeat every check second; the content guard
|
||||
// below keeps every actual change while the window collapses static
|
||||
// stretches into one event per state
|
||||
mergeWindowByType: { Death: 8, Minimap: 5, [OBJECTIVE_EVENT_TYPE]: 10 },
|
||||
// stretches into one event per state. Player statuses can revisit an
|
||||
// exact prior state no sooner than a respawn takes (~9s), so their
|
||||
// window must stay under that
|
||||
mergeWindowByType: {
|
||||
Death: 8,
|
||||
Minimap: 5,
|
||||
[OBJECTIVE_EVENT_TYPE]: 10,
|
||||
[PLAYER_STATUS_EVENT_TYPE]: 5,
|
||||
},
|
||||
sameEventDataByType: {
|
||||
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
|
||||
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: sameScoreboardMatch,
|
||||
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch,
|
||||
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
|
||||
[PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData,
|
||||
},
|
||||
minConfidence: 0.6,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ interface ExpectedScoreboard {
|
|||
| "MapStart"
|
||||
| "Minimap"
|
||||
| "Objective"
|
||||
| "PlayerStatus"
|
||||
| "none";
|
||||
data?: {
|
||||
lobby?: ScannerLobby;
|
||||
|
|
@ -99,6 +100,12 @@ interface ExpectedScoreboard {
|
|||
penalty?: [number | null, number | null];
|
||||
/** Objective only: which team currently holds the objective */
|
||||
control?: [boolean, boolean];
|
||||
/** PlayerStatus only: special held per slot, [left team, right team] */
|
||||
special?: [boolean[], boolean[]];
|
||||
/** PlayerStatus only: splatted per slot, [left team, right team] */
|
||||
dead?: [boolean[], boolean[]];
|
||||
/** PlayerStatus only: which icon-strip geometry the frame shows */
|
||||
layout?: "pov" | "cast";
|
||||
/** Minimap only: casted 8-player spectator map screen (not parsed yet) */
|
||||
spectator?: boolean;
|
||||
/** Minimap only: own-team callout cards in slot order */
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
ScannerMatch,
|
||||
ScannerMatchObjective,
|
||||
ScannerMatchPlayer,
|
||||
ScannerMatchPlayerStatus,
|
||||
ScannerMatchTeam,
|
||||
} from "./core/scanner-match";
|
||||
import { SCANNER_LOBBIES } from "./scanner-types";
|
||||
|
|
@ -72,6 +73,26 @@ const scannerMatchObjectiveSchema = z.object({
|
|||
.max(MAX_OBJECTIVE_SAMPLES),
|
||||
});
|
||||
|
||||
const playerFlagsSchema = z.tuple([
|
||||
z.boolean(),
|
||||
z.boolean(),
|
||||
z.boolean(),
|
||||
z.boolean(),
|
||||
]);
|
||||
|
||||
const scannerMatchPlayerStatusSampleSchema = z.object({
|
||||
t: z.number().int().min(0),
|
||||
time: z.number().int().min(0).nullable(),
|
||||
special: z.tuple([playerFlagsSchema, playerFlagsSchema]),
|
||||
dead: z.tuple([playerFlagsSchema, playerFlagsSchema]),
|
||||
});
|
||||
|
||||
const scannerMatchPlayerStatusSchema = z.object({
|
||||
samples: z
|
||||
.array(scannerMatchPlayerStatusSampleSchema)
|
||||
.max(MAX_OBJECTIVE_SAMPLES),
|
||||
});
|
||||
|
||||
export const scannerMatchSchema = z.object({
|
||||
startsAt: z.number().int().min(0).nullable(),
|
||||
endsAt: z.number().int().min(0).nullable(),
|
||||
|
|
@ -86,6 +107,7 @@ export const scannerMatchSchema = z.object({
|
|||
replayCode: detectionText.nullable(),
|
||||
cast: z.boolean(),
|
||||
objective: scannerMatchObjectiveSchema.nullable(),
|
||||
playerStatus: scannerMatchPlayerStatusSchema.nullable(),
|
||||
teams: z.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]),
|
||||
winner: teamIndexSchema.nullable(),
|
||||
pov: z
|
||||
|
|
@ -115,6 +137,10 @@ true satisfies MutuallyAssignable<
|
|||
z.infer<typeof scannerMatchObjectiveSchema>,
|
||||
ScannerMatchObjective
|
||||
>;
|
||||
true satisfies MutuallyAssignable<
|
||||
z.infer<typeof scannerMatchPlayerStatusSchema>,
|
||||
ScannerMatchPlayerStatus
|
||||
>;
|
||||
true satisfies MutuallyAssignable<
|
||||
z.infer<typeof scannerMatchSchema>,
|
||||
ScannerMatch
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"event": "PlayerStatus",
|
||||
"data": {
|
||||
"layout": "cast",
|
||||
"time": 214,
|
||||
"special": [
|
||||
[false, false, false, false],
|
||||
[false, false, true, false]
|
||||
],
|
||||
"dead": [
|
||||
[false, false, false, true],
|
||||
[true, false, false, true]
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"notes": "Casted stream (SWS26) spectator HUD: bigger icons with camera-button badges below and special gauge percentages above (52/56/54/66 left, 72/27 right — exact counts not parsed yet; a dead icon can keep its number on screen). Left team's slot nearest the timer and the right team's outer two slots are splatted (grey X'd icons); the right team's third slot glows white with special ready."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
18
app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/expected.json
vendored
Normal file
18
app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/expected.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"event": "PlayerStatus",
|
||||
"data": {
|
||||
"layout": "pov",
|
||||
"time": 23,
|
||||
"special": [
|
||||
[true, false, false, false],
|
||||
[false, false, true, true]
|
||||
],
|
||||
"dead": [
|
||||
[false, false, false, true],
|
||||
[false, false, false, false]
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"notes": "POV footage (720p), Wahoo World SZ. Slots left-to-right per side; left team's slot closest to the timer is splatted, its outermost slot holds special; right team holds special on its two outermost slots (icon bg below the weapon lights up in team color)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
|
|
@ -13,6 +13,7 @@ import type {
|
|||
} from "../core/detectors/minimap/index";
|
||||
import { SPECTATOR_SLOTS } from "../core/detectors/minimap/rois";
|
||||
import type { ObjectiveData } from "../core/detectors/objective/index";
|
||||
import type { PlayerStatusData } from "../core/detectors/objective/player-status";
|
||||
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
|
||||
import type { ScoreboardBattleLogData } from "../core/detectors/scoreboard-battle-log/index";
|
||||
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
|
|
@ -822,3 +823,137 @@ test("a scoreboard is the preferred weapon/mode source and closes a match", () =
|
|||
test("no minimaps and no scoreboard means no match", () => {
|
||||
assert.deepEqual(buildScannerMatches([mapStart(30), mapStart(400)]), []);
|
||||
});
|
||||
|
||||
// ---- player-status samples ----
|
||||
|
||||
const ALL_FALSE = [
|
||||
[false, false, false, false],
|
||||
[false, false, false, false],
|
||||
] as PlayerStatusData["special"];
|
||||
|
||||
function playerStatus(
|
||||
t: number,
|
||||
{
|
||||
time = (300 - Math.round(t)) as number | null,
|
||||
special = ALL_FALSE,
|
||||
dead = ALL_FALSE,
|
||||
layout = "pov" as PlayerStatusData["layout"],
|
||||
} = {},
|
||||
): DetectedEvent {
|
||||
const data: PlayerStatusData = { time, special, dead, layout };
|
||||
return { type: "PlayerStatus", t, confidence: 0.9, data };
|
||||
}
|
||||
|
||||
test("player-status reads become teams-order samples on the match", () => {
|
||||
const special = [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false],
|
||||
] as PlayerStatusData["special"];
|
||||
const dead = [
|
||||
[false, false, false, false],
|
||||
[false, false, true, false],
|
||||
] as PlayerStatusData["dead"];
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
playerStatus(120, { special, dead }),
|
||||
scoreboard(300),
|
||||
]);
|
||||
assert.deepEqual(built[0]!.match.playerStatus, {
|
||||
samples: [{ t: 120, time: 180, special, dead }],
|
||||
});
|
||||
});
|
||||
|
||||
test("a losing-side pov swaps player-status samples into teams order", () => {
|
||||
const built = buildScannerMatches([
|
||||
playerStatus(120, {
|
||||
special: [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false],
|
||||
],
|
||||
dead: [
|
||||
[false, false, false, false],
|
||||
[false, true, false, false],
|
||||
],
|
||||
}),
|
||||
scoreboard(300, { povIndex: 5 }),
|
||||
]);
|
||||
const sample = built[0]!.match.playerStatus!.samples[0]!;
|
||||
assert.deepEqual(sample.special, [
|
||||
[false, false, false, false],
|
||||
[true, false, false, false],
|
||||
]);
|
||||
assert.deepEqual(sample.dead, [
|
||||
[false, true, false, false],
|
||||
[false, false, false, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test("status reads inherit the nearest counter read's cast orientation", () => {
|
||||
const built = buildScannerMatches([
|
||||
minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }),
|
||||
objective(60, {
|
||||
score: [80, 90],
|
||||
teamColor: [GREEN_INK, PURPLE_INK],
|
||||
}),
|
||||
playerStatus(60, {
|
||||
dead: [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false],
|
||||
],
|
||||
layout: "cast",
|
||||
}),
|
||||
// the caster specs a purple player: sides swap
|
||||
objective(120, {
|
||||
score: [90, 75],
|
||||
teamColor: [PURPLE_INK, GREEN_INK],
|
||||
}),
|
||||
playerStatus(120, {
|
||||
dead: [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false],
|
||||
],
|
||||
layout: "cast",
|
||||
}),
|
||||
minimap(180),
|
||||
]);
|
||||
const samples = built[0]!.match.playerStatus!.samples;
|
||||
assert.deepEqual(samples[0]!.dead, [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false],
|
||||
]);
|
||||
// the same on-screen left side is now the other team
|
||||
assert.deepEqual(samples[1]!.dead, [
|
||||
[false, false, false, false],
|
||||
[true, false, false, false],
|
||||
]);
|
||||
assert.equal(built[0]!.match.cast, true);
|
||||
});
|
||||
|
||||
test("a known non-SZ match drops its player-status reads too", () => {
|
||||
const events = [
|
||||
mapStart(0, { mode: "CB" }),
|
||||
objective(60),
|
||||
playerStatus(61),
|
||||
scoreboard(300, { mode: "CB" }),
|
||||
];
|
||||
const built = buildScannerMatches(events);
|
||||
assert.equal(built[0]!.match.playerStatus, null);
|
||||
assert.deepEqual(invalidObjectiveEvents(built), [events[1], events[2]]);
|
||||
});
|
||||
|
||||
test("replay wipes drop status reads by the shared clock projection", () => {
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
objective(60, { score: [80, 6] }),
|
||||
playerStatus(60),
|
||||
objective(61, { score: [78, 6] }),
|
||||
// broadcast re-runs the opening moments, clock jumped back
|
||||
playerStatus(90, { time: 291 }),
|
||||
objective(91, { time: 290, score: [99, 100] }),
|
||||
scoreboard(300),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
built[0]!.match.playerStatus!.samples.map((sample) => sample.t),
|
||||
[60],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function match(
|
|||
replayCode: null,
|
||||
cast: false,
|
||||
objective: null,
|
||||
playerStatus: null,
|
||||
teams: [{ players: alpha.map(player) }, { players: bravo.map(player) }],
|
||||
winner: null,
|
||||
pov: null,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { createScoreboardDetector } from "../core/detectors/scoreboard/index";
|
||||
import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index";
|
||||
import type { Detector } from "../core/detectors/types";
|
||||
import type { DetectedEvent, Detector } from "../core/detectors/types";
|
||||
import { hueDistance, hueOf } from "../core/ink-color";
|
||||
import {
|
||||
type Fixture,
|
||||
|
|
@ -40,10 +40,13 @@ test("objective fixtures exist", () => {
|
|||
|
||||
for (const fixture of fixtures) {
|
||||
test(`objective/${fixture.name}`, async (t) => {
|
||||
const { gate, events } = await runDetectorOnFixture<ObjectiveData>(
|
||||
const { gate, events: allEvents } = await runDetectorOnFixture(
|
||||
detector,
|
||||
fixture,
|
||||
);
|
||||
const events = allEvents.filter(
|
||||
(event) => event.type === "Objective",
|
||||
) as DetectedEvent<ObjectiveData>[];
|
||||
const expectPositive = fixture.expected.event === "Objective";
|
||||
|
||||
await t.test("gate", () => {
|
||||
|
|
@ -146,11 +149,12 @@ test("cast fixture pair: team ink hues identify sides across camera swaps", asyn
|
|||
|
||||
const colors = [];
|
||||
for (const fixture of pair) {
|
||||
const { events } = await runDetectorOnFixture<ObjectiveData>(
|
||||
detector,
|
||||
fixture!,
|
||||
);
|
||||
const teamColor = events[0]?.data.teamColor;
|
||||
const { events } = await runDetectorOnFixture(detector, fixture!);
|
||||
const teamColor = (
|
||||
events.find((event) => event.type === "Objective") as
|
||||
| DetectedEvent<ObjectiveData>
|
||||
| undefined
|
||||
)?.data.teamColor;
|
||||
assert.ok(teamColor?.[0] && teamColor[1], "side ink color unreadable");
|
||||
colors.push([teamColor[0], teamColor[1]] as const);
|
||||
}
|
||||
|
|
|
|||
164
app/features/scanner/tests/player-status.test.ts
Normal file
164
app/features/scanner/tests/player-status.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Golden-file tests for the PlayerStatus event over every fixture in
|
||||
* player-status/, mirroring objective.test.ts. The event is emitted by the
|
||||
* ObjectiveDetector alongside each counter read — a positive fixture must
|
||||
* produce both events off one parse, with the icon-strip statuses and the
|
||||
* shared timer matching the hand-corrected labels. Other detectors' gates
|
||||
* must stay quiet on these frames (they show live gameplay HUD, which only
|
||||
* the objective family may claim — death excepted, see objective.test.ts).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { loadOpenCV } from "../core/cv";
|
||||
import { createDeathDetector } from "../core/detectors/death/index";
|
||||
import { createMapStartDetector } from "../core/detectors/map-start/index";
|
||||
import { createMinimapDetector } from "../core/detectors/minimap/index";
|
||||
import { createObjectiveDetector } from "../core/detectors/objective/index";
|
||||
import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
type PlayerStatusData,
|
||||
} from "../core/detectors/objective/player-status";
|
||||
import { createScoreboardDetector } from "../core/detectors/scoreboard/index";
|
||||
import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index";
|
||||
import type { DetectedEvent, Detector } from "../core/detectors/types";
|
||||
import {
|
||||
type Fixture,
|
||||
isFieldSkipped,
|
||||
loadFixtures,
|
||||
runDetectorOnFixture,
|
||||
} from "../node/fixtures";
|
||||
import { loadScoreboardResources } from "../node/resources";
|
||||
import test from "./node-test-compat";
|
||||
|
||||
await loadOpenCV();
|
||||
const resources = await loadScoreboardResources();
|
||||
const detector = createObjectiveDetector(resources);
|
||||
const fixtures = loadFixtures("player-status");
|
||||
|
||||
test("player-status fixtures exist", () => {
|
||||
assert.ok(fixtures.length > 0, "no fixtures found under player-status/");
|
||||
});
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
test(`player-status/${fixture.name}`, async (t) => {
|
||||
const { gate, events } = await runDetectorOnFixture(detector, fixture);
|
||||
const expectPositive = fixture.expected.event === "PlayerStatus";
|
||||
|
||||
await t.test("gate", () => {
|
||||
assert.equal(
|
||||
gate.pass,
|
||||
expectPositive,
|
||||
`gate ${gate.pass ? "fired" : "did not fire"} (score=${gate.score.toFixed(3)}), expected ${expectPositive ? "fire" : "no fire"}`,
|
||||
);
|
||||
});
|
||||
|
||||
if (!expectPositive) return;
|
||||
const event = events.find((e) => e.type === PLAYER_STATUS_EVENT_TYPE) as
|
||||
| DetectedEvent<PlayerStatusData>
|
||||
| undefined;
|
||||
assert.ok(
|
||||
event,
|
||||
gate.pass
|
||||
? "gate passed but no PlayerStatus event (counter unreadable?)"
|
||||
: "no event (gate did not fire)",
|
||||
);
|
||||
const expected = fixture.expected.data ?? {};
|
||||
const debug = () => JSON.stringify(event.debug);
|
||||
|
||||
await t.test(
|
||||
"layout",
|
||||
{ skip: expected.layout === undefined || skip(fixture, "layout") },
|
||||
() => {
|
||||
assert.equal(event.data.layout, expected.layout);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
"time",
|
||||
{ skip: expected.time === undefined || skip(fixture, "time") },
|
||||
() => {
|
||||
assert.equal(
|
||||
event.data.time,
|
||||
expected.time,
|
||||
`time mismatch (${debug()})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (const side of [0, 1] as const) {
|
||||
for (const slot of [0, 1, 2, 3] as const) {
|
||||
await t.test(
|
||||
`special[${side}][${slot}]`,
|
||||
{
|
||||
skip:
|
||||
expected.special === undefined ||
|
||||
skip(fixture, `special.${side}.${slot}`),
|
||||
},
|
||||
() => {
|
||||
assert.equal(
|
||||
event.data.special[side][slot],
|
||||
expected.special![side]![slot],
|
||||
`special[${side}][${slot}] mismatch (${debug()})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
`dead[${side}][${slot}]`,
|
||||
{
|
||||
skip:
|
||||
expected.dead === undefined ||
|
||||
skip(fixture, `dead.${side}.${slot}`),
|
||||
},
|
||||
() => {
|
||||
assert.equal(
|
||||
event.data.dead[side][slot],
|
||||
expected.dead![side]![slot],
|
||||
`dead[${side}][${slot}] mismatch (${debug()})`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The status strip only exists on the live-gameplay HUD, which replaces no
|
||||
// other detector's screen — their gates must stay quiet on these frames
|
||||
// (death excepted: its overlay rides live gameplay, see objective.test.ts).
|
||||
const otherDetectors: readonly [string, Detector<unknown>][] = [
|
||||
["scoreboard", createScoreboardDetector(resources) as Detector<unknown>],
|
||||
[
|
||||
"scoreboard-battle-log-replay",
|
||||
createScoreboardBattleLogReplayDetector(resources) as Detector<unknown>,
|
||||
],
|
||||
[
|
||||
"scoreboard-own",
|
||||
createScoreboardOwnDetector(resources) as Detector<unknown>,
|
||||
],
|
||||
["death", createDeathDetector(resources) as Detector<unknown>],
|
||||
["map-start", createMapStartDetector(resources) as Detector<unknown>],
|
||||
["minimap", createMinimapDetector(resources) as Detector<unknown>],
|
||||
];
|
||||
for (const fixture of fixtures.filter(
|
||||
(f) => f.expected.event === "PlayerStatus",
|
||||
)) {
|
||||
test(`other gates stay quiet on player-status/${fixture.name}`, async () => {
|
||||
for (const [name, other] of otherDetectors) {
|
||||
if (name === "death") continue;
|
||||
const { gate } = await runDetectorOnFixture(other, fixture);
|
||||
assert.equal(
|
||||
gate.pass,
|
||||
false,
|
||||
`${name} gate fired (score=${gate.score.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Shared negatives: the objective gate guards PlayerStatus emission too,
|
||||
// and objective.test.ts already sweeps it over negative/ — no repeat here.
|
||||
|
||||
function skip(fixture: Fixture, field: string): boolean | string {
|
||||
return isFieldSkipped(fixture, field) ? "skipFields" : false;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
} from "~/components/match-page/MatchTimeline";
|
||||
import type { WeaponPoolWeapon } from "~/components/match-page/WeaponPool";
|
||||
import type { ObjectiveTimelineEvent } from "~/components/ObjectiveTimeline";
|
||||
import type { PlayerStatusTimelineSample } from "~/components/PlayerStatusTimeline";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { IngestedScoreboardData } from "~/features/scanner-ingest/core/Scoreboards";
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
|
|
@ -244,6 +245,10 @@ function resolveTimelineScoreboard(
|
|||
ingestedScoreboard.data.objective,
|
||||
alphaIsWinner,
|
||||
),
|
||||
playerStatus: toTimelinePlayerStatus(
|
||||
ingestedScoreboard.data.playerStatus,
|
||||
alphaIsWinner,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +273,23 @@ function toTimelineObjective(
|
|||
}));
|
||||
}
|
||||
|
||||
/** Stored status samples are winner-first; the timeline charts alpha-first. */
|
||||
function toTimelinePlayerStatus(
|
||||
playerStatus: IngestedScoreboardData["playerStatus"],
|
||||
alphaIsWinner: boolean,
|
||||
): PlayerStatusTimelineSample[] | undefined {
|
||||
if (!playerStatus) return undefined;
|
||||
|
||||
const alphaFirst = <T,>(pair: [T, T]): [T, T] =>
|
||||
alphaIsWinner ? pair : [pair[1], pair[0]];
|
||||
|
||||
return playerStatus.samples.map((sample) => ({
|
||||
t: sample.t,
|
||||
special: alphaFirst(sample.special),
|
||||
dead: alphaFirst(sample.dead),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveTimelinePickBanData(
|
||||
data: TournamentMatchLoaderData,
|
||||
opponentOneId: number,
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Lav bane-liste",
|
||||
"maps.halfSz": "50% DD",
|
||||
"maps.mapPool": "Banepulje",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Arenen-Liste erstellen",
|
||||
"maps.halfSz": "50% Herrschaft",
|
||||
"maps.mapPool": "Arenen-Pool",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "{{time}} left",
|
||||
"objectiveTimeline.penalty": "+{{value}} penalty",
|
||||
"objectiveTimeline.inControl": "In control",
|
||||
"playerStatusTimeline.splatted": "Splatted",
|
||||
"playerStatusTimeline.specialReady": "Special ready",
|
||||
"maps.createMapList": "Create map list",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Map pool",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Crear lista de mapas",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
"maps.mapPool": "Rotación de mapas",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Crear lista de escenarios",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
"maps.mapPool": "Grupo de escenario",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Créer une liste de stages",
|
||||
"maps.halfSz": "50% DdZ",
|
||||
"maps.mapPool": "Pool de stages",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Créer une liste de stages",
|
||||
"maps.halfSz": "50% DdZ",
|
||||
"maps.mapPool": "Pool de stages",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "יצירת רשימת מפות",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "מאגר מפות",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Crea lista scenari",
|
||||
"maps.halfSz": "50% ZS",
|
||||
"maps.mapPool": "Pool di scenari",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "ステージリストを作る",
|
||||
"maps.halfSz": "半分エリア",
|
||||
"maps.mapPool": "選択可能なステージ",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "맵 목록 생성",
|
||||
"maps.halfSz": "에어리어 50%",
|
||||
"maps.mapPool": "맵 풀",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Maak levellijst",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Beschikbare levels",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Stwórz liste map",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Pula map",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Criar lista de mapas",
|
||||
"maps.halfSz": "50% Zones",
|
||||
"maps.mapPool": "Seleção de mapas",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "Создать список карт",
|
||||
"maps.halfSz": "50% Зон",
|
||||
"maps.mapPool": "Пул карт",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@
|
|||
"objectiveTimeline.timeLeft": "",
|
||||
"objectiveTimeline.penalty": "",
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"maps.createMapList": "创建场地列表",
|
||||
"maps.halfSz": "真格区域占 50%",
|
||||
"maps.mapPool": "场地池",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user