diff --git a/src/collections/server/ArmaSyncEvents.ts b/src/collections/server/ArmaSyncEvents.ts index 019c8d2..1c338a1 100644 --- a/src/collections/server/ArmaSyncEvents.ts +++ b/src/collections/server/ArmaSyncEvents.ts @@ -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 }, diff --git a/src/lib/arma-bridge/processSyncEvents.ts b/src/lib/arma-bridge/processSyncEvents.ts new file mode 100644 index 0000000..4d2f38c --- /dev/null +++ b/src/lib/arma-bridge/processSyncEvents.ts @@ -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 { + 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)?.[field] ?? 0; + + await payload.update({ + collection: "profiles", + id: profile.id, + data: { + progression: { + scoreboard: { + ...((profile.progression?.scoreboard as Record) ?? {}), + [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 }); +} diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts index 0b83ec4..ac5d743 100644 --- a/src/utils/event-log/emit.ts +++ b/src/utils/event-log/emit.ts @@ -28,7 +28,8 @@ interface GameEventInput { | "loadouts" | "market-listings" | "market-negotiations" - | "tickets"; + | "tickets" + | "game-servers"; targetId?: number; data?: Record; } @@ -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, diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index 2be6140..3bbca54 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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", diff --git a/tests/int/kill-tracking.int.spec.ts b/tests/int/kill-tracking.int.spec.ts new file mode 100644 index 0000000..d1d9184 --- /dev/null +++ b/tests/int/kill-tracking.int.spec.ts @@ -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)?.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)?.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)?.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)?.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(), + }); + }); + }); +});