mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-12 15:04:47 +00:00
add Deaths, Grabs, Returns to demo timeline, improve mobile layout
This commit is contained in:
parent
90ec7cbae2
commit
c15bce10f3
41 changed files with 374 additions and 90 deletions
|
|
@ -36,8 +36,9 @@
|
|||
}
|
||||
|
||||
.Seek[type="range"] {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
flex: 1 0 20px;
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
.ProgressFill {
|
||||
height: 100%;
|
||||
background: rgba(100, 180, 255, 0.7);
|
||||
background: rgba(10, 132, 255, 0.8);
|
||||
transition: width 0.15s;
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +134,8 @@
|
|||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="kill"] {
|
||||
.EventIcon[data-type="kill"],
|
||||
.EventIcon[data-type="death"] {
|
||||
color: #8a8380;
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +145,12 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="flag-grab"],
|
||||
.EventIcon[data-type="flag-return"] {
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.EventIcon[data-type="flag-cap"][data-affinity="enemy"] {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
import { usePlaybackActions } from "./RecordingProvider";
|
||||
import { BsPlayFill } from "react-icons/bs";
|
||||
import { AiFillStop } from "react-icons/ai";
|
||||
import { LuCrosshair } from "react-icons/lu";
|
||||
import styles from "./DemoTimeline.module.css";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
|
|
@ -18,7 +19,10 @@ function formatTime(seconds: number): string {
|
|||
}
|
||||
|
||||
const EVENT_ICON: Record<TimelineEventType, React.ReactNode> = {
|
||||
kill: <IoSkullSharp />,
|
||||
kill: <LuCrosshair />,
|
||||
death: <IoSkullSharp />,
|
||||
"flag-grab": <PiFlagBannerFill />,
|
||||
"flag-return": <PiFlagBannerFill />,
|
||||
"flag-cap": <PiFlagBannerFill />,
|
||||
"match-start": <BsPlayFill />,
|
||||
"match-end": <AiFillStop />,
|
||||
|
|
@ -26,6 +30,7 @@ const EVENT_ICON: Record<TimelineEventType, React.ReactNode> = {
|
|||
|
||||
const WEAPONS_PAST_TENSE: Record<string, string> = {
|
||||
chaingun: "chaingunned",
|
||||
plasma: "plasma rifled",
|
||||
};
|
||||
|
||||
function renderEventDescription(event: TimelineEvent): React.ReactNode {
|
||||
|
|
@ -45,6 +50,34 @@ function renderEventDescription(event: TimelineEvent): React.ReactNode {
|
|||
</>
|
||||
);
|
||||
}
|
||||
if (event.type === "death") {
|
||||
if (event.killer) {
|
||||
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} title={event.victim}>
|
||||
you
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <>{event.description}</>;
|
||||
}
|
||||
if (event.type === "flag-grab") {
|
||||
const flagLabel = event.flagTeamName
|
||||
? `the ${event.flagTeamName} flag`
|
||||
: "the enemy flag";
|
||||
return <>You grabbed {flagLabel}</>;
|
||||
}
|
||||
if (event.type === "flag-return") {
|
||||
return <>You returned your flag</>;
|
||||
}
|
||||
if (event.type === "flag-cap" && event.capturer) {
|
||||
const flagLabel =
|
||||
event.teamAffinity === "friendly"
|
||||
|
|
@ -69,7 +102,13 @@ function renderEventDescription(event: TimelineEvent): React.ReactNode {
|
|||
return event.description;
|
||||
}
|
||||
|
||||
type Filter = "all" | "kill" | "flag-cap";
|
||||
type Filter =
|
||||
| "all"
|
||||
| "kill"
|
||||
| "death"
|
||||
| "flag-grab"
|
||||
| "flag-return"
|
||||
| "flag-cap";
|
||||
|
||||
export function DemoTimeline() {
|
||||
const events = useDemoTimeline((s) => s.events);
|
||||
|
|
@ -83,6 +122,11 @@ export function DemoTimeline() {
|
|||
const handleClick = useCallback(
|
||||
(timeSec: number) => {
|
||||
seek(Math.max(0, timeSec - 3));
|
||||
// Blur so focus returns to body — allows spacebar to toggle
|
||||
// play/pause instead of re-activating the timeline button.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
},
|
||||
[seek],
|
||||
);
|
||||
|
|
@ -109,7 +153,10 @@ export function DemoTimeline() {
|
|||
if (!events) return null;
|
||||
|
||||
const killCount = events.filter((e) => e.type === "kill").length;
|
||||
const flagCount = events.filter((e) => e.type === "flag-cap").length;
|
||||
const deathCount = events.filter((e) => e.type === "death").length;
|
||||
const grabCount = events.filter((e) => e.type === "flag-grab").length;
|
||||
const returnCount = events.filter((e) => e.type === "flag-return").length;
|
||||
const capCount = events.filter((e) => e.type === "flag-cap").length;
|
||||
|
||||
return (
|
||||
<div className={styles.Root}>
|
||||
|
|
@ -130,13 +177,37 @@ export function DemoTimeline() {
|
|||
>
|
||||
Kills ({killCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.FilterButton}
|
||||
data-active={filter === "death"}
|
||||
onClick={() => setFilter("death")}
|
||||
>
|
||||
Deaths ({deathCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.FilterButton}
|
||||
data-active={filter === "flag-grab"}
|
||||
onClick={() => setFilter("flag-grab")}
|
||||
>
|
||||
Grabs ({grabCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.FilterButton}
|
||||
data-active={filter === "flag-return"}
|
||||
onClick={() => setFilter("flag-return")}
|
||||
>
|
||||
Returns ({returnCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.FilterButton}
|
||||
data-active={filter === "flag-cap"}
|
||||
onClick={() => setFilter("flag-cap")}
|
||||
>
|
||||
Flags ({flagCount})
|
||||
Caps ({capCount})
|
||||
</button>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
|||
import { LoadDemoButton } from "./LoadDemoButton";
|
||||
import { JoinServerButton } from "./JoinServerButton";
|
||||
import { Accordion, AccordionGroup } from "./Accordion";
|
||||
import styles from "./InspectorControls.module.css";
|
||||
import { useTouchDevice } from "./useTouchDevice";
|
||||
import { DemoTimeline } from "./DemoTimeline";
|
||||
import { MapTourPanel } from "./MapTourPanel";
|
||||
|
|
@ -21,6 +20,7 @@ import { useRecording } from "./RecordingProvider";
|
|||
import { useDataSource, useMissionName } from "../state/gameEntityStore";
|
||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||
import { hasMission } from "../manifest";
|
||||
import styles from "./InspectorControls.module.css";
|
||||
|
||||
const DEFAULT_PANELS = ["controls", "preferences", "audio", "timeline"];
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,23 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.CloseSidebarButton {
|
||||
composes: IconButton from "./InspectorControls.module.css";
|
||||
composes: LabelledButton from "./InspectorControls.module.css";
|
||||
width: calc(100% - 24px);
|
||||
margin: 12px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.ButtonLabel {
|
||||
composes: ButtonLabel from "./InspectorControls.module.css";
|
||||
}
|
||||
|
||||
.CloseSidebarButton .ButtonLabel {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.Frame {
|
||||
display: grid;
|
||||
|
|
@ -182,6 +199,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.CloseSidebarButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.ToggleSidebarButton {
|
||||
font-size: 28px;
|
||||
|
|
|
|||
|
|
@ -310,6 +310,14 @@ export function MapInspector() {
|
|||
onChooseMap={handleChooseMap}
|
||||
onCancelChoosingMap={handleCancelChoosingMap}
|
||||
/>
|
||||
<button
|
||||
className={styles.CloseSidebarButton}
|
||||
onClick={(event) => {
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className={styles.ButtonLabel}>Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</Activity>
|
||||
<InputProvider>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { createStore } from "zustand/vanilla";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
|
||||
export type TimelineEventType = "match-start" | "match-end" | "kill" | "flag-cap";
|
||||
export type TimelineEventType =
|
||||
| "match-start"
|
||||
| "match-end"
|
||||
| "kill"
|
||||
| "death"
|
||||
| "flag-grab"
|
||||
| "flag-return"
|
||||
| "flag-cap";
|
||||
|
||||
/** Relationship of the event to the recorder's team. */
|
||||
export type TeamAffinity = "friendly" | "enemy" | "neutral";
|
||||
|
|
@ -10,17 +17,17 @@ export interface TimelineEvent {
|
|||
timeSec: number;
|
||||
type: TimelineEventType;
|
||||
description: string;
|
||||
/** For flag-cap: whether the recorder's team or enemy team scored. */
|
||||
/** For flag events: whether the recorder's team or enemy team was involved. */
|
||||
teamAffinity?: TeamAffinity;
|
||||
/** For kill events: name of the killer. */
|
||||
/** For kill/death events: name of the killer. */
|
||||
killer?: string;
|
||||
/** For kill events: name of the victim. */
|
||||
/** For kill/death events: name of the victim. */
|
||||
victim?: string;
|
||||
/** For kill events: weapon or method of death (e.g. "disc", "mortar"). */
|
||||
/** For kill/death events: weapon or damage type (e.g. "disc", "ground"). */
|
||||
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. */
|
||||
/** For flag-cap/flag-grab events: name of the flag's team. */
|
||||
flagTeamName?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,21 +10,30 @@ const log = createLogger("demoTimelineScanner");
|
|||
const YIELD_INTERVAL = 500;
|
||||
|
||||
/**
|
||||
* Kill message types where args[5] is the killer name.
|
||||
* Case-insensitive matching is used.
|
||||
* All death message types where args[2]=victimName, args[5]=killerName,
|
||||
* args[9]=DamageTypeText. Case-insensitive matching is used.
|
||||
*
|
||||
* Note: explicit suicide (Ctrl+K) uses `msgSuicide` which is NOT in
|
||||
* this set — we intentionally ignore those.
|
||||
*/
|
||||
const KILL_MSG_TYPES = new Set([
|
||||
// Player-vs-player kills
|
||||
"msglegitkill",
|
||||
"msgheadshotkill",
|
||||
"msgteamkill",
|
||||
// Self-inflicted (own weapon damage, cratering)
|
||||
"msgselfkill",
|
||||
// Explosions (can be self or other)
|
||||
"msgexplosionkill",
|
||||
// Vehicle-related
|
||||
"msgvehiclekill",
|
||||
"msgvehiclecrash",
|
||||
"msgvehiclespawnkill",
|
||||
// Turret-related
|
||||
"msgturretkill",
|
||||
"msgcturretkill",
|
||||
"msgturretselfkill",
|
||||
// Environmental
|
||||
"msgoobkill",
|
||||
"msgcampkill",
|
||||
"msgrogueminekill",
|
||||
|
|
@ -32,16 +41,74 @@ const KILL_MSG_TYPES = new Set([
|
|||
"msglightningkill",
|
||||
]);
|
||||
|
||||
/** Suicide message types — we never attribute these as kills. */
|
||||
const SUICIDE_MSG_TYPES = new Set([
|
||||
"msgselfkill",
|
||||
"msgturretselfkill",
|
||||
"msgoobkill",
|
||||
"msglavakill",
|
||||
"msglightningkill",
|
||||
"msgcampkill",
|
||||
/**
|
||||
* Death message types where the victim killed themselves (own weapon,
|
||||
* cratering, environmental hazards). These are NOT credited as kills
|
||||
* but ARE shown as deaths. Does NOT include explicit Ctrl+K suicide
|
||||
* (which is `msgSuicide`, not in KILL_MSG_TYPES at all).
|
||||
*/
|
||||
const SELF_INFLICTED_MSG_TYPES = new Set([
|
||||
"msgselfkill", // Own weapon damage or cratering ($DamageType::Ground)
|
||||
"msgturretselfkill", // Own turret
|
||||
"msgvehiclecrash", // Vehicle crash
|
||||
"msgvehiclespawnkill", // Crushed by vehicle spawning
|
||||
"msgoobkill", // Out of bounds
|
||||
"msglavakill", // Lava
|
||||
"msglightningkill", // Lightning
|
||||
"msgcampkill", // Nexus camping
|
||||
]);
|
||||
|
||||
/** Descriptions for self-inflicted deaths by message type.
|
||||
* Used for environmental deaths where the msg type alone is sufficient. */
|
||||
const SELF_INFLICTED_DESCRIPTIONS: Record<string, string> = {
|
||||
msgoobkill: "Out of bounds",
|
||||
msglavakill: "Killed by lava",
|
||||
msglightningkill: "Struck by lightning",
|
||||
msgcampkill: "Nexus camping",
|
||||
msgturretselfkill: "Killed by own turret",
|
||||
msgvehiclecrash: "Vehicle crash",
|
||||
msgvehiclespawnkill: "Crushed by vehicle",
|
||||
};
|
||||
|
||||
/** Display names for $DamageTypeText values that need formatting. */
|
||||
const WEAPON_DISPLAY_NAMES: Record<string, string> = {
|
||||
turret: "base turret",
|
||||
"plasma turret": "plasma turret",
|
||||
"aa turret": "AA turret",
|
||||
"elf turret": "ELF turret",
|
||||
"mortar turret": "mortar turret",
|
||||
"missile turret": "missile turret",
|
||||
"clamp turret": "indoor deployable turret",
|
||||
"spike turret": "outdoor deployable turret",
|
||||
"sentry turret": "sentry turret",
|
||||
"shrike blaster": "Shrike",
|
||||
"belly turret": "Havoc belly turret",
|
||||
"bomber bomb": "bomber",
|
||||
"tank chaingun": "tank chaingun",
|
||||
"tank mortar": "tank mortar",
|
||||
"mpb missile": "MPB missile",
|
||||
forcefield: "force field",
|
||||
impact: "vehicle impact",
|
||||
crash: "vehicle crash",
|
||||
explosion: "explosion",
|
||||
};
|
||||
|
||||
/** Descriptions for msgselfkill deaths by $DamageTypeText (args[9]).
|
||||
* The msg type is the same for cratering AND own-weapon deaths, so
|
||||
* the damage type text is needed to distinguish them. */
|
||||
const SELF_KILL_BY_WEAPON: Record<string, string> = {
|
||||
ground: "Cratered",
|
||||
mine: "Killed by own mine",
|
||||
satchelcharge: "Killed by own satchel",
|
||||
grenade: "Killed by own grenade",
|
||||
mortar: "Killed by own mortar",
|
||||
disc: "Killed by own disc",
|
||||
plasma: "Killed by own plasma",
|
||||
blaster: "Killed by own blaster",
|
||||
missile: "Killed by own missile",
|
||||
explosion: "Killed by explosion",
|
||||
};
|
||||
|
||||
function resolveNetString(s: string, netStrings: Map<number, string>): string {
|
||||
if (s.length >= 2 && s.charCodeAt(0) === 1) {
|
||||
const id = parseInt(s.slice(1), 10);
|
||||
|
|
@ -236,6 +303,55 @@ export async function scanDemoTimeline(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Flag grab (taken from base).
|
||||
// Wire: args[2]=playerName, args[3]=teamName, args[4]=flag.team, args[5]=playerNameBase
|
||||
if (msgTypeLower === "msgctfflagtaken" && args.length >= 3) {
|
||||
const playerName = stripTaggedStringMarkup(
|
||||
resolveNetString(args[2], netStrings),
|
||||
).trim();
|
||||
// Only include grabs by the control player (recorder).
|
||||
if (
|
||||
normalizedRecorder &&
|
||||
playerName.toLowerCase() === normalizedRecorder
|
||||
) {
|
||||
const flagTeamName =
|
||||
args.length >= 4
|
||||
? stripTaggedStringMarkup(
|
||||
resolveNetString(args[3], netStrings),
|
||||
).trim()
|
||||
: undefined;
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "flag-grab",
|
||||
description: `You took the ${flagTeamName ?? "enemy"} flag`,
|
||||
teamAffinity: "friendly",
|
||||
flagTeamName: flagTeamName || undefined,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flag return (returned to base).
|
||||
// Wire: args[2]=playerName, args[3]=teamName, args[4]=flag.team
|
||||
if (msgTypeLower === "msgctfflagreturned" && args.length >= 3) {
|
||||
const playerName = stripTaggedStringMarkup(
|
||||
resolveNetString(args[2], netStrings),
|
||||
).trim();
|
||||
// Only include returns by the control player (recorder).
|
||||
if (
|
||||
normalizedRecorder &&
|
||||
playerName.toLowerCase() === normalizedRecorder
|
||||
) {
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "flag-return",
|
||||
description: "You returned your flag",
|
||||
teamAffinity: "friendly",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flag capture.
|
||||
// Wire: args[2]=capturerName, args[3]=teamName, args[4]=flag.team, args[5]=capturer.team
|
||||
if (msgTypeLower === "msgctfflagcapped" && args.length >= 2) {
|
||||
|
|
@ -283,11 +399,8 @@ export async function scanDemoTimeline(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Kill messages.
|
||||
// Kill/death 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),
|
||||
|
|
@ -302,24 +415,78 @@ export async function scanDemoTimeline(
|
|||
).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,
|
||||
});
|
||||
if (normalizedRecorder) {
|
||||
const normalizedKiller = killerName.toLowerCase();
|
||||
const normalizedVictim = victimName.toLowerCase();
|
||||
const isSelfInflicted = SELF_INFLICTED_MSG_TYPES.has(msgTypeLower);
|
||||
|
||||
// Kills: recorder killed someone else (not self-inflicted).
|
||||
if (
|
||||
!isSelfInflicted &&
|
||||
normalizedKiller === normalizedRecorder &&
|
||||
normalizedVictim !== 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Deaths: recorder is the victim.
|
||||
if (normalizedVictim === normalizedRecorder) {
|
||||
if (isSelfInflicted) {
|
||||
// Self-inflicted: cratering, own weapon, environmental.
|
||||
// For msgselfkill, use the weapon text to distinguish
|
||||
// (e.g. "ground" = cratered, "mine" = own mine).
|
||||
const msgDescription =
|
||||
SELF_INFLICTED_DESCRIPTIONS[msgTypeLower];
|
||||
const weaponDescription =
|
||||
msgTypeLower === "msgselfkill" && weapon
|
||||
? SELF_KILL_BY_WEAPON[weapon.toLowerCase()]
|
||||
: undefined;
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "death",
|
||||
description: msgDescription ?? weaponDescription ?? "Died",
|
||||
weapon: weapon || undefined,
|
||||
});
|
||||
} else if (
|
||||
normalizedKiller !== normalizedRecorder &&
|
||||
killerName
|
||||
) {
|
||||
// Killed by another player or controlled turret.
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "death",
|
||||
description: `Killed by ${killerName}`,
|
||||
killer: killerName,
|
||||
victim: victimName,
|
||||
weapon: weapon || undefined,
|
||||
});
|
||||
} else {
|
||||
// No killer name (base turret, explosion, etc.) or
|
||||
// self-inflicted on a non-self-inflicted msg type
|
||||
// (e.g. msgexplosionkill where killer === victim).
|
||||
const weaponLower = weapon?.toLowerCase();
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "death",
|
||||
description: weapon
|
||||
? `Killed by ${WEAPON_DISPLAY_NAMES[weaponLower!] ?? weapon}`
|
||||
: "Died",
|
||||
weapon: weapon || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue