feat(arma): track player kills from sync events
This commit is contained in:
parent
d4060c06f0
commit
3dfdc8916d
5 changed files with 278 additions and 1 deletions
|
|
@ -1,9 +1,22 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
||||
|
||||
export const ArmaSyncEvents: CollectionConfig = {
|
||||
slug: "arma-sync-events",
|
||||
admin: { group: "Server", useAsTitle: "eventId", defaultColumns: ["eventId", "type", "server", "occurredAt"] },
|
||||
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: [
|
||||
{ name: "eventId", type: "text", required: true, unique: true, index: true },
|
||||
{ name: "server", type: "relationship", relationTo: "game-servers", required: true, index: true },
|
||||
|
|
|
|||
112
src/lib/arma-bridge/processSyncEvents.ts
Normal file
112
src/lib/arma-bridge/processSyncEvents.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ interface GameEventInput {
|
|||
| "loadouts"
|
||||
| "market-listings"
|
||||
| "market-negotiations"
|
||||
| "tickets";
|
||||
| "tickets"
|
||||
| "game-servers";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -52,6 +53,9 @@ export async function emitGameEvent(
|
|||
structure: event.structure,
|
||||
},
|
||||
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", {
|
||||
type: event.type,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ export const EventTypes = {
|
|||
// Game Tick
|
||||
GameTick: "game:tick",
|
||||
|
||||
// Server Presence
|
||||
ServerOffline: "server:offline",
|
||||
|
||||
// Minigames
|
||||
MinigameFlappy: "minigame:flappy",
|
||||
|
||||
|
|
@ -89,6 +92,10 @@ export const EventTypes = {
|
|||
MissionAutoComplete: "mission:auto-complete",
|
||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||
|
||||
// Combat
|
||||
PlayerKill: "combat:kill",
|
||||
PlayerDeath: "combat:death",
|
||||
|
||||
// Helpdesk
|
||||
TicketCreate: "ticket:create",
|
||||
TicketReply: "ticket:reply",
|
||||
|
|
|
|||
141
tests/int/kill-tracking.int.spec.ts
Normal file
141
tests/int/kill-tracking.int.spec.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue