Deterministic, explainable, advisory readiness aggregates per subject behind a permission gate, and mission-scoped zone pressure with read-time decay yielding route-risk and yield multipliers. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
258 lines
9 KiB
TypeScript
258 lines
9 KiB
TypeScript
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<number> {
|
|
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<number> {
|
|
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);
|
|
});
|
|
});
|