import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { applyZonePressure, computeZoneState, PRESSURE_OUTCOME_DELTAS } from "@/lib/operations/territory"; let payload: Payload; const RUN = `terr-${Date.now().toString(36)}`; const DAY = 86_400_000; // --------------------------------------------------------------------------- // Fixture tracking for cleanup // --------------------------------------------------------------------------- const syncEventIds: number[] = []; const opEventIds: number[] = []; const opEffectIds: number[] = []; const zoneIds: number[] = []; const pressureRowIds: number[] = []; const userIds: number[] = []; let factionId: number; let mapId: number; let serverId: number; let zoneAId: number; let zoneBId: number; let zoneCId: number; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Full hook chain: raw op.end sync event -> validated event -> effect -> * settlement (handleOperationEnd) -> one attributed pressure delta. */ async function endOperation(label: string, outcome: string, zoneId: number): Promise { const sync = await payload.create({ collection: "arma-sync-events", data: { eventId: `${RUN}-${label}`, server: serverId, type: "operation.end", occurredAt: new Date().toISOString(), payload: { operationId: RUN, version: 1, outcome, zoneId, factionId, actorId: userIds[0] }, }, overrideAccess: true, depth: 0, }); syncEventIds.push(sync.id); const events = await payload.find({ collection: "operation-events", where: { sourceEvent: { equals: sync.id } }, limit: 1, depth: 0, overrideAccess: true, }); expect(events.docs).toHaveLength(1); opEventIds.push(events.docs[0].id); const effects = await payload.find({ collection: "operation-effects", where: { operationEvent: { equals: events.docs[0].id } }, limit: 1, depth: 0, overrideAccess: true, }); expect(effects.docs).toHaveLength(1); opEffectIds.push(effects.docs[0].id); return effects.docs[0].id; } async function pressureRowCount(zoneId: number): Promise { const rows = await payload.find({ collection: "zone-pressure", where: { zone: { equals: zoneId } }, limit: 100, depth: 0, overrideAccess: true, }); return rows.docs.length; } // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); const actor = await payload.create({ collection: "users", data: { username: `${RUN}-actor`, discordUsername: `${RUN}-actor-discord`, displayName: `${RUN} Actor`, steamId: `${RUN}-steam-actor`, password: "Test1234", }, overrideAccess: true, depth: 0, }); userIds.push(actor.id); const faction = await payload.create({ collection: "factions", data: { name: `${RUN} Hostiles`, type: "majorFaction" }, overrideAccess: true, depth: 0, }); factionId = faction.id; const server = await payload.create({ collection: "game-servers", data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" }, overrideAccess: true, depth: 0, }); serverId = server.id; const map = await payload.create({ collection: "maps", data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, }); mapId = map.id; for (const label of ["alpha", "bravo", "charlie"] as const) { const zone = await payload.create({ collection: "map-zones", data: { name: `${RUN} Zone ${label.toUpperCase()}`, map: mapId, type: "territory", points: [ [0, 0], [100, 0], [100, 100], [0, 100], ], }, overrideAccess: true, depth: 0, }); zoneIds.push(zone.id); if (label === "alpha") zoneAId = zone.id; if (label === "bravo") zoneBId = zone.id; if (label === "charlie") zoneCId = zone.id; } }); afterAll(async () => { if (!payload) return; const pressureRows = await payload.find({ collection: "zone-pressure", where: { operationId: { equals: RUN } }, limit: 100, depth: 0, overrideAccess: true, }); for (const row of pressureRows.docs) { await payload.delete({ collection: "zone-pressure", id: row.id, overrideAccess: true }).catch(() => {}); } for (const id of opEffectIds) { await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {}); } for (const id of opEventIds) { await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {}); } for (const id of syncEventIds) { await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {}); } for (const id of zoneIds) { await payload.delete({ collection: "map-zones", id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "factions", id: factionId, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {}); for (const id of userIds) { await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("Zone pressure (task 7)", () => { it("applies hostile pressure once for a failed operation and replays as a no-op", async () => { const effectId = await endOperation("fail-1", "failed", zoneAId); const state = await computeZoneState(payload, zoneAId); expect(state.pressure).toBe(PRESSURE_OUTCOME_DELTAS.failure); expect(state.state).toBe("contested"); expect(state.routeRiskMultiplier).toBe(1.25); expect(state.yieldMultiplier).toBe(0.9); expect(state.activeRows).toBe(1); const replay = await applyZonePressure(payload, { zoneId: zoneAId, factionId, operationId: RUN, sourceEffectId: effectId, delta: PRESSURE_OUTCOME_DELTAS.failure, }); expect(replay.created).toBe(false); expect(await pressureRowCount(zoneAId)).toBe(1); }); it("walks the full arc: contested after failure, recovering after a successful push", async () => { await endOperation("fail-b", "failed", zoneBId); const contested = await computeZoneState(payload, zoneBId); expect(contested.state).toBe("contested"); await endOperation("success-b", "success", zoneBId); const recovering = await computeZoneState(payload, zoneBId); expect(recovering.pressure).toBe(15); expect(recovering.state).toBe("recovering"); expect(recovering.routeRiskMultiplier).toBe(1); expect(recovering.yieldMultiplier).toBe(1); }); it("reaches hostile control at the documented threshold", async () => { await endOperation("fail-2", "failed", zoneAId); const controlled = await computeZoneState(payload, zoneAId); expect(controlled.pressure).toBe(80); expect(controlled.state).toBe("controlled"); expect(controlled.routeRiskMultiplier).toBe(1.5); expect(controlled.yieldMultiplier).toBe(0.75); }); it("decays deterministically, halving per elapsed period", async () => { const now = Date.now(); const plusOnePeriod = await computeZoneState(payload, zoneAId, new Date(now + 7 * DAY + 3_600_000)); expect(plusOnePeriod.pressure).toBe(40); expect(plusOnePeriod.state).toBe("contested"); const plusTwoPeriods = await computeZoneState(payload, zoneAId, new Date(now + 14 * DAY + 3_600_000)); expect(plusTwoPeriods.pressure).toBe(20); expect(plusTwoPeriods.state).toBe("recovering"); const plusTenPeriods = await computeZoneState(payload, zoneAId, new Date(now + 70 * DAY + 3_600_000)); expect(plusTenPeriods.pressure).toBeLessThan(1); expect(plusTenPeriods.state).toBe("neutral"); }); it("never touches zones the operation did not declare", async () => { const untouched = await computeZoneState(payload, zoneCId); expect(untouched.pressure).toBe(0); expect(untouched.state).toBe("neutral"); expect(untouched.activeRows).toBe(0); expect(await pressureRowCount(zoneCId)).toBe(0); }); it("keeps placement semantics independent of pressure (pure helpers only)", () => { // Placement rules live in src/lib/map/placement.ts (terrain + resources), // which pressure never imports or mutates. This assertion documents the // boundary: state thresholds are the ONLY pressure inputs to downstream // multipliers. expect(PRESSURE_OUTCOME_DELTAS.failure).toBe(40); expect(PRESSURE_OUTCOME_DELTAS.success).toBe(-25); }); });