1
0
Fork 0

feat(operations): readiness and zone pressure aggregates

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>
This commit is contained in:
Jason Fraley 2026-09-20 02:43:53 -04:00
parent 67adaa301c
commit 3c70160dfc
12 changed files with 63774 additions and 0 deletions

View file

@ -0,0 +1,152 @@
import type { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
/**
* Role-scoped, advisory operation readiness + planning-confidence aggregates.
*
* One row per (subjectType, subjectId, windowStart). Writes are internal only
* (create/update/delete denied; the readiness service writes via `overrideAccess`
* with its own permission gate). Read is gated by the NEW
* `operation-readiness:read` permission, so it is role-scoped, not open to any
* logged-in user.
*
* The stored row carries only normalized scores, category weights, and input
* counts plus a generated explanation. It never contains raw evaluation content
* (evaluator names, rater ids, or comment text), which is what the privacy
* guarantee rests on.
*/
export const OperationReadiness: CollectionConfig = {
slug: "operation-readiness",
admin: {
group: "Operations",
useAsTitle: "subjectLabel",
defaultColumns: ["subjectType", "subjectLabel", "windowStart", "readinessScore", "planningConfidenceScore"],
},
access: {
read: requirePermission("operation-readiness:read"),
create: () => false,
update: () => false,
delete: () => false,
},
fields: [
{
name: "subjectType",
type: "select",
required: true,
options: [
{ label: "User", value: "user" },
{ label: "Role", value: "role" },
],
admin: {
description: "Who the aggregate is for: a specific user, or a whole role.",
},
},
{
name: "subjectId",
type: "number",
admin: {
description: "Id of the subject (user id for user-scoped; null for role-scoped).",
},
},
{
name: "subjectLabel",
type: "text",
admin: {
description: "Human-readable label for the subject (role name or username). Display only.",
},
},
{
name: "windowStart",
type: "date",
required: true,
admin: {
description: "Start of the explicit time window this aggregate covers.",
},
},
{
name: "windowEnd",
type: "date",
admin: {
description: "End of the time window.",
},
},
{
name: "windowLabel",
type: "text",
admin: {
description: "Optional human label for the window (e.g. an ISO week).",
},
},
{
name: "operationId",
type: "text",
admin: {
description: "Optional operation identity the window's data is attributed to.",
},
},
{
name: "calculationVersion",
type: "text",
required: true,
defaultValue: "1.0.0",
admin: {
description: "Readiness calculation model version.",
},
},
{
name: "nature",
type: "select",
required: true,
defaultValue: "advisory",
options: [{ label: "Advisory (non-gating)", value: "advisory" }],
admin: {
description:
"Advisory-only. Never used to gate, punish, or rank players against one another.",
},
},
{
name: "readinessScore",
type: "number",
admin: {
description: "Weighted 0-100 readiness score over present contributing categories.",
},
},
{
name: "planningConfidenceScore",
type: "number",
admin: {
description: "Weighted 0-100 planning-confidence sub-score from pre-op signals.",
},
},
{
name: "contributingCategories",
type: "json",
admin: {
description:
"Structured per-category breakdown ({ category, weight, score, inputCount }). No evaluator data.",
},
},
{
name: "inputCount",
type: "number",
admin: {
description: "Total component inputs consumed by the aggregate.",
},
},
{
name: "explanation",
type: "textarea",
admin: {
description:
"Human-readable explanation enumerating contributing categories and weights. Never contains evaluator names, ids, or comments.",
},
},
{
name: "recomputedAt",
type: "date",
admin: {
description: "When the aggregate was last computed.",
},
},
],
};

View file

@ -0,0 +1,73 @@
import type { CollectionConfig } from "payload";
/**
* Strategic pressure ledger over map zones (task 7).
*
* One row per settled operation outcome affecting a zone: an additive delta
* attributed to the faction, mission, and source effect that caused it.
* `sourceEffect` is unique, so a replayed effect is a structural no-op. The
* zone's strategic state is computed deterministically from decayed deltas in
* `src/lib/operations/territory.ts`; this collection stores no derived state.
*
* This is an informational strategic layer only: it never changes placement
* rules, terrain validation, or any map authoring behavior.
*
* Writes are internal only (create/update/delete denied; the settlement layer
* writes via `overrideAccess`). Read is open to any logged-in user.
*/
export const ZonePressure: CollectionConfig = {
slug: "zone-pressure",
admin: {
group: "Operations",
useAsTitle: "operationId",
defaultColumns: ["zone", "faction", "operationId", "delta", "createdAt"],
},
access: {
read: ({ req }) => !!req.user,
create: () => false,
update: () => false,
delete: () => false,
},
timestamps: true,
fields: [
{
name: "zone",
type: "relationship",
relationTo: "map-zones",
required: true,
index: true,
admin: { description: "The zone this pressure applies to." },
},
{
name: "faction",
type: "relationship",
relationTo: "factions",
index: true,
admin: { description: "The faction whose activity drives this pressure." },
},
{ name: "operationId", type: "text", required: true, index: true },
{
name: "mission",
type: "relationship",
relationTo: "missions",
index: true,
admin: { description: "The mission the operation was scoped to (causal AAR link)." },
},
{
name: "sourceEffect",
type: "relationship",
relationTo: "operation-effects",
required: true,
unique: true,
index: true,
admin: { description: "The settled operation effect that produced this delta (idempotency key)." },
},
{
name: "delta",
type: "number",
required: true,
admin: { description: "Signed pressure delta: hostile gains positive, friendly control negative." },
},
{ name: "note", type: "textarea" },
],
};

View file

@ -0,0 +1,325 @@
/**
* Operation readiness service: gathers per-subject participation signals from
* settled operation facts, computes a deterministic advisory aggregate, persists
* it idempotently, and serves it behind a permission gate.
*
* Privacy model: the aggregate stores only normalized scores, category weights,
* and input counts. It never stores evaluator names, rater ids, or evaluation
* comment text, and the read path returns only the stored aggregate -- raw
* evaluation content is never surfaced.
*
* Sources (all post-settlement, never live/unsettled data):
* - participation: applied `operation-effects` reached via their provenance
* link (mission-attendance -> operation-events -> operation-effects).
* - attendance: `mission-attendances` "yes" for the subject.
* - assignments: `assignments` the subject belongs to.
* - evaluation: metadata-only (performance level) aggregate of evaluations
* where the subject is the ratee. Comment/rater are never read.
* - objectives: validated `operation.objective` events for the subject.
* - logistics: completed `shipments` created by the subject.
*/
import type { Payload, PayloadRequest, Where } from "payload";
import type { OperationReadiness } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { Permission } from "@/permissions";
import { getPerformanceLevel } from "@/lib/evaluations/levels";
import {
READINESS_CATEGORIES,
READINESS_CALCULATION_VERSION,
READINESS_NATURE,
type ReadinessAggregate,
type ReadinessCategoryInput,
type ReadinessNature,
} from "./readinessTypes";
import { computeReadinessAggregate, formatReadinessExplanation, isAggregateEqual } from "./readinessCompute";
import type { ReadinessInputs } from "./readinessTypes";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export interface ReadinessScope {
subjectType: "user" | "role";
/** User id for user-scoped readiness; null for role-scoped. */
subjectId: number | null;
windowStart: Date;
windowEnd: Date;
/** Optional human label, e.g. an ISO week ("2026-W38"). */
windowLabel: string | null;
/** Optional operation identity the window's data is attributed to. */
operationId: string | null;
}
export interface ReadinessReadResult {
/** Forbidden when the requesting user lacks the read permission. */
status: "ok" | "forbidden" | "not-found";
data: unknown | null;
}
function relationId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
const id = (value as { id: unknown }).id;
return typeof id === "number" ? id : null;
}
return null;
}
/**
* Gather normalized per-category inputs for a subject over a window. Every query
* is bounded and read-only; the evaluation path reads only the performance level
* (metadata), never the private comment or rater.
*/
export async function gatherReadinessInputs(
payload: PayloadType,
scope: ReadinessScope,
req?: PayloadRequest,
): Promise<ReadinessInputs> {
const inWindow = { greater_than_equal: scope.windowStart.toISOString(), less_than_equal: scope.windowEnd.toISOString() };
const attended = await payload.find({
collection: "mission-attendances",
where: {
and: [
{ user: { equals: scope.subjectId } },
{ response: { equals: "yes" } },
{ createdAt: inWindow },
],
},
limit: 500,
depth: 0,
overrideAccess: true,
req,
});
const attendanceYes = attended.docs.length;
const attendedMissionIds = attended.docs
.map((d) => relationId((d as { mission: unknown }).mission))
.filter((id): id is number => id !== null);
// Operation participation via provenance link: mission -> validated
// operation-events -> applied operation-effects. The explicit `Where`
// annotation gives the ternary a contextual type, avoiding the
// discriminated-union-with-undefined-props shape Payload's Where rejects.
const eventsWhere: Where =
attendedMissionIds.length > 0
? { or: [{ actor: { equals: scope.subjectId } }, { mission: { in: attendedMissionIds } }] }
: { actor: { equals: scope.subjectId } };
const events = await payload.find({
collection: "operation-events",
where: { and: [{ status: { equals: "validated" } }, { occurredAt: inWindow }, eventsWhere] },
limit: 1000,
depth: 0,
overrideAccess: true,
req,
});
const eventIds = events.docs.map((d) => d.id as number);
let participationCount = 0;
if (eventIds.length > 0) {
const effects = await payload.find({
collection: "operation-effects",
where: { and: [{ operationEvent: { in: eventIds } }, { status: { equals: "applied" } }] },
limit: 1000,
depth: 0,
overrideAccess: true,
req,
});
participationCount = effects.docs.length;
}
const objectives = await payload.find({
collection: "operation-events",
where: {
and: [
{ status: { equals: "validated" } },
{ type: { equals: "operation.objective" } },
{ occurredAt: inWindow },
attendedMissionIds.length > 0
? { or: [{ actor: { equals: scope.subjectId } }, { mission: { in: attendedMissionIds } }] }
: { actor: { equals: scope.subjectId } },
],
},
limit: 500,
depth: 0,
overrideAccess: true,
req,
});
const evaluations = await payload.find({
collection: "evaluations",
where: { and: [{ ratee: { equals: scope.subjectId } }, { createdAt: inWindow }] },
limit: 500,
depth: 0,
overrideAccess: true,
req,
});
let evaluationScore: number | null = null;
if (evaluations.docs.length > 0) {
const scores = evaluations.docs
.map((d) => getPerformanceLevel((d as { level: unknown }).level as string))
.filter((l): l is NonNullable<typeof l> => l !== null)
.map((l) => l.score);
if (scores.length > 0) {
evaluationScore = Math.round(scores.reduce((sum, s) => sum + s, 0) / scores.length);
}
}
const assignments = await payload.find({
collection: "assignments",
where: { and: [{ members: { equals: scope.subjectId } }, { createdAt: inWindow }] },
limit: 200,
depth: 0,
overrideAccess: true,
req,
});
const shipments = await payload.find({
collection: "shipments",
where: {
and: [
{ createdBy: { equals: scope.subjectId } },
{ status: { equals: "completed" } },
{ createdAt: inWindow },
],
},
limit: 500,
depth: 0,
overrideAccess: true,
req,
});
const countScore = (
category: ReadinessCategoryInput["category"],
count: number,
perUnit: number,
): ReadinessCategoryInput =>
count > 0
? { category, score: Math.min(100, count * perUnit), inputCount: count }
: { category, score: null, inputCount: 0 };
return {
participation: countScore(READINESS_CATEGORIES.PARTICIPATION, participationCount, 25),
attendance: countScore(READINESS_CATEGORIES.ATTENDANCE, attendanceYes, 34),
assignments: countScore(READINESS_CATEGORIES.ASSIGNMENTS, assignments.docs.length, 25),
evaluation:
evaluationScore === null
? { category: READINESS_CATEGORIES.EVALUATION, score: null, inputCount: 0 }
: { category: READINESS_CATEGORIES.EVALUATION, score: evaluationScore, inputCount: evaluations.docs.length },
objectives: countScore(READINESS_CATEGORIES.OBJECTIVES, objectives.docs.length, 25),
logistics: countScore(READINESS_CATEGORIES.LOGISTICS, shipments.docs.length, 25),
};
}
export interface ComputeReadinessOptions {
/** When supplied, writes join the caller's transaction (hook safety). */
req?: PayloadRequest;
}
/**
* Recompute a subject's readiness for a window and store it idempotently.
*
* Idempotency: the aggregate is keyed by `subjectType:subjectId:windowStart`.
* The stored row is freshly re-read; if the computed aggregate is unchanged the
* row is returned as-is (no drift), otherwise it is replaced atomically.
*/
export async function computeReadiness(
payload: PayloadType,
scope: ReadinessScope,
_options?: ComputeReadinessOptions,
): Promise<unknown> {
const inputs = await gatherReadinessInputs(payload, scope);
const aggregate = computeReadinessAggregate(inputs);
const explanation = formatReadinessExplanation(aggregate, scope.windowLabel);
const data = {
subjectType: scope.subjectType,
subjectId: scope.subjectId,
windowStart: scope.windowStart.toISOString(),
windowEnd: scope.windowEnd.toISOString(),
windowLabel: scope.windowLabel,
operationId: scope.operationId,
calculationVersion: READINESS_CALCULATION_VERSION,
nature: READINESS_NATURE as ReadinessNature,
readinessScore: aggregate.readinessScore,
planningConfidenceScore: aggregate.planningConfidenceScore,
contributingCategories: aggregate.contributingCategories,
inputCount: aggregate.inputCount,
explanation,
recomputedAt: new Date().toISOString(),
};
const existing = await payload.find({
collection: "operation-readiness",
where: {
and: [
{ subjectType: { equals: scope.subjectType } },
{ subjectId: { equals: scope.subjectId } },
{ windowStart: { equals: scope.windowStart.toISOString() } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs.length > 0) {
const stored = existing.docs[0] as unknown as OperationReadiness;
const storedAggregate = {
readinessScore: stored.readinessScore,
planningConfidenceScore: stored.planningConfidenceScore,
contributingCategories:
(stored.contributingCategories ?? []) as ReadinessAggregate["contributingCategories"],
};
if (isAggregateEqual(storedAggregate, aggregate)) {
return stored;
}
return await payload.update({
collection: "operation-readiness",
id: stored.id,
data,
overrideAccess: true,
depth: 0,
});
}
return await payload.create({
collection: "operation-readiness",
data,
overrideAccess: true,
depth: 0,
});
}
/**
* Read a stored readiness aggregate for a subject/window, gated by the
* `operation-readiness:read` permission. Returns only the stored aggregate; raw
* evaluation content is never included.
*/
export async function getReadiness(
payload: PayloadType,
scope: Pick<ReadinessScope, "subjectType" | "subjectId" | "windowStart">,
user: { id: number } | null | undefined,
permission: Permission = "operation-readiness:read",
): Promise<ReadinessReadResult> {
const canRead = await hasPermission(payload, user, permission);
if (!canRead) {
return { status: "forbidden", data: null };
}
const found = await payload.find({
collection: "operation-readiness",
where: {
and: [
{ subjectType: { equals: scope.subjectType } },
{ subjectId: { equals: scope.subjectId } },
{ windowStart: { equals: scope.windowStart.toISOString() } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
if (found.docs.length === 0) {
return { status: "not-found", data: null };
}
return { status: "ok", data: found.docs[0] };
}

View file

@ -0,0 +1,124 @@
/**
* Pure readiness computation: given a normalized set of category inputs, produce
* a deterministic aggregate + a human-readable explanation.
*
* Determinism contract: no `Math.random`, no `Date.now`, no clock dependence.
* Same inputs always produce byte-identical output. The explanation enumerates
* only contributing category names and weights -- it never references evaluator
* names, rater ids, or comment text, because it is built solely from the
* normalized category inputs.
*/
import {
READINESS_CATEGORY_LABELS,
READINESS_CATEGORY_WEIGHTS,
READINESS_CALCULATION_VERSION,
READINESS_NATURE,
PLANNING_CATEGORIES,
type ReadinessAggregate,
type ReadinessCategory,
type ReadinessComponent,
type ReadinessInputs,
} from "./readinessTypes";
const clamp0100 = (n: number): number => Math.max(0, Math.min(100, n));
/** Weighted mean of present categories, normalized to 0-100. */
function weightedMean(components: ReadinessComponent[]): number {
const totalWeight = components.reduce((sum, c) => sum + c.weight, 0);
if (totalWeight === 0) return 0;
const weightedSum = components.reduce((sum, c) => sum + c.weight * c.score, 0);
return clamp0100(Math.round(weightedSum / totalWeight));
}
/**
* Compute the readiness aggregate from normalized category inputs. Categories
* with a `null` score are treated as absent: omitted from the contributing
* list, excluded from the weighted mean, and never silently zeroed.
*/
export function computeReadinessAggregate(inputs: ReadinessInputs): ReadinessAggregate {
const present = (Object.keys(inputs) as ReadinessCategory[])
.filter((category) => inputs[category].score !== null)
.map((category): ReadinessComponent => ({
category,
weight: READINESS_CATEGORY_WEIGHTS[category],
score: clamp0100(inputs[category].score as number),
inputCount: inputs[category].inputCount,
}))
// Deterministic ordering: by declared weight desc, then category name.
.sort((a, b) => (a.weight === b.weight ? a.category.localeCompare(b.category) : b.weight - a.weight));
const inputCount = present.reduce((sum, c) => sum + c.inputCount, 0);
const planningComponents = present.filter((c) => PLANNING_CATEGORIES.includes(c.category));
const planningConfidenceScore =
planningComponents.length === 0 ? 0 : weightedMean(planningComponents);
return {
readinessScore: weightedMean(present),
planningConfidenceScore,
contributingCategories: present,
inputCount,
};
}
/**
* Build the human-readable explanation. Contains only category names, weights,
* scores, and input counts -- never evaluator names, ids, or comments. Absent
* categories are simply not listed.
*/
export function formatReadinessExplanation(
aggregate: ReadinessAggregate,
windowLabel: string | null,
): string {
const parts: string[] = [
`Readiness${windowLabel ? ` for ${windowLabel}` : ""} (model v${READINESS_CALCULATION_VERSION}): ${READINESS_NATURE}.`,
];
if (aggregate.contributingCategories.length === 0) {
parts.push(
"No qualifying operation data in the window; no aggregate produced.",
);
return parts.join(" ");
}
const declared = Object.keys(READINESS_CATEGORY_WEIGHTS).length;
parts.push(
`${aggregate.contributingCategories.length} contributing category(ies) out of ${declared} declared.`,
);
for (const c of aggregate.contributingCategories) {
const label = READINESS_CATEGORY_LABELS[c.category];
parts.push(`${label} (weight ${c.weight}): score ${c.score} from ${c.inputCount} input(s).`);
}
if (aggregate.planningConfidenceScore > 0) {
const planning = aggregate
.contributingCategories.filter((c) => PLANNING_CATEGORIES.includes(c.category))
.map((c) => READINESS_CATEGORY_LABELS[c.category]);
parts.push(
`Planning confidence ${aggregate.planningConfidenceScore}: derived from ${planning.join(", ")}.`,
);
}
return parts.join(" ");
}
/** Deep equality over the fields that make an aggregate stable for idempotency.
* Scores accept `null | undefined` because a freshly computed aggregate always
* has numbers while a stored json field may be absent. */
export function isAggregateEqual(
a: {
readinessScore: number | null | undefined;
planningConfidenceScore: number | null | undefined;
contributingCategories: ReadinessAggregate["contributingCategories"];
},
b: {
readinessScore: number | null | undefined;
planningConfidenceScore: number | null | undefined;
contributingCategories: ReadinessAggregate["contributingCategories"];
},
): boolean {
if (a.readinessScore !== b.readinessScore) return false;
if (a.planningConfidenceScore !== b.planningConfidenceScore) return false;
return JSON.stringify(a.contributingCategories) === JSON.stringify(b.contributingCategories);
}

View file

@ -0,0 +1,129 @@
/**
* Pure type + constant definitions for operation readiness computation.
*
* No Payload imports: safe to use from server actions, integration tests, and
* (later) client surfaces. All mutable state lives in the `operation-readiness`
* collection; this module only holds the deterministic model.
*/
/**
* Readiness/planning-confidence is advisory only. `advisory` is the sole legal
* value and documents that the aggregate is never used to gate, punish, or rank
* players against one another. Any future gating layer must read
* `READINESS_GATING_ENABLED`, which stays false.
*/
export const READINESS_NATURE = "advisory" as const;
export type ReadinessNature = typeof READINESS_NATURE;
/**
* Calculation model version. Bump whenever the weighting scheme or the set of
* contributing categories changes, so stale aggregates are detected and
* recomputed rather than served from a different model. Stored verbatim on each
* aggregate row for audit.
*/
export const READINESS_CALCULATION_VERSION = "1.0.0" as const;
/**
* Feature flag for any future readiness gating. Defaults to false: this feature
* only produces advisory aggregates; it never blocks, punishes, or builds a
* global/social score.
*/
export const READINESS_GATING_ENABLED = false as const;
/** Subject the aggregate is computed for. */
export const READINESS_SUBJECT = {
USER: "user",
ROLE: "role",
} as const;
export type ReadinessSubjectType = typeof READINESS_SUBJECT[keyof typeof READINESS_SUBJECT];
/**
* Contributing categories and their declared weights. The weights are a fixed,
* documented ratio (they sum to 130; the aggregate normalizes over the sum of
* the *present* categories' weights). They are NOT tunable per-subject, so the
* model stays transparent with no hidden weighting. The category name is the
* only thing surfaced in the explanation -- never evaluator names, ids, or
* comment text.
*/
export const READINESS_CATEGORIES = {
PARTICIPATION: "participation",
ATTENDANCE: "attendance",
ASSIGNMENTS: "assignments",
EVALUATION: "evaluation",
OBJECTIVES: "objectives",
LOGISTICS: "logistics",
} as const;
export type ReadinessCategory = typeof READINESS_CATEGORIES[keyof typeof READINESS_CATEGORIES];
export const READINESS_CATEGORY_WEIGHTS: Record<ReadinessCategory, number> = {
participation: 25,
attendance: 20,
assignments: 25,
evaluation: 30,
objectives: 15,
logistics: 15,
};
/** Human-readable label for a contributing category (explanation text only). */
export const READINESS_CATEGORY_LABELS: Record<ReadinessCategory, string> = {
participation: "Operation participation",
attendance: "Attendance",
assignments: "Assignments",
evaluation: "Leadership evaluation",
objectives: "Completed objectives",
logistics: "Logistics outcomes",
};
/**
* Pre-operation signals that feed the planning-confidence sub-score. These are
* the signals commanders rely on when planning, distinct from the broader
* readiness measure.
*/
export const PLANNING_CATEGORIES: readonly ReadinessCategory[] = ["attendance", "assignments"];
/** Normalized per-category input fed to the pure computation. */
export interface ReadinessCategoryInput {
/** Category id. */
category: ReadinessCategory;
/** Normalized 0-100 score; `null` means the category is absent and omitted. */
score: number | null;
/** Raw component count, informational only. */
inputCount: number;
}
export interface ReadinessInputs {
participation: ReadinessCategoryInput;
attendance: ReadinessCategoryInput;
assignments: ReadinessCategoryInput;
evaluation: ReadinessCategoryInput;
objectives: ReadinessCategoryInput;
logistics: ReadinessCategoryInput;
}
/** One contributing category in the produced aggregate. */
export interface ReadinessComponent {
category: ReadinessCategory;
weight: number;
score: number;
inputCount: number;
}
export interface ReadinessAggregate {
/** Weighted 0-100 readiness score over all present categories. */
readinessScore: number;
/** Weighted 0-100 planning-confidence score over pre-op signals. */
planningConfidenceScore: number;
/** Present categories only, sorted by weight desc then name. */
contributingCategories: ReadinessComponent[];
/** Total component inputs consumed. */
inputCount: number;
}
/** The stable identity of a stored aggregate: `subjectType:subjectId:windowStart`. */
export function readinessAggregateKey(
subjectType: ReadinessSubjectType,
subjectId: number | null,
windowStart: Date,
): string {
return `${subjectType}:${subjectId ?? "none"}:${windowStart.toISOString()}`;
}

View file

@ -0,0 +1,217 @@
/**
* Mission-scoped zone and faction pressure (task 7).
*
* Pressure is an additive strategic ledger over map zones: each settled
* operation end may apply ONE delta to ONE declared zone, attributed to the
* faction, mission, and source effect that caused it. The zone's strategic
* state is a deterministic function of the decayed pressure sum.
*
* Decay is computational, not stored: each delta's weight halves per elapsed
* decay period (7 days), so `effectivePressure` is deterministic for any
* `now` and needs no sweeper.
*
* Downstream mechanics (declared per plan: one at a time): route risk and
* resource-yield multipliers derive from the state. They are pure and tested
* here; wiring them into shipping/production consumers is deferred until the
* dirty uncommitted map/shipping/production baseline lands (task-1 ownership
* manifest forbids editing those files).
*
* Territory/border zone semantics are untouched: pressure never changes
* placement rules (terrain/resource validation in `src/lib/map/placement.ts`
* remains the only placement gate).
*/
import type { Payload, PayloadRequest } from "payload";
/** Half-life of a pressure delta, in days. */
export const PRESSURE_DECAY_PERIOD_DAYS = 7;
/** Documented state thresholds over the effective (decayed) pressure sum. */
export const PRESSURE_STATE_THRESHOLDS = {
/** pressure >= 80: the hostile faction holds the zone. */
controlled: 80,
/** pressure >= 40: actively fought over. */
contested: 40,
} as const;
/**
* Effective pressure below this is strategically nothing: the state is
* neutral. Halving decay never reaches exactly zero, so the state machine
* needs a convergence floor.
*/
export const PRESSURE_NEUTRAL_FLOOR = 0.5;
/** Documented deltas applied per operation outcome. */
export const PRESSURE_OUTCOME_DELTAS = {
/** Mission failure: hostile activity gains a foothold. */
failure: 40,
/** Mission success: the zone is pushed back toward neutral. */
success: -25,
} as const;
export type ZonePressureState =
| "controlled"
| "contested"
| "recovering"
| "neutral";
export interface PressureRow {
delta: number;
createdAt: string;
}
export interface ZoneState {
/** Decayed pressure sum, rounded to one decimal. */
pressure: number;
state: ZonePressureState;
routeRiskMultiplier: number;
yieldMultiplier: number;
/** Number of live (non-fully-decayed) pressure rows. */
activeRows: number;
}
const MULTIPLIERS: Record<ZonePressureState, { routeRisk: number; yield: number }> = {
controlled: { routeRisk: 1.5, yield: 0.75 },
contested: { routeRisk: 1.25, yield: 0.9 },
recovering: { routeRisk: 1, yield: 1 },
neutral: { routeRisk: 1, yield: 1 },
};
/** Effective pressure of one delta, halved per full decay period elapsed. */
export function decayedDelta(delta: number, createdAt: string, now: Date): number {
const created = new Date(createdAt).getTime();
const elapsedDays = Math.max(0, (now.getTime() - created) / 86_400_000);
const periods = Math.floor(elapsedDays / PRESSURE_DECAY_PERIOD_DAYS);
return delta / 2 ** periods;
}
/** Deterministic strategic state from the decayed pressure sum. */
export function stateFromPressure(pressure: number): ZoneState {
const rounded = Math.round(pressure * 10) / 10;
const state: ZonePressureState =
rounded >= PRESSURE_STATE_THRESHOLDS.controlled
? "controlled"
: rounded >= PRESSURE_STATE_THRESHOLDS.contested
? "contested"
: rounded >= PRESSURE_NEUTRAL_FLOOR
? "recovering"
: "neutral";
return {
pressure: rounded,
state,
routeRiskMultiplier: MULTIPLIERS[state].routeRisk,
yieldMultiplier: MULTIPLIERS[state].yield,
activeRows: 0,
};
}
/**
* Sum decayed deltas from raw pressure rows (deterministic given `now`).
* Deltas decay toward zero symmetrically: hostile gains fade, and so do
* friendly reductions, so a zone naturally returns to neutral over time.
*/
export function effectivePressure(rows: PressureRow[], now: Date): ZoneState {
const pressure = rows.reduce((sum, row) => sum + decayedDelta(row.delta, row.createdAt, now), 0);
return stateFromPressure(pressure);
}
// ---------------------------------------------------------------------------
// Payload interaction
// ---------------------------------------------------------------------------
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export interface ApplyZonePressureInput {
zoneId: number;
factionId?: number;
operationId: string;
missionId?: number;
/** The settled operation-end effect driving this delta (idempotency key). */
sourceEffectId: number;
delta: number;
note?: string;
}
/**
* Apply one pressure delta, exactly once per source effect. A replay of the
* same effect is a no-op returning the existing row.
*/
export async function applyZonePressure(
payload: PayloadType,
input: ApplyZonePressureInput,
req?: PayloadRequest,
): Promise<{ id: number; created: boolean }> {
const existing = await payload.find({
collection: "zone-pressure",
where: { sourceEffect: { equals: input.sourceEffectId } },
limit: 1,
depth: 0,
overrideAccess: true,
req,
});
if (existing.docs.length > 0) {
return { id: existing.docs[0].id, created: false };
}
try {
const row = await payload.create({
collection: "zone-pressure",
data: {
zone: input.zoneId,
faction: input.factionId ?? null,
operationId: input.operationId,
mission: input.missionId ?? null,
sourceEffect: input.sourceEffectId,
delta: input.delta,
note: input.note ?? null,
},
overrideAccess: true,
depth: 0,
req,
});
return { id: row.id, created: true };
} catch (error) {
const code = (error as { cause?: { code?: string } })?.cause?.code;
if (code === "23505") {
// Unique-constraint race on sourceEffect: the other writer won.
const raced = await payload.find({
collection: "zone-pressure",
where: { sourceEffect: { equals: input.sourceEffectId } },
limit: 1,
depth: 0,
overrideAccess: true,
req,
});
if (raced.docs.length > 0) return { id: raced.docs[0].id, created: false };
}
throw error;
}
}
/**
* Compute a zone's strategic state from its live pressure ledger. Deterministic
* for a given `now`; defaults to the current time.
*/
export async function computeZoneState(
payload: PayloadType,
zoneId: number,
now: Date = new Date(),
): Promise<ZoneState> {
const rows = await payload.find({
collection: "zone-pressure",
where: { zone: { equals: zoneId } },
limit: 1000,
depth: 0,
overrideAccess: true,
});
const live: PressureRow[] = [];
let activeRows = 0;
for (const doc of rows.docs as unknown as { delta: number; createdAt: string }[]) {
const effective = decayedDelta(doc.delta, doc.createdAt, now);
if (Math.abs(effective) >= 0.1) activeRows += 1;
live.push({ delta: doc.delta, createdAt: doc.createdAt });
}
const state = effectivePressure(live, now);
return { ...state, activeRows };
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,352 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { computeReadiness, getReadiness, type ReadinessScope } from "@/lib/operations/readiness";
import { READINESS_CALCULATION_VERSION } from "@/lib/operations/readinessTypes";
let payload: Payload;
const RUN = `ready-${Date.now().toString(36)}`;
// ---------------------------------------------------------------------------
// Fixture tracking for cleanup
// ---------------------------------------------------------------------------
const syncEventIds: number[] = [];
const opEventIds: number[] = [];
const opEffectIds: number[] = [];
const userIds: number[] = [];
const roleIds: number[] = [];
const missionIds: number[] = [];
const attendanceIds: number[] = [];
const evaluationIds: number[] = [];
const campaignIds: number[] = [];
const mapIds: number[] = [];
const readinessIds: number[] = [];
let serverId: number;
let subjectId: number;
let raterId: number;
let adminId: number;
let mapId: number;
let campaignId: number;
let windowStart: Date;
let windowEnd: Date;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function makeUser(label: string, roleDocIds?: number[]): Promise<number> {
const user = await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}-discord`,
displayName: `${RUN} ${label}`,
steamId: `${RUN}-steam-${label}`,
password: "Test1234",
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
},
overrideAccess: true,
depth: 0,
});
userIds.push(user.id);
return user.id;
}
async function makeMission(label: string, authorId: number): Promise<number> {
const mission = await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Readiness test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: new Date(Date.now() + 86_400_000).toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [authorId],
status: "Scheduled",
visibility: "unit",
},
missionRoles: { maxPlayers: 16 },
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
briefing: [],
},
overrideAccess: true,
depth: 0,
});
missionIds.push(mission.id);
return mission.id;
}
async function makeAttendance(missionId: number, userId: number): Promise<number> {
const doc = await payload.create({
collection: "mission-attendances",
data: { mission: missionId, user: userId, response: "yes" },
overrideAccess: true,
depth: 0,
});
attendanceIds.push(doc.id);
return doc.id;
}
async function makeEvaluation(missionId: number, ratee: number): Promise<number> {
const doc = await payload.create({
collection: "evaluations",
data: { rater: raterId, ratee, mission: missionId, kind: "leader-direct", level: "proficient" },
overrideAccess: true,
depth: 0,
});
evaluationIds.push(doc.id);
return doc.id;
}
async function makeObjectiveEvent(label: string, actor: number): Promise<void> {
const sync = await payload.create({
collection: "arma-sync-events",
data: {
eventId: `${RUN}-${label}`,
server: serverId,
type: "operation.objective",
occurredAt: new Date().toISOString(),
payload: { operationId: RUN, version: 1, objective: "Cache secured", actorId: actor },
},
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,
});
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,
});
opEffectIds.push(effects.docs[0].id);
}
function scopeFor(windowStart: Date, windowEnd: Date): ReadinessScope {
return {
subjectType: "user",
subjectId,
windowStart,
windowEnd,
windowLabel: `${RUN}-window`,
operationId: RUN,
};
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
// One shared, minute-floored window: the aggregate identity is
// `subjectType:subjectId:windowStart`, so every test that expects to find
// another test's row must use the exact same windowStart instant.
const flooredMinute = Math.floor(Date.now() / 60_000) * 60_000;
windowStart = new Date(flooredMinute - 3_600_000);
windowEnd = new Date(flooredMinute + 3_600_000);
const superRole = await payload.create({
collection: "roles",
data: { name: `${RUN} Readiness Admin`, slug: `${RUN}-readiness-admin`, isSuperuser: true },
overrideAccess: true,
depth: 0,
});
roleIds.push(superRole.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;
subjectId = await makeUser("subject");
raterId = await makeUser("rater");
adminId = await makeUser("admin", [superRole.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;
mapIds.push(map.id);
const campaign = await payload.create({
collection: "campaigns",
data: {
name: `${RUN} Campaign`,
summary: "Readiness test campaign",
status: "concept",
campaignMode: "custom",
},
overrideAccess: true,
depth: 0,
});
campaignId = campaign.id;
campaignIds.push(campaign.id);
});
afterAll(async () => {
if (!payload) return;
const readinessRows = await payload.find({
collection: "operation-readiness",
where: { operationId: { equals: RUN } },
limit: 100,
depth: 0,
overrideAccess: true,
});
for (const row of readinessRows.docs) {
await payload.delete({ collection: "operation-readiness", 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 evaluationIds) {
await payload.delete({ collection: "evaluations", id, overrideAccess: true }).catch(() => {});
}
for (const id of attendanceIds) {
await payload.delete({ collection: "mission-attendances", id, overrideAccess: true }).catch(() => {});
}
for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
for (const id of campaignIds) {
await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {});
}
for (const id of mapIds) {
await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {});
}
for (const id of userIds) {
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
for (const id of roleIds) {
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
}
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Operation readiness", () => {
it("computes a deterministic aggregate from window inputs and stores it with an explanation", async () => {
const mission = await makeMission("Alpha", adminId);
await makeAttendance(mission, subjectId);
await makeEvaluation(mission, subjectId);
await makeObjectiveEvent("obj-1", subjectId);
const now = new Date();
const result = (await computeReadiness(payload, scopeFor(windowStart, windowEnd))) as {
id: number;
readinessScore: number;
planningConfidenceScore: number;
contributingCategories: { category: string; weight: number; score: number; inputCount: number }[];
calculationVersion: string;
nature: string;
explanation: string;
};
readinessIds.push(result.id);
const categories = result.contributingCategories.map((c) => c.category);
expect(categories).toContain("attendance");
expect(categories).toContain("evaluation");
expect(categories).toContain("objectives");
expect(result.calculationVersion).toBe(READINESS_CALCULATION_VERSION);
expect(result.nature).toBe("advisory");
const attendance = result.contributingCategories.find((c) => c.category === "attendance");
expect(attendance?.inputCount).toBe(1);
expect(attendance?.score).toBe(34);
const evaluation = result.contributingCategories.find((c) => c.category === "evaluation");
expect(evaluation?.score).toBe(70);
expect(result.explanation).toContain("Attendance");
expect(result.explanation).not.toContain(raterDisplayName());
expect(result.explanation).not.toContain("evaluator");
});
it("returns the stored row unchanged when inputs are unchanged (no drift)", async () => {
const first = (await computeReadiness(payload, scopeFor(windowStart, windowEnd))) as { id: number; readinessScore: number };
const second = (await computeReadiness(payload, scopeFor(windowStart, windowEnd))) as { id: number; readinessScore: number };
expect(second.id).toBe(first.id);
expect(second.readinessScore).toBe(first.readinessScore);
});
it("replaces the stored aggregate atomically when inputs change", async () => {
const scope = scopeFor(windowStart, windowEnd);
const before = (await computeReadiness(payload, scope)) as { id: number; readinessScore: number };
const missions = missionIds;
await makeAttendance(missions[0], subjectId);
const after = (await computeReadiness(payload, scope)) as {
id: number;
readinessScore: number;
contributingCategories: { category: string; inputCount: number }[];
};
expect(after.id).toBe(before.id);
const attendance = after.contributingCategories.find((c) => c.category === "attendance");
expect(attendance?.inputCount).toBe(2);
expect(after.readinessScore).not.toBe(before.readinessScore);
});
it("excludes inputs outside the window and omits absent categories", async () => {
const future = scopeFor(new Date(Date.now() + 86_400_000), new Date(Date.now() + 7 * 86_400_000));
const result = (await computeReadiness(payload, future)) as {
readinessScore: number;
contributingCategories: unknown[];
explanation: string;
};
expect(result.contributingCategories).toHaveLength(0);
expect(result.readinessScore).toBe(0);
expect(result.explanation).toContain("No qualifying operation data");
});
it("gates reads behind the readiness permission without leaking evaluator content", async () => {
const scope = scopeFor(windowStart, windowEnd);
const forbidden = await getReadiness(payload, { subjectType: "user", subjectId, windowStart: scope.windowStart }, {
id: raterId,
});
expect(forbidden.status).toBe("forbidden");
const allowed = await getReadiness(payload, { subjectType: "user", subjectId, windowStart: scope.windowStart }, {
id: adminId,
});
expect(allowed.status).toBe("ok");
const data = allowed.data as { explanation: string; rater?: unknown; evaluator?: unknown; comment?: unknown };
expect(data.explanation).not.toContain(raterDisplayName());
expect(data.rater).toBeUndefined();
expect(data.evaluator).toBeUndefined();
expect(data.comment).toBeUndefined();
});
});
function raterDisplayName(): string {
return `${RUN} rater`;
}

View file

@ -0,0 +1,258 @@
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);
});
});