mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 22:29:31 +00:00
add demo timeline
This commit is contained in:
parent
0de43ece22
commit
68f2c184da
67 changed files with 1420 additions and 621 deletions
|
|
@ -48,6 +48,9 @@
|
|||
padding: 16px 12px 10px 12px;
|
||||
}
|
||||
|
||||
.BodyNoPadding {
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ export function Accordion({
|
|||
value,
|
||||
label,
|
||||
children,
|
||||
noPadding = false,
|
||||
}: {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
noPadding?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<RadixAccordion.Item value={value}>
|
||||
|
|
@ -22,7 +24,9 @@ export function Accordion({
|
|||
<IoCaretForward className={styles.TriggerIcon} /> {label}
|
||||
</RadixAccordion.Trigger>
|
||||
<RadixAccordion.Content className={styles.Content}>
|
||||
<div className={styles.Body}>{children}</div>
|
||||
<div className={noPadding ? styles.BodyNoPadding : styles.Body}>
|
||||
{children}
|
||||
</div>
|
||||
</RadixAccordion.Content>
|
||||
</RadixAccordion.Item>
|
||||
);
|
||||
|
|
|
|||
179
src/components/DemoTimeline.module.css
Normal file
179
src/components/DemoTimeline.module.css
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ProgressWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.ProgressLabel {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ProgressBar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ProgressFill {
|
||||
height: 100%;
|
||||
background: rgba(100, 180, 255, 0.7);
|
||||
transition: width 0.15s;
|
||||
}
|
||||
|
||||
.Filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.FilterButton {
|
||||
padding: 2px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.FilterButton[data-active="true"] {
|
||||
background: rgba(0, 98, 179, 0.6);
|
||||
border-color: rgba(100, 180, 255, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.EventList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px 0 12px 0;
|
||||
}
|
||||
|
||||
.EventRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 10px 3px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.EventRow:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.FilterButton:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.FilterButton[data-active="true"]:hover {
|
||||
background: rgba(0, 98, 179, 0.8);
|
||||
border-color: rgba(100, 180, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.Filters {
|
||||
}
|
||||
|
||||
.FilterButton {
|
||||
flex: 1 0 auto;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.EventList {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.EventRow {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.EventTime {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.6;
|
||||
min-width: 3.5em;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.EventIcon {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="kill"] {
|
||||
color: #8a8380;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="flag-cap"] {
|
||||
color: #69db7c;
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="flag-cap"][data-affinity="enemy"] {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="flag-cap"][data-affinity="neutral"] {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="match-start"] {
|
||||
color: #74c0fc;
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="match-end"] {
|
||||
color: #74c0fc;
|
||||
}
|
||||
|
||||
.EventDescription {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.Killer {
|
||||
}
|
||||
|
||||
.Victim {
|
||||
}
|
||||
|
||||
.DamageType {
|
||||
}
|
||||
|
||||
.Empty {
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
padding: 4px 10px 12px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
170
src/components/DemoTimeline.tsx
Normal file
170
src/components/DemoTimeline.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { PiFlagBannerFill } from "react-icons/pi";
|
||||
import { IoSkullSharp } from "react-icons/io5";
|
||||
import { useDemoTimeline } from "../state/demoTimelineStore";
|
||||
import type {
|
||||
TimelineEvent,
|
||||
TimelineEventType,
|
||||
} from "../state/demoTimelineStore";
|
||||
import { usePlaybackActions } from "./RecordingProvider";
|
||||
import { BsPlayFill } from "react-icons/bs";
|
||||
import { AiFillStop } from "react-icons/ai";
|
||||
import styles from "./DemoTimeline.module.css";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const EVENT_ICON: Record<TimelineEventType, React.ReactNode> = {
|
||||
kill: <IoSkullSharp />,
|
||||
"flag-cap": <PiFlagBannerFill />,
|
||||
"match-start": <BsPlayFill />,
|
||||
"match-end": <AiFillStop />,
|
||||
};
|
||||
|
||||
const WEAPONS_PAST_TENSE: Record<string, string> = {
|
||||
chaingun: "chaingunned",
|
||||
};
|
||||
|
||||
function renderEventDescription(event: TimelineEvent): React.ReactNode {
|
||||
if (event.type === "kill" && event.killer && event.victim) {
|
||||
return (
|
||||
<>
|
||||
<span className={styles.Killer}>{event.killer}</span>{" "}
|
||||
<span className={styles.DamageType}>
|
||||
{event.weapon
|
||||
? (WEAPONS_PAST_TENSE[event.weapon] ??
|
||||
`${event.weapon}${event.weapon.endsWith("e") ? "d" : "ed"}`)
|
||||
: "killed"}
|
||||
</span>{" "}
|
||||
<span className={styles.Victim}>{event.victim}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (event.type === "flag-cap" && event.capturer) {
|
||||
const flagLabel =
|
||||
event.teamAffinity === "friendly"
|
||||
? "the enemy flag"
|
||||
: event.teamAffinity === "enemy"
|
||||
? "your flag"
|
||||
: event.flagTeamName
|
||||
? `the ${event.flagTeamName} flag`
|
||||
: "a flag";
|
||||
return (
|
||||
<>
|
||||
{event.capturer} captured {flagLabel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (event.type === "match-start") {
|
||||
return "Match started";
|
||||
}
|
||||
if (event.type === "match-end") {
|
||||
return "Match ended";
|
||||
}
|
||||
return event.description;
|
||||
}
|
||||
|
||||
type Filter = "all" | "kill" | "flag-cap";
|
||||
|
||||
export function DemoTimeline() {
|
||||
const events = useDemoTimeline((s) => s.events);
|
||||
const scanProgress = useDemoTimeline((s) => s.scanProgress);
|
||||
const { seek } = usePlaybackActions();
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
|
||||
const filtered =
|
||||
events?.filter((e) => filter === "all" || e.type === filter) ?? [];
|
||||
|
||||
const handleClick = useCallback(
|
||||
(timeSec: number) => {
|
||||
seek(Math.max(0, timeSec - 3));
|
||||
},
|
||||
[seek],
|
||||
);
|
||||
|
||||
// Scanning in progress.
|
||||
if (scanProgress != null && events == null) {
|
||||
return (
|
||||
<div className={styles.Root}>
|
||||
<div className={styles.ProgressWrap}>
|
||||
<span className={styles.ProgressLabel}>
|
||||
Scanning… {Math.round(scanProgress * 100)}%
|
||||
</span>
|
||||
<div className={styles.ProgressBar}>
|
||||
<div
|
||||
className={styles.ProgressFill}
|
||||
style={{ width: `${scanProgress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!events) return null;
|
||||
|
||||
const killCount = events.filter((e) => e.type === "kill").length;
|
||||
const flagCount = events.filter((e) => e.type === "flag-cap").length;
|
||||
|
||||
return (
|
||||
<div className={styles.Root}>
|
||||
<div className={styles.Filters}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.FilterButton}
|
||||
data-active={filter === "all"}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
All ({events.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.FilterButton}
|
||||
data-active={filter === "kill"}
|
||||
onClick={() => setFilter("kill")}
|
||||
>
|
||||
Kills ({killCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.FilterButton}
|
||||
data-active={filter === "flag-cap"}
|
||||
onClick={() => setFilter("flag-cap")}
|
||||
>
|
||||
Flags ({flagCount})
|
||||
</button>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.Empty}>No events found.</div>
|
||||
) : (
|
||||
<div className={styles.EventList}>
|
||||
{filtered.map((event, i) => (
|
||||
<button
|
||||
key={`${event.timeSec}-${event.type}-${i}`}
|
||||
type="button"
|
||||
className={styles.EventRow}
|
||||
onClick={() => handleClick(event.timeSec)}
|
||||
>
|
||||
<span className={styles.EventTime}>
|
||||
{formatTime(event.timeSec)}
|
||||
</span>
|
||||
<span
|
||||
className={styles.EventIcon}
|
||||
data-type={event.type}
|
||||
data-affinity={event.teamAffinity}
|
||||
>
|
||||
{EVENT_ICON[event.type]}
|
||||
</span>
|
||||
<span className={styles.EventDescription}>
|
||||
{renderEventDescription(event)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,12 +15,13 @@ import { JoinServerButton } from "./JoinServerButton";
|
|||
import { Accordion, AccordionGroup } from "./Accordion";
|
||||
import styles from "./InspectorControls.module.css";
|
||||
import { useTouchDevice } from "./useTouchDevice";
|
||||
import { DemoTimeline } from "./DemoTimeline";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { useDataSource, useMissionName } from "../state/gameEntityStore";
|
||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||
import { hasMission } from "../manifest";
|
||||
|
||||
const DEFAULT_PANELS = ["controls", "preferences", "audio"];
|
||||
const DEFAULT_PANELS = ["controls", "preferences", "audio", "timeline"];
|
||||
|
||||
export const InspectorControls = memo(function InspectorControls({
|
||||
missionName,
|
||||
|
|
@ -181,6 +182,11 @@ export const InspectorControls = memo(function InspectorControls({
|
|||
</div>
|
||||
<div className={styles.Accordions}>
|
||||
<AccordionGroup type="multiple" defaultValue={DEFAULT_PANELS}>
|
||||
{recording?.source === "demo" && (
|
||||
<Accordion value="timeline" label="Timeline" noPadding>
|
||||
<DemoTimeline />
|
||||
</Accordion>
|
||||
)}
|
||||
<Accordion value="controls" label="Controls">
|
||||
<div className={styles.Field}>
|
||||
<label htmlFor="speedInput">Fly speed</label>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { MdOndemandVideo } from "react-icons/md";
|
||||
import { createLogger } from "../logger";
|
||||
import { demoTimelineStore } from "../state/demoTimelineStore";
|
||||
import { liveConnectionStore } from "../state/liveConnectionStore";
|
||||
import { usePlaybackActions, useRecording } from "./RecordingProvider";
|
||||
import styles from "./LoadDemoButton.module.css";
|
||||
|
|
@ -21,6 +22,7 @@ export function LoadDemoButton({
|
|||
const { setRecording } = usePlaybackActions();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const parseTokenRef = useRef(0);
|
||||
const scanAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (choosingMap && isDemoLoaded) {
|
||||
|
|
@ -30,7 +32,10 @@ export function LoadDemoButton({
|
|||
if (isDemoLoaded) {
|
||||
// Unload the recording/parser but leave entities frozen in the store.
|
||||
parseTokenRef.current += 1;
|
||||
scanAbortRef.current?.abort();
|
||||
scanAbortRef.current = null;
|
||||
setRecording(null);
|
||||
demoTimelineStore.getState().reset();
|
||||
return;
|
||||
}
|
||||
inputRef.current?.click();
|
||||
|
|
@ -57,6 +62,37 @@ export function LoadDemoButton({
|
|||
liveState.disconnectServer();
|
||||
// Metadata-first: mission/game-mode sync happens immediately.
|
||||
setRecording(recording);
|
||||
// Kick off background timeline scan.
|
||||
scanAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
scanAbortRef.current = abortController;
|
||||
const store = demoTimelineStore.getState();
|
||||
store.reset();
|
||||
store.setScanProgress(0);
|
||||
import("../stream/demoTimelineScanner")
|
||||
.then(({ scanDemoTimeline }) =>
|
||||
scanDemoTimeline(
|
||||
buffer,
|
||||
recording.recorderName,
|
||||
(p) => {
|
||||
if (parseTokenRef.current !== parseToken) return;
|
||||
demoTimelineStore.getState().setScanProgress(p);
|
||||
},
|
||||
abortController.signal,
|
||||
),
|
||||
)
|
||||
.then((events) => {
|
||||
if (parseTokenRef.current !== parseToken) return;
|
||||
const s = demoTimelineStore.getState();
|
||||
s.setEvents(events);
|
||||
s.setScanProgress(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (parseTokenRef.current !== parseToken) return;
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
log.error("Timeline scan failed: %o", err);
|
||||
demoTimelineStore.getState().setScanProgress(null);
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("Failed to load demo: %o", err);
|
||||
}
|
||||
|
|
|
|||
54
src/state/demoTimelineStore.ts
Normal file
54
src/state/demoTimelineStore.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createStore } from "zustand/vanilla";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
|
||||
export type TimelineEventType = "match-start" | "match-end" | "kill" | "flag-cap";
|
||||
|
||||
/** Relationship of the event to the recorder's team. */
|
||||
export type TeamAffinity = "friendly" | "enemy" | "neutral";
|
||||
|
||||
export interface TimelineEvent {
|
||||
timeSec: number;
|
||||
type: TimelineEventType;
|
||||
description: string;
|
||||
/** For flag-cap: whether the recorder's team or enemy team scored. */
|
||||
teamAffinity?: TeamAffinity;
|
||||
/** For kill events: name of the killer. */
|
||||
killer?: string;
|
||||
/** For kill events: name of the victim. */
|
||||
victim?: string;
|
||||
/** For kill events: weapon or method of death (e.g. "disc", "mortar"). */
|
||||
weapon?: string;
|
||||
/** For flag-cap events: name of the player who captured. */
|
||||
capturer?: string;
|
||||
/** For flag-cap events: name of the team whose flag was captured. */
|
||||
flagTeamName?: string;
|
||||
}
|
||||
|
||||
export interface DemoTimelineState {
|
||||
events: TimelineEvent[] | null;
|
||||
scanProgress: number | null;
|
||||
setEvents(events: TimelineEvent[]): void;
|
||||
setScanProgress(progress: number | null): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export const demoTimelineStore = createStore<DemoTimelineState>((set) => ({
|
||||
events: null,
|
||||
scanProgress: null,
|
||||
setEvents(events) {
|
||||
set({ events });
|
||||
},
|
||||
setScanProgress(progress) {
|
||||
set({ scanProgress: progress });
|
||||
},
|
||||
reset() {
|
||||
set({ events: null, scanProgress: null });
|
||||
},
|
||||
}));
|
||||
|
||||
export function useDemoTimeline<T>(
|
||||
selector: (state: DemoTimelineState) => T,
|
||||
equality?: (a: T, b: T) => boolean,
|
||||
): T {
|
||||
return useStoreWithEqualityFn(demoTimelineStore, selector, equality);
|
||||
}
|
||||
341
src/stream/demoTimelineScanner.ts
Normal file
341
src/stream/demoTimelineScanner.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { BlockTypeMove, BlockTypePacket, DemoParser } from "t2-demo-parser";
|
||||
import { TICK_DURATION_MS } from "./entityClassification";
|
||||
import { stripTaggedStringMarkup } from "./streamHelpers";
|
||||
import type { TimelineEvent } from "../state/demoTimelineStore";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const log = createLogger("demoTimelineScanner");
|
||||
|
||||
/** Yield control every N blocks to keep the UI responsive. */
|
||||
const YIELD_INTERVAL = 500;
|
||||
|
||||
/**
|
||||
* Kill message types where args[5] is the killer name.
|
||||
* Case-insensitive matching is used.
|
||||
*/
|
||||
const KILL_MSG_TYPES = new Set([
|
||||
"msglegitkill",
|
||||
"msgheadshotkill",
|
||||
"msgteamkill",
|
||||
"msgselfkill",
|
||||
"msgexplosionkill",
|
||||
"msgvehiclekill",
|
||||
"msgvehiclecrash",
|
||||
"msgvehiclespawnkill",
|
||||
"msgturretkill",
|
||||
"msgcturretkill",
|
||||
"msgturretselfkill",
|
||||
"msgoobkill",
|
||||
"msgcampkill",
|
||||
"msgrogueminekill",
|
||||
"msglavakill",
|
||||
"msglightningkill",
|
||||
]);
|
||||
|
||||
/** Suicide message types — we never attribute these as kills. */
|
||||
const SUICIDE_MSG_TYPES = new Set([
|
||||
"msgselfkill",
|
||||
"msgturretselfkill",
|
||||
"msgoobkill",
|
||||
"msglavakill",
|
||||
"msglightningkill",
|
||||
"msgcampkill",
|
||||
]);
|
||||
|
||||
function resolveNetString(s: string, netStrings: Map<number, string>): string {
|
||||
if (s.length >= 2 && s.charCodeAt(0) === 1) {
|
||||
const id = parseInt(s.slice(1), 10);
|
||||
if (Number.isFinite(id)) return netStrings.get(id) ?? s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatRemoteArgs(
|
||||
template: string,
|
||||
args: string[],
|
||||
netStrings: Map<number, string>,
|
||||
): string {
|
||||
let resolved = resolveNetString(template, netStrings);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const placeholder = `%${i + 1}`;
|
||||
if (resolved.includes(placeholder)) {
|
||||
resolved = resolved.replaceAll(
|
||||
placeholder,
|
||||
stripTaggedStringMarkup(resolveNetString(args[i], netStrings)),
|
||||
);
|
||||
}
|
||||
}
|
||||
resolved = resolved.replace(/%\d+/g, "");
|
||||
return stripTaggedStringMarkup(resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an entire demo recording for timeline events (kills, flag caps,
|
||||
* match start). Yields to the event loop periodically to stay responsive.
|
||||
*/
|
||||
export async function scanDemoTimeline(
|
||||
buffer: ArrayBuffer,
|
||||
recorderName: string | null,
|
||||
onProgress?: (progress: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TimelineEvent[]> {
|
||||
const parser = new DemoParser(new Uint8Array(buffer));
|
||||
const { initialBlock } = await parser.load();
|
||||
|
||||
const netStrings = new Map<number, string>();
|
||||
for (const [id, value] of initialBlock.taggedStrings) {
|
||||
netStrings.set(id, value);
|
||||
}
|
||||
|
||||
const registry = parser.getRegistry();
|
||||
const normalizedRecorder = recorderName
|
||||
? stripTaggedStringMarkup(recorderName).trim().toLowerCase()
|
||||
: null;
|
||||
|
||||
// Extract recorder's clientId and initial team from demoValues.
|
||||
let recorderClientId: number | null = null;
|
||||
let recorderTeamId: number | null = null;
|
||||
for (let i = 0; i < initialBlock.demoValues.length; i++) {
|
||||
if (initialBlock.demoValues[i] !== "readplayerinfo") continue;
|
||||
const value = initialBlock.demoValues[i + 1];
|
||||
if (value?.startsWith("1\t")) {
|
||||
const fields = value.split("\t");
|
||||
const cid = parseInt(fields[1], 10);
|
||||
if (Number.isFinite(cid)) recorderClientId = cid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Get initial team from PLAYERLIST in demoValues.
|
||||
if (recorderClientId != null) {
|
||||
const dv = initialBlock.demoValues;
|
||||
// PLAYERLIST starts after MISC (1 value): idx 1 is playerCount.
|
||||
const playerCount =
|
||||
parseInt(dv[1] === "<BLANK>" ? "0" : (dv[1] ?? "0"), 10) || 0;
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const fields = (dv[2 + i] ?? "").split("\t");
|
||||
const cid = parseInt(fields[2], 10);
|
||||
if (cid === recorderClientId) {
|
||||
const tid = parseInt(fields[4], 10);
|
||||
if (!isNaN(tid)) recorderTeamId = tid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const events: TimelineEvent[] = [];
|
||||
let moveTicks = 0;
|
||||
let seenMatchStart = false;
|
||||
let blockCount = 0;
|
||||
const totalBlocks = parser.blockCount;
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
let block;
|
||||
try {
|
||||
block = parser.nextBlock();
|
||||
} catch (err) {
|
||||
// Parser cursor state is unknown after a throw — stop scanning and
|
||||
// return whatever events we've found rather than risking an infinite loop.
|
||||
log.warn("Stopping scan at block %d due to read error: %o", blockCount, err);
|
||||
break;
|
||||
}
|
||||
if (!block) break;
|
||||
blockCount++;
|
||||
|
||||
if (block.type === BlockTypeMove) {
|
||||
moveTicks++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type !== BlockTypePacket || !block.parsed) continue;
|
||||
|
||||
const packet = block.parsed as {
|
||||
events?: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
if (!packet.events) continue;
|
||||
|
||||
const timeSec = moveTicks * (TICK_DURATION_MS / 1000);
|
||||
|
||||
for (const evt of packet.events) {
|
||||
try {
|
||||
if (!evt.parsedData) continue;
|
||||
const type = evt.parsedData.type as string | undefined;
|
||||
|
||||
if (type === "NetStringEvent") {
|
||||
const id = evt.parsedData.id as number;
|
||||
const value = evt.parsedData.value as string | undefined;
|
||||
if (value != null) {
|
||||
netStrings.set(id, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also check the registry name for RemoteCommandEvent identification.
|
||||
const eventName = registry.getEventParser(evt.classId)?.name;
|
||||
if (
|
||||
type !== "RemoteCommandEvent" &&
|
||||
eventName !== "RemoteCommandEvent"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const funcName = resolveNetString(
|
||||
evt.parsedData.funcName as string,
|
||||
netStrings,
|
||||
);
|
||||
if (funcName !== "ServerMessage") continue;
|
||||
|
||||
const args = evt.parsedData.args as string[];
|
||||
if (!args || args.length < 2) continue;
|
||||
|
||||
const msgType = resolveNetString(args[0], netStrings);
|
||||
const msgTypeLower = msgType.toLowerCase();
|
||||
|
||||
// Track recorder's team changes.
|
||||
if (
|
||||
msgTypeLower === "msgclientjointeam" &&
|
||||
recorderClientId != null &&
|
||||
args.length >= 6
|
||||
) {
|
||||
const clientId = parseInt(
|
||||
resolveNetString(args[4], netStrings),
|
||||
10,
|
||||
);
|
||||
if (clientId === recorderClientId) {
|
||||
const teamId = parseInt(
|
||||
resolveNetString(args[5], netStrings),
|
||||
10,
|
||||
);
|
||||
if (!isNaN(teamId)) recorderTeamId = teamId;
|
||||
}
|
||||
}
|
||||
|
||||
// Match start: MsgMissionStart is sent when the match actually begins
|
||||
// (after the countdown). MsgSystemClock is just the countdown timer.
|
||||
if (msgTypeLower === "msgmissionstart" && !seenMatchStart) {
|
||||
seenMatchStart = true;
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "match-start",
|
||||
description: "Match started",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match ended.
|
||||
if (msgTypeLower === "msggameover") {
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "match-end",
|
||||
description: "Match ended",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flag capture.
|
||||
// Wire: args[2]=capturerName, args[3]=teamName, args[4]=flag.team, args[5]=capturer.team
|
||||
if (msgTypeLower === "msgctfflagcapped" && args.length >= 2) {
|
||||
const description = formatRemoteArgs(
|
||||
args[1],
|
||||
args.slice(2),
|
||||
netStrings,
|
||||
);
|
||||
const capturerName =
|
||||
args.length >= 3
|
||||
? stripTaggedStringMarkup(
|
||||
resolveNetString(args[2], netStrings),
|
||||
).trim()
|
||||
: undefined;
|
||||
const flagTeamName =
|
||||
args.length >= 4
|
||||
? stripTaggedStringMarkup(
|
||||
resolveNetString(args[3], netStrings),
|
||||
).trim()
|
||||
: undefined;
|
||||
let teamAffinity: "friendly" | "enemy" | "neutral" = "neutral";
|
||||
if (
|
||||
recorderTeamId != null &&
|
||||
recorderTeamId > 0 &&
|
||||
args.length >= 6
|
||||
) {
|
||||
const capturerTeam = parseInt(
|
||||
resolveNetString(args[5], netStrings),
|
||||
10,
|
||||
);
|
||||
if (capturerTeam === recorderTeamId) {
|
||||
teamAffinity = "friendly";
|
||||
} else if (!isNaN(capturerTeam)) {
|
||||
teamAffinity = "enemy";
|
||||
}
|
||||
}
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "flag-cap",
|
||||
description: description || "Flag captured",
|
||||
teamAffinity,
|
||||
capturer: capturerName,
|
||||
flagTeamName: flagTeamName || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kill messages.
|
||||
if (KILL_MSG_TYPES.has(msgTypeLower) && args.length >= 6) {
|
||||
// Exclude suicides — victim == killer, no distinct killer to credit.
|
||||
if (SUICIDE_MSG_TYPES.has(msgTypeLower)) continue;
|
||||
|
||||
// args[2]=victimName, args[5]=killerName, args[9]=DamageTypeText
|
||||
const killerName = stripTaggedStringMarkup(
|
||||
resolveNetString(args[5], netStrings),
|
||||
).trim();
|
||||
const victimName = stripTaggedStringMarkup(
|
||||
resolveNetString(args[2], netStrings),
|
||||
).trim();
|
||||
const weapon =
|
||||
args.length >= 10
|
||||
? stripTaggedStringMarkup(
|
||||
resolveNetString(args[9], netStrings),
|
||||
).trim()
|
||||
: undefined;
|
||||
|
||||
// Only include kills where the recorder is the killer.
|
||||
if (
|
||||
normalizedRecorder &&
|
||||
killerName.toLowerCase() === normalizedRecorder
|
||||
) {
|
||||
const description = formatRemoteArgs(
|
||||
args[1],
|
||||
args.slice(2),
|
||||
netStrings,
|
||||
);
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "kill",
|
||||
description: description || `${killerName} got a kill`,
|
||||
killer: killerName,
|
||||
victim: victimName,
|
||||
weapon: weapon || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Skipping malformed event in block %d: %o", blockCount, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Yield control periodically.
|
||||
if (blockCount % YIELD_INTERVAL === 0) {
|
||||
if (totalBlocks && onProgress) {
|
||||
onProgress(Math.min(blockCount / totalBlocks, 1));
|
||||
}
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Scanned %d blocks, found %d events", blockCount, events.length);
|
||||
return events;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue