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 { 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 },
|
||||||
|
|
|
||||||
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"
|
| "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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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