1
0
Fork 0

feat(arma): track player kills from sync events

This commit is contained in:
Jason Fraley 2026-08-26 18:05:51 -04:00
parent d4060c06f0
commit 3dfdc8916d
5 changed files with 278 additions and 1 deletions

View file

@ -1,9 +1,22 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
export const ArmaSyncEvents: CollectionConfig = { export const ArmaSyncEvents: CollectionConfig = {
slug: "arma-sync-events", slug: "arma-sync-events",
admin: { group: "Server", useAsTitle: "eventId", defaultColumns: ["eventId", "type", "server", "occurredAt"] }, admin: { group: "Server", useAsTitle: "eventId", defaultColumns: ["eventId", "type", "server", "occurredAt"] },
access: { read: ({ req }) => !!req.user, create: () => false, update: () => false, delete: () => false }, access: { read: ({ req }) => !!req.user, create: () => false, update: () => false, delete: () => false },
hooks: {
afterChange: [
async ({ doc, req, operation }) => {
if (operation !== "create") return;
if (doc.type === "player.kill") {
processPlayerKill(req.payload, doc).catch((err) => {
req.payload.logger.error(`[KillTracker] Failed to process kill event ${doc.eventId}: ${err}`);
});
}
},
],
},
fields: [ fields: [
{ name: "eventId", type: "text", required: true, unique: true, index: true }, { name: "eventId", type: "text", required: true, unique: true, index: true },
{ name: "server", type: "relationship", relationTo: "game-servers", required: true, index: true }, { name: "server", type: "relationship", relationTo: "game-servers", required: true, index: true },

View file

@ -0,0 +1,112 @@
import type { Payload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { broadcastToUser } from "@/lib/realtime/bus";
type ScoreboardField = "infantryKills" | "vehicleKills" | "armorKills" | "airKills" | "deaths";
const VEHICLE_CLASS_AIR = /(^|[_])(Heli|Plane|VTOL|UAV)(_|$)/i;
const VEHICLE_CLASS_ARMOR = /(^|[_])(MBT|APC|IFV|Tank|LT[_]?)(_|$)/i;
const VEHICLE_CLASS_INFANTRY = /(^|[_])(Soldier|Crew|Officer|Sniper|Spotter|Medic|Engineer|Rifleman|Grenadier|MachineGunner)(_|$)/i;
export function classifyKillCategory(victimVehicle: string | null | undefined): ScoreboardField {
if (!victimVehicle) return "infantryKills";
if (VEHICLE_CLASS_AIR.test(victimVehicle)) return "airKills";
if (VEHICLE_CLASS_ARMOR.test(victimVehicle)) return "armorKills";
if (VEHICLE_CLASS_INFANTRY.test(victimVehicle)) return "infantryKills";
return "vehicleKills";
}
interface KillPayload {
victimName: string;
killerName: string;
killerSteamId: string;
victimVehicle: string | null;
killerVehicle: string | null;
}
export function parseKillPayload(payload: unknown): KillPayload | null {
if (!Array.isArray(payload) || payload.length < 6) return null;
const victimName = String(payload[0] ?? "");
const killerName = String(payload[2] ?? "");
const killerSteamId = String(payload[3] ?? "");
const victimVehicle = payload[6] ? String(payload[6]) : null;
const killerVehicle = payload[7] ? String(payload[7]) : null;
if (!killerSteamId) return null;
return { victimName, killerName, killerSteamId, victimVehicle, killerVehicle };
}
export async function processPlayerKill(
payload: Payload,
eventDoc: { id: number; payload: unknown; occurredAt: string },
): Promise<void> {
const kill = parseKillPayload(eventDoc.payload);
if (!kill) return;
const users = await payload.find({
collection: "users",
where: { steamId: { equals: kill.killerSteamId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const user = users.docs[0];
if (!user) {
payload.logger.warn(
`[KillTracker] No user found for Steam ID ${kill.killerSteamId} — kill not recorded.`,
);
return;
}
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const profile = profiles.docs[0];
if (!profile) {
payload.logger.warn(
`[KillTracker] No profile found for user ${user.id} (${user.username}) — kill not recorded.`,
);
return;
}
const field = classifyKillCategory(kill.victimVehicle);
const current = (profile.progression?.scoreboard as Record<string, number | undefined>)?.[field] ?? 0;
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
scoreboard: {
...((profile.progression?.scoreboard as Record<string, number>) ?? {}),
[field]: current + 1,
},
},
},
depth: 0,
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.PlayerKill,
actor: user.id,
message: `${kill.killerName} eliminated ${kill.victimName} (${field.replace("Kills", "")})`,
targetCollection: "users",
targetId: user.id,
data: {
victim: kill.victimName,
category: field,
victimVehicle: kill.victimVehicle,
killerVehicle: kill.killerVehicle,
},
});
broadcastToUser("profile-update", user.id, { profileId: profile.id });
}

View file

@ -28,7 +28,8 @@ interface GameEventInput {
| "loadouts" | "loadouts"
| "market-listings" | "market-listings"
| "market-negotiations" | "market-negotiations"
| "tickets"; | "tickets"
| "game-servers";
targetId?: number; targetId?: number;
data?: Record<string, unknown>; data?: Record<string, unknown>;
} }
@ -52,6 +53,9 @@ export async function emitGameEvent(
structure: event.structure, structure: event.structure,
}, },
depth: 0, depth: 0,
// Bins and tests run without an authenticated user; create access on
// game-event-logs requires one, so system events would be silently dropped.
overrideAccess: true,
}); });
broadcast("game-event", { broadcast("game-event", {
type: event.type, type: event.type,

View file

@ -57,6 +57,9 @@ export const EventTypes = {
// Game Tick // Game Tick
GameTick: "game:tick", GameTick: "game:tick",
// Server Presence
ServerOffline: "server:offline",
// Minigames // Minigames
MinigameFlappy: "minigame:flappy", MinigameFlappy: "minigame:flappy",
@ -89,6 +92,10 @@ export const EventTypes = {
MissionAutoComplete: "mission:auto-complete", MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle", MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
// Combat
PlayerKill: "combat:kill",
PlayerDeath: "combat:death",
// Helpdesk // Helpdesk
TicketCreate: "ticket:create", TicketCreate: "ticket:create",
TicketReply: "ticket:reply", TicketReply: "ticket:reply",

View file

@ -0,0 +1,141 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { classifyKillCategory, parseKillPayload, processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
let payload: Payload;
const RUN = `kill-${Date.now().toString(36)}`;
describe("Kill tracking", () => {
let userId: number;
let profileId: number;
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const user = await payload.create({
collection: "users",
data: {
username: `${RUN}-killer`,
discordUsername: `${RUN}-killer-discord`,
displayName: `${RUN} Killer`,
steamId: `${RUN}-steam-001`,
password: "Test1234",
},
overrideAccess: true,
depth: 0,
});
userId = user.id;
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
profileId = profiles.docs[0].id;
});
afterAll(async () => {
if (profileId) {
await payload.delete({ collection: "profiles", id: profileId, overrideAccess: true }).catch(() => {});
}
if (userId) {
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
}
});
describe("classifyKillCategory", () => {
it("classifies infantry kills", () => {
expect(classifyKillCategory("O_T_Soldier_AT_F")).toBe("infantryKills");
expect(classifyKillCategory("B_Soldier_GL_F")).toBe("infantryKills");
expect(classifyKillCategory(null)).toBe("infantryKills");
expect(classifyKillCategory("")).toBe("infantryKills");
});
it("classifies vehicle kills", () => {
expect(classifyKillCategory("B_T_LTV_01_recon_F")).toBe("vehicleKills");
expect(classifyKillCategory("O_T_MRAP_01_hmg_F")).toBe("vehicleKills");
});
it("classifies armor kills", () => {
expect(classifyKillCategory("B_T_MBT_01_cannon_F")).toBe("armorKills");
expect(classifyKillCategory("O_T_APC_Wheeled_02_rcws_F")).toBe("armorKills");
expect(classifyKillCategory("I_LT_01_AT_F")).toBe("armorKills");
});
it("classifies air kills", () => {
expect(classifyKillCategory("B_Heli_Light_01_armed_F")).toBe("airKills");
expect(classifyKillCategory("O_T_VTOL_02_infantry_dynamicLoadout_F")).toBe("airKills");
expect(classifyKillCategory("B_Plane_Fighter_01_stealth_F")).toBe("airKills");
});
});
describe("parseKillPayload", () => {
it("parses valid kill payload", () => {
const result = parseKillPayload([
"Qi Fu", "", "[Capt.] J. Fraley", "76561198091303179",
"[Capt.] J. Fraley", "76561198091303179", "O_T_Soldier_AT_F", "B_Officer_Parade_Veteran_F",
]);
expect(result).toEqual({
victimName: "Qi Fu",
killerName: "[Capt.] J. Fraley",
killerSteamId: "76561198091303179",
victimVehicle: "O_T_Soldier_AT_F",
killerVehicle: "B_Officer_Parade_Veteran_F",
});
});
it("returns null for invalid payload", () => {
expect(parseKillPayload(null)).toBeNull();
expect(parseKillPayload("string")).toBeNull();
expect(parseKillPayload([])).toBeNull();
expect(parseKillPayload([1, 2, 3])).toBeNull();
});
});
describe("processPlayerKill", () => {
it("increments infantryKills for soldier victim", async () => {
const before = await payload.findByID({ collection: "profiles", id: profileId, depth: 0, overrideAccess: true });
const beforeCount = (before.progression?.scoreboard as Record<string, number>)?.infantryKills ?? 0;
await processPlayerKill(payload, {
id: 999001,
payload: ["Qi Fu", "", `${RUN} Killer`, `${RUN}-steam-001`,
`${RUN} Killer`, `${RUN}-steam-001`, "O_T_Soldier_AT_F", "B_Officer_Parade_Veteran_F"],
occurredAt: new Date().toISOString(),
});
const after = await payload.findByID({ collection: "profiles", id: profileId, depth: 0, overrideAccess: true });
const afterCount = (after.progression?.scoreboard as Record<string, number>)?.infantryKills ?? 0;
expect(afterCount).toBe(beforeCount + 1);
});
it("increments armorKills for armored victim", async () => {
const before = await payload.findByID({ collection: "profiles", id: profileId, depth: 0, overrideAccess: true });
const beforeCount = (before.progression?.scoreboard as Record<string, number>)?.armorKills ?? 0;
await processPlayerKill(payload, {
id: 999002,
payload: ["Tank", "", `${RUN} Killer`, `${RUN}-steam-001`,
`${RUN} Killer`, `${RUN}-steam-001`, "B_T_MBT_01_cannon_F", "B_T_MBT_01_cannon_F"],
occurredAt: new Date().toISOString(),
});
const after = await payload.findByID({ collection: "profiles", id: profileId, depth: 0, overrideAccess: true });
const afterCount = (after.progression?.scoreboard as Record<string, number>)?.armorKills ?? 0;
expect(afterCount).toBe(beforeCount + 1);
});
it("ignores events for unknown Steam IDs", async () => {
await processPlayerKill(payload, {
id: 999003,
payload: ["Victim", "", "Unknown Player", "00000000000000000",
"Unknown Player", "00000000000000000", "O_T_Soldier_AT_F", "B_Soldier_F"],
occurredAt: new Date().toISOString(),
});
});
});
});