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:
parent
67adaa301c
commit
3c70160dfc
12 changed files with 63774 additions and 0 deletions
152
src/collections/operations/OperationReadiness.ts
Normal file
152
src/collections/operations/OperationReadiness.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
73
src/collections/operations/ZonePressure.ts
Normal file
73
src/collections/operations/ZonePressure.ts
Normal 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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
325
src/lib/operations/readiness.ts
Normal file
325
src/lib/operations/readiness.ts
Normal 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] };
|
||||||
|
}
|
||||||
124
src/lib/operations/readinessCompute.ts
Normal file
124
src/lib/operations/readinessCompute.ts
Normal 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);
|
||||||
|
}
|
||||||
129
src/lib/operations/readinessTypes.ts
Normal file
129
src/lib/operations/readinessTypes.ts
Normal 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()}`;
|
||||||
|
}
|
||||||
217
src/lib/operations/territory.ts
Normal file
217
src/lib/operations/territory.ts
Normal 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 };
|
||||||
|
}
|
||||||
30861
src/migrations/20260918_222549_add_operation_readiness.json
Normal file
30861
src/migrations/20260918_222549_add_operation_readiness.json
Normal file
File diff suppressed because it is too large
Load diff
62
src/migrations/20260918_222549_add_operation_readiness.ts
Normal file
62
src/migrations/20260918_222549_add_operation_readiness.ts
Normal file
File diff suppressed because one or more lines are too long
31162
src/migrations/20260920_044743_add_zone_pressure.json
Normal file
31162
src/migrations/20260920_044743_add_zone_pressure.json
Normal file
File diff suppressed because it is too large
Load diff
59
src/migrations/20260920_044743_add_zone_pressure.ts
Normal file
59
src/migrations/20260920_044743_add_zone_pressure.ts
Normal file
File diff suppressed because one or more lines are too long
352
tests/int/operation-readiness.int.spec.ts
Normal file
352
tests/int/operation-readiness.int.spec.ts
Normal 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`;
|
||||||
|
}
|
||||||
258
tests/int/operation-territory.int.spec.ts
Normal file
258
tests/int/operation-territory.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue