Files
2026-09-09 21:46:10 +03:00

359 lines
8.3 KiB
TypeScript

/**
* Flattens detected events into one CSV. One row per event; types share
* columns where they overlap, and a scoreboard's eight player rows (or the
* minimap's cards, or the objective HUD's icon strip) pack into one cell.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
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 {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import {
SCOREBOARD_EVENT_TYPE,
type ScoreboardData,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import {
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
type ScoreboardBattleLogReplayData,
} from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { formatClock, formatTime } from "./format";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
export interface CsvEvent {
type: string;
/** video/stream time in seconds */
t: number;
/** wall-clock time of detection (live capture only) */
detectedAt?: number;
confidence: number;
data: unknown;
}
const HEADER = [
"type",
"time",
"t_seconds",
"detected_at",
"confidence",
"lobby",
"mode",
"stage",
"winner_score",
"loser_score",
"pov",
"weapon",
"name",
"abilities",
"players",
"replay_code",
"replay_timestamp",
];
type Cell = string | number | null | undefined;
function csvCell(value: Cell): string {
if (value === null || value === undefined) return "";
const s = String(value);
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
/** [head, clothes, shoes] rows of [main, sub, sub, sub] ability ids */
function formatAbilities(rows: string[][]): string {
return rows.map((row) => row.join("+")).join(" | ");
}
/** the minimap's flat [head, clothes, shoes] main-ability row */
function formatMinimapAbilities(abilities: (string | null)[]): string {
return abilities.map((a) => a ?? "?").join("+");
}
function formatMinimapPlayers(data: MinimapData): string {
const fmt = (
label: string,
p: {
name: string | null;
weaponId: number | null;
abilities: (string | null)[];
dead: boolean;
specialReady: boolean;
},
) =>
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` +
`${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`;
return [
...data.teammates.map((p, i) => fmt(p.self ? "self" : `ally${i + 1}`, p)),
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),
].join("; ");
}
/** one team's four slots as top-candidate weapon names, ✕ = splatted */
function formatStripWeaponsSide(data: StripWeaponsData, side: 0 | 1): string {
return data.slots[side]
.map((candidates) =>
candidates === null
? "✕"
: (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"),
)
.join(" | ");
}
/** one team's four icons as ✕ splatted / ★ special ready / · alive */
function formatPlayerStatusSide(data: PlayerStatusData, side: 0 | 1): string {
return data.dead[side]
.map((dead, slot) => (dead ? "✕" : data.special[side][slot] ? "★" : "·"))
.join("");
}
function formatPlayers(data: ScoreboardData): string {
return data.players
.map(
(p, i) =>
`${i < 4 ? "W" : "L"} ${p.name} · ${p.weaponId ?? "?"} · ${p.paint ?? "?"}p ` +
`${p.ka ?? "?"}/${p.d ?? "?"}/${p.s ?? "?"}`,
)
.join("; ");
}
function eventCells(event: CsvEvent): Cell[] {
const base: Cell[] = [
event.type,
formatTime(event.t),
Math.round(event.t * 1000) / 1000,
event.detectedAt === undefined
? ""
: new Date(event.detectedAt).toISOString(),
Math.round(event.confidence * 1000) / 1000,
];
switch (event.type) {
case DEATH_EVENT_TYPE: {
const d = event.data as DeathData;
return [
...base,
"",
"",
"",
"",
"",
"",
weaponLabel(d.weaponType, d.weaponId),
d.name,
formatAbilities(d.abilities),
"",
"",
"",
];
}
case MAP_START_EVENT_TYPE: {
const d = event.data as MapStartData;
return [
...base,
"",
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
"",
"",
"",
"",
"",
"",
];
}
case SCOREBOARD_OWN_EVENT_TYPE: {
const d = event.data as ScoreboardOwnData;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
mainWeaponLabel(d.weaponId),
"",
formatAbilities(d.abilities),
"",
"",
"",
];
}
case KILL_EVENT_TYPE: {
const d = event.data as KillData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
// oldest first, the order the splats happened
`${clock}${d.names
.toReversed()
.map((name) => name ?? "?")
.join(" | ")}`,
"",
"",
"",
"",
];
}
case OBJECTIVE_EVENT_TYPE: {
const d = event.data as ObjectiveData;
const sideText = (side: 0 | 1) =>
`${d.score[side] ?? "?"}${d.penalty[side] !== null ? ` (+${d.penalty[side]})` : ""}${d.control[side] ? " ctrl" : ""}`;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
modeLabel(d.mode),
"",
"",
"",
"",
"",
"",
"",
`${clock}${sideText(0)} vs ${sideText(1)}`,
"",
"",
];
}
case MINIMAP_EVENT_TYPE: {
const d = event.data as MinimapData;
const self = d.teammates.find((p) => p.self);
return [
...base,
"", // lobby
"", // mode (not readable from the minimap)
stageLabel(d.stage), // stage (planner-signature match)
"", // winner_score
"", // loser_score
self?.name,
self ? mainWeaponLabel(self.weaponId) : "",
"",
self ? formatMinimapAbilities(self.abilities) : "",
formatMinimapPlayers(d),
"",
"",
];
}
case PLAYER_STATUS_EVENT_TYPE: {
const d = event.data as PlayerStatusData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatPlayerStatusSide(d, 0)} vs ${formatPlayerStatusSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case STRIP_WEAPONS_EVENT_TYPE: {
const d = event.data as StripWeaponsData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatStripWeaponsSide(d, 0)} vs ${formatStripWeaponsSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case SCOREBOARD_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE: {
// the three scoreboard reads share the base shape
const d = event.data as ScoreboardData &
Partial<ScoreboardBattleLogReplayData>;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
d.matchScores[0],
d.matchScores[1],
d.povIndex === null ? "" : d.players[d.povIndex]?.name,
"",
"",
"",
formatPlayers(d),
d.replayCode ?? "",
d.timestamp ?? "",
];
}
default:
return [...base, ...Array(HEADER.length - base.length).fill("")];
}
}
export function eventsToCsv(events: CsvEvent[]): string {
const lines = [HEADER.join(",")];
for (const event of events)
lines.push(eventCells(event).map(csvCell).join(","));
return `${lines.join("\r\n")}\r\n`;
}
export function downloadEventsCsv(filename: string, events: CsvEvent[]): void {
const blob = new Blob([eventsToCsv(events)], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}