feat: #66 contested regions / frontlines (v0.44.0) #79
@@ -104,7 +104,7 @@ history/
|
|||||||
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
|
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
|
||||||
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63) /
|
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63) /
|
||||||
│ │ │ # migration (v0.42.0 #64)
|
│ │ │ # migration (v0.42.0 #64)
|
||||||
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 10-ability contract)
|
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 12-ability contract)
|
||||||
│ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user)
|
│ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user)
|
||||||
│ └── *.test.ts # unit tests (vitest)
|
│ └── *.test.ts # unit tests (vitest)
|
||||||
└── src/
|
└── src/
|
||||||
@@ -583,6 +583,52 @@ history/
|
|||||||
12 named zones**, sole-control semantics, contest-by-higher-military, and a durable
|
12 named zones**, sole-control semantics, contest-by-higher-military, and a durable
|
||||||
`seaZones` list — a cleaner, more deterministic model that the LLM can reason about without
|
`seaZones` list — a cleaner, more deterministic model that the LLM can reason about without
|
||||||
inventing zone names each turn.
|
inventing zone names each turn.
|
||||||
|
- **Contested regions / frontlines — a `TurnResult.contested` lever opening/resolving war frontlines: a THIRD region state in no nation's `territory` and not in `unowned` (v0.44.0, #66).**
|
||||||
|
`TurnResult.contested = ContestedOps = { open: ContestOp[]; resolve: string[] }` (required,
|
||||||
|
default `{ open: [], resolve: [] }`); `ContestOp = { regionKey, parties }` where `regionKey` is
|
||||||
|
a region key or resolvable name and `parties` are TWO OR MORE nation refs (ids or names, via
|
||||||
|
the shared `resolveNationRef`). Like the `sea` / `industry` layers this is **durable**: it owns
|
||||||
|
the `GameState.contested: ContestedRegion[]` field (`ContestedRegion = { regionKey, parties }`
|
||||||
|
— `parties` store the RESOLVED nation ids; required, `[]` at creation; a legacy SQLite row
|
||||||
|
without the field loads as `[]` — via the generic `ABILITIES` `stateFields` loop in `db.ts`,
|
||||||
|
no `db.ts` code change needed). `applyContested` (run LAST in the `ABILITIES` loop, after
|
||||||
|
territory / wars / economy / sea — it sees the final post-claim ownership) runs **RESOLVE
|
||||||
|
first, then OPEN**: a `resolve` removes a currently-contested key and **releases it to no
|
||||||
|
man's land** (into `unowned`, out of any territory, `regionStats` deleted) — UNLESS a
|
||||||
|
concurrent winning claim by one of the contesting **parties** already re-owned it this turn
|
||||||
|
(the only claim the territory step permits on a contested key), in which case it stays owned
|
||||||
|
and keeps (or re-inits) its `regionStats`; an `open` makes a region a frontline only when it
|
||||||
|
is **contestable** — a **sole owner** at war (a shared **active** `War`) with at least one
|
||||||
|
party, or a **no-man's-land** region (0 owners AND in `unowned`) whose two parties are at war
|
||||||
|
with each other — a 2+-owner key or an owner not at war with any party is **dropped**
|
||||||
|
(an armistice/ended war does not count); on a successful open the key is **stripped from its
|
||||||
|
owner's `territory` (or from `unowned`)**, its `regionStats` entry deleted, and a
|
||||||
|
`{ regionKey, parties }` entry added to `next.contested` (**unique by `regionKey`** — a
|
||||||
|
re-open replaces a kept entry), with the stripped owner's derived stats re-derived (same
|
||||||
|
0-region / no-entry guards). The **sacrosanct sole-ownership invariant is never broken**: a
|
||||||
|
contested key is in **no** territory (never two) and is **never** in `unowned` while
|
||||||
|
contested (the two states are mutually exclusive). The **territory step in `engine.ts` is
|
||||||
|
guarded**: a normal `gain` on a currently-contested key is **dropped** unless this same turn
|
||||||
|
resolves the contest AND the gainer is one of the contesting parties (a party re-claim) — a
|
||||||
|
still-contested key is never a valid normal-claim target. The **map (client-only, like
|
||||||
|
no-man's-land)**: `WorldMap` renders a contested key in the distinct visible
|
||||||
|
`CONTESTED_FILL` / `CONTESTED_STROKE` (orange "hot" frontline) with a `"Contested: A vs B"`
|
||||||
|
hover sub-line (names from `GameState.contested`; a removed party degrades to a dash) — the
|
||||||
|
contested check runs **first** (before unowned / owned); the server `RegionMap` stays
|
||||||
|
owned-vs-absent (it never knows about contested). **The map legend stays at 7 items** (it is
|
||||||
|
derived from the `RegionMap` owners only — a contested key is absent from the `RegionMap`, so
|
||||||
|
no new legend entry; e2e legend assertions are unaffected). `isMeaningful` is
|
||||||
|
`open.length > 0 || resolve.length > 0`; the system prompt documents the field + constraints
|
||||||
|
(at most 1–2 contested-region changes per turn, each narrated in `events`); the user payload
|
||||||
|
carries the current frontlines (region key + party **names**) **only when non-empty** —
|
||||||
|
`payload()` → `{}` on a fresh / no-frontline world (unlike `sea`, which always emits). The
|
||||||
|
**stub never** opens or resolves a frontline (`{ open: [], resolve: [] }`), so e2e (CI,
|
||||||
|
`LLM_STUB=1`) is unaffected and the path is unit-tested only
|
||||||
|
(`server/abilities/contested.test.ts`, 52 tests, each asserting the sole-ownership invariants).
|
||||||
|
**Design note (deviation from the issue body):** the Gitea #66 body sketched a **two-owner**
|
||||||
|
`disputed` / `fronts` design that "relaxes sole-ownership"; the implemented, approved design
|
||||||
|
is this **third region state** (a contested key in NO territory, sole-ownership preserved)
|
||||||
|
with `open` / `resolve` ops and a minimum of two contesting parties.
|
||||||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.44.0 — 2026-10-16 15:10
|
||||||
|
- **#66: Contested regions / frontlines — a `TurnResult.contested` lever letting the LLM declare a region a **contested** war frontline (a **third region state**: in **no** nation's `territory` and **not** in `unowned` / no-man's-land) and resolve existing frontlines.** It lands as the **12th ability** of the v0.40.0 modular system, in a new `server/abilities/contested.ts` module, **registered last** in the `ABILITIES` registry (after `sea`). Like the `sea` and `industry` layers it is **durable**: `GameState.contested` persists across turns and in SQLite. It is a **pure territory-state** layer — the contested key simply *leaves* ownership (sole ownership is never broken: a key is in at most one `territory`), it is never placed into two territories, and it never touches borders directly.
|
||||||
|
- **Types (`shared/protocol.ts`).** New `ContestOp` (`{ regionKey; parties }` — `regionKey` is a region key or resolvable name, `parties` are two or more nation refs (ids or names)), `ContestedOps` (`{ open: ContestOp[]; resolve: string[] }`), and `ContestedRegion` (`{ regionKey; parties }` — `parties` store the **resolved** nation ids); `TurnResult.contested` is **required**, placed between `sea` and `markers`, default `{ open: [], resolve: [] }`. New `CONTESTED_FILL = '#f97316'` / `CONTESTED_STROKE` (the distinct visible frontline fill/stroke for the map — a reddish/orange "hot" frontline on the dark basemap, clearly distinct from a nation color and from the no-man's-land grey) and `contestedTooltipLabel(parties)` (a short `"Contested: A vs B"` hover sub-line; a missing party name degrades to a dash, never crashes). `ContestedRegion` is imported into `shared/types.ts`; `GameState.contested: ContestedRegion[]` is **required** (`[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wiped, via the generic `ABILITIES` `stateFields` loop in `db.ts` — no `db.ts` code change needed).
|
||||||
|
- **Ability module (`server/abilities/contested.ts`).** `field: 'contested'`; `default()` / `stub()` → a **fresh** `{ open: [], resolve: [] }` each call; `validate` wraps the exported `validateContestedOps` (defensive, never throws — accepts the `{ open, resolve }` object; an `open` op needs a non-empty `regionKey` (trimmed, ≤120) and **two or more** non-empty `party` refs (each trimmed, ≤200), a `resolve` entry a non-empty key (trimmed, ≤120); missing / malformed → dropped; a missing / malformed top-level object yields `{ open: [], resolve: [] }`); `apply` runs the exported `applyContested`; `schemaLine()` / `constraint()` supply the prompt text; **`payload()` → `{ contested: [{ regionKey, parties: [NAMES] }] }`** (the current frontlines with party **names** resolved from the stored ids) **only when non-empty** — `{}` on a fresh / no-frontline world (like the turn-transient layers, unlike `sea` which always emits); `isMeaningful` = `open.length > 0 || resolve.length > 0`; **`stateFields: ['contested']`** (the durable `GameState` field, for the `db.ts` legacy-row migration).
|
||||||
|
- **Open semantics (`applyContested`, OPEN phase).** Each `open` op's `regionKey` resolves to a concrete key (exact key first, then a unique case-insensitive name via the region manifest — with no manifest loaded the raw string is accepted; a key absent from the loaded manifest is dropped). Its `parties` resolve via the shared `resolveNationRef` (id first, then a unique name; unresolvable / ambiguous refs dropped, order preserved, duplicates removed) — an op left with **fewer than two distinct resolved parties** is dropped. A region is **contestable** only when either (a) it has a **sole owner** that is at war (a shared **active** `War`) with at least one of the parties, or (b) it has **zero owners AND is in `unowned`** (no man's land) and **two of the parties are at war with each other**. A region with **two or more owners** (corrupt), or whose owner is **not at war with any party**, is never contestable (dropped — an ended / armistice war does not count). On a successful open the key is **removed from its sole owner's `territory`** (or from `unowned` for a no-man's-land region), its `regionStats` entry is **deleted**, and a `{ regionKey, parties: [ids] }` entry is added to `next.contested` (**unique by `regionKey`** — a re-open replaces any kept entry for the same key). The stripped owner's derived `stats` are **re-derived** (the same 0-region / no-entry guards as the other layers, so a 0-region owner keeps its directly-mutated value).
|
||||||
|
- **Resolve semantics (`applyContested`, RESOLVE phase — runs FIRST).** Each key in `resolve` that is currently in `next.contested` is removed and its region **released to no man's land** (out of any territory, into `unowned`, `regionStats` deleted) — **UNLESS a concurrent winning claim by one of the contesting parties** already re-owned it this turn (the only claim the territory step permits on a contested key), in which case it **stays owned** and its `regionStats` is **kept** (or **re-initialised** as a copy of the owner's stats if absent). A key that is not currently contested is ignored (no-op). A resolved key owned by a **non-party** (defensive) is released as above.
|
||||||
|
- **Territory-step guard (`engine.ts applyTurnResult`).** A **currently-contested** key (in `state.contested`) sits in no nation's territory, so a normal `gain` would otherwise be treated as a free re-claim. The claims step is guarded: a normal claim on a contested key proceeds **only** when this same turn `resolve`s the contest **AND** the gainer is one of the contesting **parties** (a party re-claim); otherwise the frontline **stays contested** for the contested layer (which runs later in the turn) to decide its fate. A still-contested key is therefore never a valid normal-claim target.
|
||||||
|
- **Map (client `WorldMap.tsx`).** The contested state is decided by the client from `GameState.contested` (like no-man's-land), **never by the `RegionMap`** (which stays owned-vs-absent). A contested key is rendered with the distinct `CONTESTED_FILL` / `CONTESTED_STROKE` and a `"Contested: A vs B"` hover sub-line; the contested check runs **first** (before unowned / owned) in the fill-resolution, and the hover handler resolves the party names from `GameState.contested`. **The map legend stays at 7 items** (it is derived from the `RegionMap` owners only — a contested key is absent from the `RegionMap`, so it adds no legend entry — e2e legend assertions unaffected). `App.tsx` passes `contested={game.contested ?? []}` to `<WorldMap>`.
|
||||||
|
- **Registry & prompt.** `contested` is added **last** in `ABILITIES` (existing order untouched — the 12th entry); the system prompt gains the `contested` schema line and its constraint bullet ("a contested region is in NO nation's territory (never two) and is never no man's land while contested; at most 1–2 contested-region changes per turn, each narrated in `events`"); the user prompt gains `"contested"` (region key + party **names**) **only when the list is non-empty** (the one durable ability whose `payload()` can be `{}`). The two committed system-prompt **goldens** (`system-no-tool.txt`, `system-tool.txt`) are regenerated (each gains exactly the new `contested` schema line after `sea` + constraint bullet before the "Prefer 1-4 events" bullet); the **`user.txt` golden gains a `"contested"` entry** (the sample `b-r1` frontline with party names) as a new key after `seaZones`.
|
||||||
|
- **Tests.** New `server/abilities/contested.test.ts` (**52 tests**): the `CONTESTED_FILL` / `CONTESTED_STROKE` / `contestedTooltipLabel` surface; `validateContestedOps` (shape / leniency / drops / field caps / malformed top-level); `applyContested` OPEN (sole-owner-at-war with owner-as-party / party-not-owner, owner-not-at-war drop, no-man's-land open + NML-with-warring-parties + not-in-unowned drop, corrupt 2-owner drop, armistice-not-a-war drop, distinct-party / unresolvable / ambiguous drops, region-name + party-name resolve, manifest-gated key drop, owner stats re-derive + 0-region guard, two-opens-on-same-key, multi-key open); RESOLVE (release-to-NML, party-re-claim keeps + re-inits stats, non-party-owner release, multi-frontline, non-contested no-op, empty no-op); ordering (re-open replaces, resolve-then-open from NML, still-contested not re-openable, combined apply); and the ability const contract (field, `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload()` id→name / missing-nation fallback / `{}` when empty, `validate` wrapping, `apply` ctx wiring). Every apply test asserts the **sole-ownership invariants** (no key in >1 territory; a contested key in no territory and not unowned; an unowned key in no territory and not contested). `registry.test.ts` is updated for the **12-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable `stateFields`, composes 13 / `extended[12]`, re-export pin, user-golden 15th arg). New `db.test.ts` legacy-row test (a pre-v0.44 save without `contested` loads with `[]`).
|
||||||
|
- **Design note (deviation from the issue body).** The Gitea #66 body sketched a **two-owner** design (`disputed` regions with `fronts`, explicitly "relaxing sole-ownership"). The approved, implemented design is a **third region state**: a contested key is in **no** nation's `territory` and not in `unowned` (sole ownership is preserved — a key is never in two territories), with `open` / `resolve` ops and a **minimum of two contesting parties** tied to an active war. This keeps the engine's sacrosanct sole-ownership invariant intact while still modelling a war frontline.
|
||||||
|
- **Guarantees.** No new runtime deps, `shared/` keeps zero runtime deps, no `@turf`/`@types/geojson` in any `.ts`. No engine randomness (the LLM declares; the engine applies fixed, deterministic rules). All existing unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is contested-inert — it never opens or resolves a frontline, and the map legend stays at 7).
|
||||||
|
- **Version** bumped `0.43.0 → 0.44.0`.
|
||||||
|
|
||||||
## v0.43.0 — 2026-10-16 12:09
|
## v0.43.0 — 2026-10-16 12:09
|
||||||
- **#65: Sea control — a `TurnResult.sea` lever letting the LLM declare sole control over a fixed set of 12 named sea zones (North Atlantic, South Atlantic, Pacific, Indian, Mediterranean, North Sea, Baltic, Arctic, Black Sea, Gulf of Mexico, Red Sea, Sargasso), with a deterministic per-turn +1 military / +1 economy bonus for the controlling nation.** It is a **durable** feature (unlike the turn-transient `hybrid` / `disasters` / `migration` layers): `GameState.seaZones` persists across turns and in SQLite, and the user payload **always** carries the current `seaZones` list (even when empty) so the model knows who controls what. It lands as the **11th ability** of the v0.40.0 modular system, in a new `server/abilities/sea.ts` module, **registered last** in the `ABILITIES` registry (after `migration`). Sea control is fully **parallel to but independent of land**: it never touches `territory` / `unowned`, and moving land never touches a sea zone.
|
- **#65: Sea control — a `TurnResult.sea` lever letting the LLM declare sole control over a fixed set of 12 named sea zones (North Atlantic, South Atlantic, Pacific, Indian, Mediterranean, North Sea, Baltic, Arctic, Black Sea, Gulf of Mexico, Red Sea, Sargasso), with a deterministic per-turn +1 military / +1 economy bonus for the controlling nation.** It is a **durable** feature (unlike the turn-transient `hybrid` / `disasters` / `migration` layers): `GameState.seaZones` persists across turns and in SQLite, and the user payload **always** carries the current `seaZones` list (even when empty) so the model knows who controls what. It lands as the **11th ability** of the v0.40.0 modular system, in a new `server/abilities/sea.ts` module, **registered last** in the `ABILITIES` registry (after `migration`). Sea control is fully **parallel to but independent of land**: it never touches `territory` / `unowned`, and moving land never touches a sea zone.
|
||||||
- **Types (`shared/protocol.ts`).** New `SEA_ZONES` (a frozen array of 12 `{ id, name }` pairs), `SEA_ZONE_IDS` (a `ReadonlySet` of all 12 ids), `isSeaZoneId(id)` (type guard), `seaZoneName(id)` (human-readable name; unknown → raw), `SeaControlOp` (`{ zone; by }`), and `SeaOps` (`{ control: SeaControlOp[] }`); `TurnResult.sea` is **required**, placed between `migration` and `markers`, default `{ control: [] }`. New `SeaZoneClaim` (`{ zone; by }`) in `shared/types.ts`; `GameState.seaZones: SeaZoneClaim[]` is **required** (`[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wiped, via the generic `ABILITIES` `stateFields` loop in `db.ts` — no `db.ts` code change needed).
|
- **Types (`shared/protocol.ts`).** New `SEA_ZONES` (a frozen array of 12 `{ id, name }` pairs), `SEA_ZONE_IDS` (a `ReadonlySet` of all 12 ids), `isSeaZoneId(id)` (type guard), `seaZoneName(id)` (human-readable name; unknown → raw), `SeaControlOp` (`{ zone; by }`), and `SeaOps` (`{ control: SeaControlOp[] }`); `TurnResult.sea` is **required**, placed between `migration` and `markers`, default `{ control: [] }`. New `SeaZoneClaim` (`{ zone; by }`) in `shared/types.ts`; `GameState.seaZones: SeaZoneClaim[]` is **required** (`[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wiped, via the generic `ABILITIES` `stateFields` loop in `db.ts` — no `db.ts` code change needed).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.43.0",
|
"version": "0.44.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
|||||||
|
// The CONTESTED (contested regions / frontlines) ability (v0.44.0, #66) as a
|
||||||
|
// modular LLM-ability (v0.40.0, #67) — a THIRD region state, besides "owned by
|
||||||
|
// one nation" and "no man's land": a war frontline that is in **no** nation's
|
||||||
|
// `territory` and not in `unowned`, contested by two or more nations at war.
|
||||||
|
//
|
||||||
|
// Each turn the LLM may `open` a region as a contested frontline and/or
|
||||||
|
// `resolve` an existing one. `open` requires the region to have a SOLE owner
|
||||||
|
// that is at war with at least one of the parties, or to be no man's land (in
|
||||||
|
// `unowned`) with two or more of the parties at war with each other; a region
|
||||||
|
// with two or more owners, or whose owner is not at war with any party, cannot
|
||||||
|
// be contested (dropped). On a successful open the key is removed from its
|
||||||
|
// owner's `territory` (sole ownership is preserved — a contested key is in NO
|
||||||
|
// territory) and from `unowned`, its `regionStats` entry is deleted, and a
|
||||||
|
// `{ regionKey, parties }` entry (parties = resolved nation ids) is added to
|
||||||
|
// `GameState.contested` (unique by `regionKey` — re-opening replaces it).
|
||||||
|
// `resolve` removes the entry and releases the key to no man's land — UNLESS a
|
||||||
|
// concurrent winning claim by one of the parties (allowed by the
|
||||||
|
// territory-step guard in engine.ts) already re-owned it, in which case it
|
||||||
|
// stays owned and its stats are kept.
|
||||||
|
//
|
||||||
|
// A still-contested key is NOT a valid normal-claim target: a plain `gain` on
|
||||||
|
// it is dropped in the territory step (only a resolving party's claim
|
||||||
|
// re-owns it). The layer NEVER moves a key into two territories (sole
|
||||||
|
// ownership is sacrosanct), never touches borders directly (the key simply
|
||||||
|
// leaves ownership), and is deterministic (no hidden randomness — the LLM
|
||||||
|
// declares, the engine applies fixed rules).
|
||||||
|
//
|
||||||
|
// This is a DURABLE ability (unlike the turn-transient hybrid / disasters /
|
||||||
|
// migration layers): it owns the `GameState.contested` field, and the user
|
||||||
|
// payload carries the current contested list (only when non-empty).
|
||||||
|
|
||||||
|
import { deriveNationStats } from '../../shared/protocol';
|
||||||
|
import type { ContestedOps, ContestedRegion } from '../../shared/protocol';
|
||||||
|
// The map-fill constants + tooltip label live in the shared protocol module
|
||||||
|
// (like the sea-zone / no-man's-land constants); re-exported here so the
|
||||||
|
// index / engine re-export surface is one consistent place.
|
||||||
|
export {
|
||||||
|
CONTESTED_FILL,
|
||||||
|
CONTESTED_STROKE,
|
||||||
|
contestedTooltipLabel,
|
||||||
|
} from '../../shared/protocol';
|
||||||
|
import type { GameState } from '../../shared/types';
|
||||||
|
import { atWar } from './apply-helpers';
|
||||||
|
import { resolveNationRef, resolveRegionKey, str } from './shared';
|
||||||
|
import type { Ability, AbilityCtx } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) The SOLE owners of a region key in `next` (a key that is in no
|
||||||
|
* nation's `territory` yields `[]`; a corrupt 2+ owner key yields both). Pure.
|
||||||
|
*/
|
||||||
|
function currentOwners(next: GameState, key: string): string[] {
|
||||||
|
const owners: string[] = [];
|
||||||
|
for (const n of Object.values(next.nations)) {
|
||||||
|
if (n.territory.includes(key)) owners.push(n.id);
|
||||||
|
}
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) Resolve a region reference (a raw key or a human name) to a
|
||||||
|
* concrete region key: exact key first, then a unique case-insensitive name
|
||||||
|
* match. With no region-key manifest loaded (`validKeys` undefined) the raw
|
||||||
|
* string is accepted as the key (a name cannot be resolved without the
|
||||||
|
* manifest). Returns `null` when the reference is empty or unresolvable.
|
||||||
|
*/
|
||||||
|
function resolveKey(
|
||||||
|
ref: string,
|
||||||
|
validKeys?: ReadonlySet<string>,
|
||||||
|
nameOf?: (key: string) => string | undefined,
|
||||||
|
): string | null {
|
||||||
|
const r = (ref ?? '').trim();
|
||||||
|
if (!r) return null;
|
||||||
|
if (!validKeys) return r; // no manifest: accept the raw key as-is
|
||||||
|
return resolveRegionKey(r, validKeys, nameOf ?? (() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) Resolve a list of party references to distinct nation ids
|
||||||
|
* (id first, then a case-insensitive unique name), preserving first-appearance
|
||||||
|
* order. An unresolvable ref is dropped. Pure.
|
||||||
|
*/
|
||||||
|
function resolveParties(next: GameState, refs: string[]): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const ref of refs) {
|
||||||
|
const id = resolveNationRef(next, ref);
|
||||||
|
if (id && !out.includes(id)) out.push(id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) Re-derive a nation's aggregate `stats` from its regions'
|
||||||
|
* per-region stats, with the same 0-region / no-entry guards as the engine's
|
||||||
|
* main recompute (a nation with no region, or none with a stats entry, keeps
|
||||||
|
* its directly-mutated `stats`). Used after a contested open strips a region
|
||||||
|
* from its owner. Never throws.
|
||||||
|
*/
|
||||||
|
function rederiveOwnerStats(
|
||||||
|
next: GameState,
|
||||||
|
ownerId: string,
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
|
): void {
|
||||||
|
const n = next.nations[ownerId];
|
||||||
|
if (!n) return;
|
||||||
|
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||||
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) Validate the raw `contested` field of a turn into a safe
|
||||||
|
* {@link ContestedOps}. Defensive like the rest of the parser — never throws.
|
||||||
|
* `open`: each op needs a non-empty `regionKey` (capped at 120 chars) and
|
||||||
|
* TWO OR MORE non-empty `parties` refs (each capped at 200 chars); an op with
|
||||||
|
* a missing `regionKey` or fewer than two party refs is dropped. `resolve`:
|
||||||
|
* each entry is a non-empty region key (capped at 120 chars); empty / non-
|
||||||
|
* string entries are dropped. Both region keys and party refs are kept RAW
|
||||||
|
* here (like the hybrid / economy / industry refs) — whether a ref resolves to
|
||||||
|
* a real region / nation, and whether the war-linkage / ownership conditions
|
||||||
|
* hold, is checked at apply time (a ref that does not resolve, or a region that
|
||||||
|
* is not contestable, is dropped there). A missing / malformed top-level object
|
||||||
|
* yields `{ open: [], resolve: [] }`.
|
||||||
|
*/
|
||||||
|
export function validateContestedOps(raw: unknown): ContestedOps {
|
||||||
|
const out: ContestedOps = { open: [], resolve: [] };
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (Array.isArray(o.open)) {
|
||||||
|
for (const e of o.open) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const p = e as Record<string, unknown>;
|
||||||
|
const regionKey = str(p.regionKey, 120);
|
||||||
|
if (!regionKey) continue; // missing / empty region key: drop
|
||||||
|
if (!Array.isArray(p.parties)) continue; // missing / malformed parties: drop
|
||||||
|
const parties = p.parties
|
||||||
|
.map((x) => str(x, 200))
|
||||||
|
.filter((x): x is string => x !== null);
|
||||||
|
if (parties.length < 2) continue; // need at least two party refs
|
||||||
|
out.open.push({ regionKey, parties });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(o.resolve)) {
|
||||||
|
for (const e of o.resolve) {
|
||||||
|
const key = str(e, 120);
|
||||||
|
if (key) out.resolve.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) Apply the turn's contested-region ops to `next`, mutating in
|
||||||
|
* place and never throwing. Runs in the `ABILITIES` loop AFTER the territory
|
||||||
|
* step and the derived-stats recompute, and BEFORE markers / nationOps, so the
|
||||||
|
* owner / unowned / wars it inspects are all final for this turn.
|
||||||
|
*
|
||||||
|
* (1) RESOLVE (first): each key in `resolve` that is currently contested is
|
||||||
|
* removed from `next.contested` and released to no man's land (into
|
||||||
|
* `unowned`, out of any territory, `regionStats` deleted) — UNLESS a
|
||||||
|
* concurrent winning claim by one of the parties (the only claim the
|
||||||
|
* territory step permits on a contested key) already re-owned it, in
|
||||||
|
* which case it stays owned and keeps (or re-inits) its stats.
|
||||||
|
* (2) OPEN: each op that resolves to a contestable region (a sole owner at
|
||||||
|
* war with a party, or a no-man's-land region whose parties are at war
|
||||||
|
* with each other) is moved out of ownership (the owner's territory /
|
||||||
|
* `unowned`), its `regionStats` is deleted, and it is added to
|
||||||
|
* `next.contested` (unique by `regionKey` — an open replaces a kept
|
||||||
|
* entry for the same key). The stripped owner's derived stats are
|
||||||
|
* re-derived.
|
||||||
|
*
|
||||||
|
* A contested key is never placed into two territories and is never left in
|
||||||
|
* `unowned` (the two states are mutually exclusive), so the sole-ownership
|
||||||
|
* invariant holds after every op.
|
||||||
|
*/
|
||||||
|
export function applyContested(next: GameState, ops: ContestedOps, ctx: AbilityCtx): void {
|
||||||
|
const validKeys = ctx.validRegionKeys;
|
||||||
|
const nameOf = ctx.regionNameOf;
|
||||||
|
const geoOf = ctx.geoOf;
|
||||||
|
const open = Array.isArray(ops?.open) ? ops.open : [];
|
||||||
|
const resolve = Array.isArray(ops?.resolve) ? ops.resolve : [];
|
||||||
|
const prevContested = Array.isArray(next.contested) ? next.contested : [];
|
||||||
|
|
||||||
|
// (1) RESOLVE first: rebuild the kept list, dropping resolved keys. A
|
||||||
|
// resolved key is released to no man's land unless a party re-owned it.
|
||||||
|
const resolveSet = new Set(resolve);
|
||||||
|
const kept: ContestedRegion[] = [];
|
||||||
|
for (const c of prevContested) {
|
||||||
|
if (!resolveSet.has(c.regionKey)) {
|
||||||
|
kept.push(c); // not resolved this turn: keep
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const owners = currentOwners(next, c.regionKey);
|
||||||
|
if (owners.length === 1 && c.parties.includes(owners[0])) {
|
||||||
|
// A party re-owned it via a concurrent winning claim (the territory step
|
||||||
|
// allowed it): keep it owned, ensure it has stats (the claim re-inits an
|
||||||
|
// absent entry; the post-claim recompute already reflected the gain).
|
||||||
|
if (!next.regionStats[c.regionKey]) {
|
||||||
|
next.regionStats[c.regionKey] = { ...next.nations[owners[0]].stats };
|
||||||
|
}
|
||||||
|
continue; // stays owned — not re-added to `contested`
|
||||||
|
}
|
||||||
|
// Release to no man's land: out of any territory (defensive — a contested
|
||||||
|
// key is normally in none), into `unowned`, and its per-region stats leave.
|
||||||
|
for (const n of Object.values(next.nations)) {
|
||||||
|
const i = n.territory.indexOf(c.regionKey);
|
||||||
|
if (i !== -1) n.territory.splice(i, 1);
|
||||||
|
}
|
||||||
|
if (!next.unowned.includes(c.regionKey)) next.unowned.push(c.regionKey);
|
||||||
|
delete next.regionStats[c.regionKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) OPEN: each op that resolves to a contestable region is moved out of
|
||||||
|
// ownership and added to the contested list (unique by region key).
|
||||||
|
const opened = new Map<string, string[]>(); // region key -> resolved party ids
|
||||||
|
for (const op of open) {
|
||||||
|
const key = resolveKey(op.regionKey, validKeys, nameOf);
|
||||||
|
if (!key) continue;
|
||||||
|
const parties = resolveParties(next, op.parties);
|
||||||
|
if (parties.length < 2) continue; // fewer than two distinct resolved parties
|
||||||
|
|
||||||
|
const owners = currentOwners(next, key);
|
||||||
|
let ok = false;
|
||||||
|
if (owners.length === 1) {
|
||||||
|
// Sole owner present: the owner must be at war with at least one party
|
||||||
|
// (the owner may itself be a party — atWar is false for a==b, so this
|
||||||
|
// checks the owner against the OTHER parties).
|
||||||
|
ok = parties.some((p) => atWar(next.wars, owners[0], p));
|
||||||
|
} else if (owners.length === 0 && next.unowned.includes(key)) {
|
||||||
|
// No owner + no man's land: two of the parties must be at war with
|
||||||
|
// each other.
|
||||||
|
for (let i = 0; i < parties.length && !ok; i++) {
|
||||||
|
for (let j = i + 1; j < parties.length; j++) {
|
||||||
|
if (atWar(next.wars, parties[i], parties[j])) {
|
||||||
|
ok = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two or more owners (corrupt), an owner not at war with any party, or a
|
||||||
|
// not-in-unowned keyless region: never contestable (dropped).
|
||||||
|
if (!ok) continue;
|
||||||
|
|
||||||
|
if (owners.length === 1) {
|
||||||
|
const n = next.nations[owners[0]];
|
||||||
|
const i = n.territory.indexOf(key);
|
||||||
|
if (i !== -1) n.territory.splice(i, 1);
|
||||||
|
rederiveOwnerStats(next, owners[0], geoOf);
|
||||||
|
} else {
|
||||||
|
const i = next.unowned.indexOf(key);
|
||||||
|
if (i !== -1) next.unowned.splice(i, 1);
|
||||||
|
}
|
||||||
|
delete next.regionStats[key];
|
||||||
|
opened.set(key, parties);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild `next.contested`: the kept (unresolved) entries plus the newly
|
||||||
|
// opened ones, unique by region key (an open replaces a kept entry for the
|
||||||
|
// same key; a resolve removed its key from `kept` above).
|
||||||
|
const final: ContestedRegion[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of kept) {
|
||||||
|
if (opened.has(c.regionKey)) continue; // replaced by a re-open this turn
|
||||||
|
if (seen.has(c.regionKey)) continue; // dedupe (should not happen, but safe)
|
||||||
|
seen.add(c.regionKey);
|
||||||
|
final.push(c);
|
||||||
|
}
|
||||||
|
for (const [key, parties] of opened) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
final.push({ regionKey: key, parties });
|
||||||
|
}
|
||||||
|
next.contested = final;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contested: Ability<'contested'> = {
|
||||||
|
field: 'contested',
|
||||||
|
default: () => ({ open: [], resolve: [] }),
|
||||||
|
validate: (raw) => validateContestedOps(raw),
|
||||||
|
apply: (state, data, ctx) => applyContested(state, data, ctx),
|
||||||
|
schemaLine: () =>
|
||||||
|
' "contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},',
|
||||||
|
constraint: () =>
|
||||||
|
'- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man\'s land, contested by two or more nations at war. An "open" op ({"regionKey":"<key>","parties":["<a>","<b>"]}) makes a region a frontline — it leaves its owner\'s territory (or no-man\'s-land) and is contested: the region\'s sole owner must be at war with at least one of the parties, or (for a no-man\'s-land region) two of the parties must be at war with each other. A "resolve" op (["<key>"]) ends a frontline, releasing the region to no man\'s land — unless a party\'s concurrent claim re-owns it. A contested region is in NO nation\'s territory (never two) and is never no man\'s land while contested. The user message\'s "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" event).',
|
||||||
|
isMeaningful: (data) => data.open.length > 0 || data.resolve.length > 0,
|
||||||
|
stateFields: ['contested'],
|
||||||
|
stub: () => ({ open: [], resolve: [] }),
|
||||||
|
// The user payload carries the current contested frontlines (region key +
|
||||||
|
// party NAMES, resolved from the stored ids) — but only when non-empty (a
|
||||||
|
// fresh / no-frontline world contributes nothing, like the turn-transient
|
||||||
|
// layers).
|
||||||
|
payload: (ctx) => {
|
||||||
|
if (!ctx.contested || ctx.contested.length === 0) return {};
|
||||||
|
return {
|
||||||
|
contested: ctx.contested.map((c) => ({
|
||||||
|
regionKey: c.regionKey,
|
||||||
|
parties: c.parties.map((id) => ctx.state.nations[id]?.name ?? id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -72,6 +72,8 @@ function mkState(
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
|
|||||||
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
|
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
|
||||||
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
|
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
|
||||||
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
|
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
|
||||||
|
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
|
||||||
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
|
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ HARD CONSTRAINTS
|
|||||||
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
|
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
|
||||||
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
|
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
|
||||||
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).
|
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).
|
||||||
|
- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"<key>","parties":["<a>","<b>"]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op (["<key>"]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" event).
|
||||||
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
|
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
|
||||||
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
|
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
|
||||||
- Keep every stat value between 0 and 100.
|
- Keep every stat value between 0 and 100.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that
|
|||||||
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
|
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
|
||||||
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
|
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
|
||||||
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
|
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
|
||||||
|
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
|
||||||
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
|
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ HARD CONSTRAINTS
|
|||||||
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
|
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
|
||||||
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
|
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
|
||||||
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).
|
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).
|
||||||
|
- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"<key>","parties":["<a>","<b>"]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op (["<key>"]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" event).
|
||||||
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
|
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
|
||||||
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
|
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
|
||||||
- Keep every stat value between 0 and 100.
|
- Keep every stat value between 0 and 100.
|
||||||
|
|||||||
@@ -206,5 +206,14 @@
|
|||||||
"type": "industry"
|
"type": "industry"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"seaZones": []
|
"seaZones": [],
|
||||||
|
"contested": [
|
||||||
|
{
|
||||||
|
"regionKey": "b-r1",
|
||||||
|
"parties": [
|
||||||
|
"Alpha",
|
||||||
|
"Bravo"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// The ordered LLM-ability registry.
|
// The ordered LLM-ability registry.
|
||||||
//
|
//
|
||||||
// `ABILITIES` is the single source of truth for the 11 per-feature LLM abilities.
|
// `ABILITIES` is the single source of truth for the 12 per-feature LLM abilities.
|
||||||
// The engine iterates it in this order:
|
// The engine iterates it in this order:
|
||||||
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea
|
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea → contested
|
||||||
// for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability
|
// for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability
|
||||||
// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt,
|
// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt,
|
||||||
// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check,
|
// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check,
|
||||||
@@ -25,10 +25,11 @@ import { ideology } from './ideology';
|
|||||||
import { disasters } from './disasters';
|
import { disasters } from './disasters';
|
||||||
import { migration } from './migration';
|
import { migration } from './migration';
|
||||||
import { sea } from './sea';
|
import { sea } from './sea';
|
||||||
|
import { contested } from './contested';
|
||||||
import type { Ability, AbilityField } from './types';
|
import type { Ability, AbilityField } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 11 per-feature LLM abilities in their canonical apply order.
|
* The 12 per-feature LLM abilities in their canonical apply order.
|
||||||
* The order matters: it determines the order of the schema lines in the
|
* The order matters: it determines the order of the schema lines in the
|
||||||
* system prompt, the order of the validate calls, the order of the apply
|
* system prompt, the order of the validate calls, the order of the apply
|
||||||
* steps, and the order of the payload fields in the user prompt. New
|
* steps, and the order of the payload fields in the user prompt. New
|
||||||
@@ -47,6 +48,7 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
|
|||||||
disasters,
|
disasters,
|
||||||
migration,
|
migration,
|
||||||
sea,
|
sea,
|
||||||
|
contested,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── type re-exports ────────────────────────────────────────────────────────────
|
// ── type re-exports ────────────────────────────────────────────────────────────
|
||||||
@@ -173,3 +175,13 @@ export {
|
|||||||
isSeaZoneId,
|
isSeaZoneId,
|
||||||
seaZoneName,
|
seaZoneName,
|
||||||
} from './sea';
|
} from './sea';
|
||||||
|
|
||||||
|
// ── contested (frontlines) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export {
|
||||||
|
validateContestedOps,
|
||||||
|
applyContested,
|
||||||
|
CONTESTED_FILL,
|
||||||
|
CONTESTED_STROKE,
|
||||||
|
contestedTooltipLabel,
|
||||||
|
} from './contested';
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ function mkState(
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// by the disasters feature (its payload is always empty).
|
// by the disasters feature (its payload is always empty).
|
||||||
//
|
//
|
||||||
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
|
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
|
||||||
// list of the 11 abilities; each ability's default() equals the canonical
|
// list of the 12 abilities; each ability's default() equals the canonical
|
||||||
// TurnResult default for its field, is a FRESH value (never a shared
|
// TurnResult default for its field, is a FRESH value (never a shared
|
||||||
// reference), its default is not "meaningful" while a populated value is,
|
// reference), its default is not "meaningful" while a populated value is,
|
||||||
// and the registry composes (a hypothetical extra ability can be appended
|
// and the registry composes (a hypothetical extra ability can be appended
|
||||||
@@ -76,6 +76,12 @@ import {
|
|||||||
SEA_ZONE_IDS,
|
SEA_ZONE_IDS,
|
||||||
isSeaZoneId,
|
isSeaZoneId,
|
||||||
seaZoneName,
|
seaZoneName,
|
||||||
|
// contested (v0.44.0, #66)
|
||||||
|
validateContestedOps,
|
||||||
|
applyContested,
|
||||||
|
CONTESTED_FILL,
|
||||||
|
CONTESTED_STROKE,
|
||||||
|
contestedTooltipLabel,
|
||||||
} from '../engine';
|
} from '../engine';
|
||||||
import { createPresetStore } from '../presets';
|
import { createPresetStore } from '../presets';
|
||||||
import { ABILITIES } from './index';
|
import { ABILITIES } from './index';
|
||||||
@@ -92,7 +98,7 @@ import type {
|
|||||||
SeaZoneClaim,
|
SeaZoneClaim,
|
||||||
TradeAgreement,
|
TradeAgreement,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
|
import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const goldensDir = path.join(here, 'goldens');
|
const goldensDir = path.join(here, 'goldens');
|
||||||
@@ -165,6 +171,8 @@ const state: GameState = {
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- buildUserPrompt arguments: every optional branch exercised. ---
|
// --- buildUserPrompt arguments: every optional branch exercised. ---
|
||||||
@@ -202,6 +210,10 @@ const nationResources: Record<string, RegionResources> = {
|
|||||||
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
|
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
|
||||||
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
|
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
|
||||||
const seaZones: SeaZoneClaim[] = [];
|
const seaZones: SeaZoneClaim[] = [];
|
||||||
|
// (v0.44.0, #66) a non-empty contested sample so the golden's user payload
|
||||||
|
// includes the `contested` key (region key + party NAMES, resolved from the
|
||||||
|
// state's nations). `b-r1` is owned by b2; the parties are a1 + b2.
|
||||||
|
const contested: ContestedRegion[] = [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert the current prompt output is byte-identical to the committed golden —
|
* Assert the current prompt output is byte-identical to the committed golden —
|
||||||
@@ -241,6 +253,7 @@ function fullDefaultTurn(): TurnResult {
|
|||||||
disasters: { events: [] },
|
disasters: { events: [] },
|
||||||
migration: { ops: [] },
|
migration: { ops: [] },
|
||||||
sea: { control: [] },
|
sea: { control: [] },
|
||||||
|
contested: { open: [], resolve: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -270,11 +283,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
|
|||||||
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
|
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
|
||||||
case 'sea':
|
case 'sea':
|
||||||
return { control: [{ zone: 'pacific', by: 'a1' }] };
|
return { control: [{ zone: 'pacific', by: 'a1' }] };
|
||||||
|
case 'contested':
|
||||||
|
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ABILITIES registry (v0.40.0, #67)', () => {
|
describe('ABILITIES registry (v0.40.0, #67)', () => {
|
||||||
it('is the canonical 11-ability order (takeovers → sea)', () => {
|
it('is the canonical 12-ability order (takeovers → contested)', () => {
|
||||||
expect(ABILITIES.map((a) => a.field)).toEqual([
|
expect(ABILITIES.map((a) => a.field)).toEqual([
|
||||||
'takeovers',
|
'takeovers',
|
||||||
'wars',
|
'wars',
|
||||||
@@ -287,13 +302,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
|
|||||||
'disasters',
|
'disasters',
|
||||||
'migration',
|
'migration',
|
||||||
'sea',
|
'sea',
|
||||||
|
'contested',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists 11 distinct fields, each a key of TurnResult', () => {
|
it('lists 12 distinct fields, each a key of TurnResult', () => {
|
||||||
const fields = ABILITIES.map((a) => a.field);
|
const fields = ABILITIES.map((a) => a.field);
|
||||||
expect(fields).toHaveLength(11);
|
expect(fields).toHaveLength(12);
|
||||||
expect(new Set(fields).size).toBe(11);
|
expect(new Set(fields).size).toBe(12);
|
||||||
const all = fullDefaultTurn();
|
const all = fullDefaultTurn();
|
||||||
for (const f of fields) expect(f in all).toBe(true);
|
for (const f of fields) expect(f in all).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -324,6 +340,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
|
|||||||
industryProjects,
|
industryProjects,
|
||||||
nuclear,
|
nuclear,
|
||||||
seaZones,
|
seaZones,
|
||||||
|
contested,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -353,13 +370,13 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
|
|||||||
it('declares exactly the durable GameState fields the db migration must default', () => {
|
it('declares exactly the durable GameState fields the db migration must default', () => {
|
||||||
const owned: string[] = [];
|
const owned: string[] = [];
|
||||||
for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields);
|
for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields);
|
||||||
expect([...owned].sort()).toEqual(['embargoes', 'industry', 'seaZones', 'trades', 'wars']);
|
expect([...owned].sort()).toEqual(['contested', 'embargoes', 'industry', 'seaZones', 'trades', 'wars']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
|
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
|
||||||
// The registry is a plain ordered array the engine iterates uniformly. A
|
// The registry is a plain ordered array the engine iterates uniformly. A
|
||||||
// structural (field-name-agnostic) loop over the abilities — schema line,
|
// structural (field-name-agnostic) loop over the abilities — schema line,
|
||||||
// constraint bullet — must keep working when an 11th ability is appended.
|
// constraint bullet — must keep working when a 12th ability is appended.
|
||||||
// This documents the extension point without widening the closed
|
// This documents the extension point without widening the closed
|
||||||
// AbilityField union.
|
// AbilityField union.
|
||||||
interface Loopable {
|
interface Loopable {
|
||||||
@@ -378,9 +395,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
|
|||||||
'custom',
|
'custom',
|
||||||
]);
|
]);
|
||||||
// every uniform loop runs over all N abilities, the appended one included:
|
// every uniform loop runs over all N abilities, the appended one included:
|
||||||
expect(extended.map((a) => a.schemaLine())).toHaveLength(12);
|
expect(extended.map((a) => a.schemaLine())).toHaveLength(13);
|
||||||
expect(extended.map((a) => a.constraint())).toHaveLength(12);
|
expect(extended.map((a) => a.constraint())).toHaveLength(13);
|
||||||
expect(extended[11].schemaLine()).toBe(' "custom": [],');
|
expect(extended[12].schemaLine()).toBe(' "custom": [],');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the engine still re-exports every name its callers import from it', () => {
|
it('the engine still re-exports every name its callers import from it', () => {
|
||||||
@@ -439,6 +456,12 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
|
|||||||
SEA_ZONE_IDS,
|
SEA_ZONE_IDS,
|
||||||
isSeaZoneId,
|
isSeaZoneId,
|
||||||
seaZoneName,
|
seaZoneName,
|
||||||
|
// contested (v0.44.0, #66)
|
||||||
|
validateContestedOps,
|
||||||
|
applyContested,
|
||||||
|
CONTESTED_FILL,
|
||||||
|
CONTESTED_STROKE,
|
||||||
|
contestedTooltipLabel,
|
||||||
};
|
};
|
||||||
for (const [name, value] of Object.entries(reexported)) {
|
for (const [name, value] of Object.entries(reexported)) {
|
||||||
expect(value, `engine should re-export ${name}`).toBeDefined();
|
expect(value, `engine should re-export ${name}`).toBeDefined();
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ function mkState(
|
|||||||
embargoes: [],
|
embargoes: [],
|
||||||
industry: [],
|
industry: [],
|
||||||
seaZones,
|
seaZones,
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ import type {
|
|||||||
SeaZoneClaim,
|
SeaZoneClaim,
|
||||||
TradeAgreement,
|
TradeAgreement,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
|
import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 11 per-feature LLM abilities a turn can exercise, by the `TurnResult`
|
* The 12 per-feature LLM abilities a turn can exercise, by the `TurnResult`
|
||||||
* field each one owns. This is the closed universe the ordered `ABILITIES`
|
* field each one owns. This is the closed universe the ordered `ABILITIES`
|
||||||
* registry (./index.ts) is built from: a turn's world changes are exactly the
|
* registry (./index.ts) is built from: a turn's world changes are exactly the
|
||||||
* core engine effects (events / stats / territory / no-man's-land / nation-ops
|
* core engine effects (events / stats / territory / no-man's-land / nation-ops
|
||||||
* / markers) plus, in order, these eleven abilities.
|
* / markers) plus, in order, these twelve abilities.
|
||||||
*/
|
*/
|
||||||
export type AbilityField =
|
export type AbilityField =
|
||||||
| 'takeovers'
|
| 'takeovers'
|
||||||
@@ -35,7 +35,8 @@ export type AbilityField =
|
|||||||
| 'ideology'
|
| 'ideology'
|
||||||
| 'disasters'
|
| 'disasters'
|
||||||
| 'migration'
|
| 'migration'
|
||||||
| 'sea';
|
| 'sea'
|
||||||
|
| 'contested';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything a single ability's `apply` may need from the surrounding turn.
|
* Everything a single ability's `apply` may need from the surrounding turn.
|
||||||
@@ -92,6 +93,7 @@ export interface UserPromptCtx {
|
|||||||
readonly nationResources?: Record<string, RegionResources>;
|
readonly nationResources?: Record<string, RegionResources>;
|
||||||
readonly industryProjects?: IndustryProject[];
|
readonly industryProjects?: IndustryProject[];
|
||||||
readonly seaZones?: SeaZoneClaim[];
|
readonly seaZones?: SeaZoneClaim[];
|
||||||
|
readonly contested?: ContestedRegion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ function makeState(id: string): GameState {
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +259,17 @@ describe('openDb', () => {
|
|||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('(v0.44.0 #66) a legacy row saved before the contested field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { contested?: unknown[] }).contested; // a pre-v0.44 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.contested).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('delete removes a game by id and reports absence otherwise', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
+43
-38
@@ -135,6 +135,8 @@ function baseState(): GameState {
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +574,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withOps).not.toBeNull();
|
expect(withOps).not.toBeNull();
|
||||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||||
@@ -594,7 +596,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
@@ -615,7 +617,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([
|
expect(next.pendingNationOps).toEqual([
|
||||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||||
@@ -634,7 +636,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
});
|
});
|
||||||
@@ -1091,7 +1093,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
applyTurnResult(state, result);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
expect(JSON.stringify(state)).toBe(snapshot);
|
||||||
@@ -1108,7 +1110,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.pending).toEqual([]);
|
expect(next.pending).toEqual([]);
|
||||||
@@ -1131,7 +1133,7 @@ describe('applyTurnResult', () => {
|
|||||||
// is not a real key at all. Both are proposed as a gain by DE.
|
// is not a real key at all. Both are proposed as a gain by DE.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||||
@@ -1157,7 +1159,7 @@ describe('applyTurnResult', () => {
|
|||||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
@@ -1175,7 +1177,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
|||||||
regionChanges: [],
|
regionChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||||
@@ -1265,7 +1267,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||||
@@ -1380,7 +1382,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
|
|
||||||
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||||
expect(
|
expect(
|
||||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } }),
|
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1687,9 +1689,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
|||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
it('is true with an event, false when everything is empty', () => {
|
it('is true with an event, false when everything is empty', () => {
|
||||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(false);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||||
@@ -1702,7 +1704,7 @@ describe('isMeaningful', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1723,7 +1725,7 @@ describe('isMeaningful', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1744,7 +1746,7 @@ describe('isMeaningful', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1767,7 +1769,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.nations.de.name).toBe('The Reich');
|
expect(next.nations.de.name).toBe('The Reich');
|
||||||
@@ -1800,7 +1802,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.playerNationId).toBe('uk'); // player still leads it
|
expect(next.playerNationId).toBe('uk'); // player still leads it
|
||||||
@@ -1824,7 +1826,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
// no nation was renamed
|
// no nation was renamed
|
||||||
@@ -1851,7 +1853,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
// neither was renamed (ambiguous)
|
// neither was renamed (ambiguous)
|
||||||
@@ -1877,7 +1879,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(validated!.takeovers).toEqual([]);
|
expect(validated!.takeovers).toEqual([]);
|
||||||
const next = applyTurnResult(state, validated!);
|
const next = applyTurnResult(state, validated!);
|
||||||
@@ -1903,7 +1905,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.nations.uk.name).toBe('The Commonwealth');
|
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||||||
@@ -1927,7 +1929,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.version).toBe(2); // exactly one bump for the whole turn
|
expect(next.version).toBe(2); // exactly one bump for the whole turn
|
||||||
@@ -1949,7 +1951,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.nations.de.name).toBe('The Reich');
|
expect(next.nations.de.name).toBe('The Reich');
|
||||||
@@ -1983,7 +1985,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||||
expect(next.markers).toHaveLength(1);
|
expect(next.markers).toHaveLength(1);
|
||||||
@@ -2007,13 +2009,13 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
||||||
const first = applyTurnResult(state, {
|
const first = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(first.markers).toHaveLength(1);
|
expect(first.markers).toHaveLength(1);
|
||||||
// a second turn proposes the exact same marker again
|
// a second turn proposes the exact same marker again
|
||||||
const second = applyTurnResult(first, {
|
const second = applyTurnResult(first, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(second.markers).toHaveLength(1);
|
expect(second.markers).toHaveLength(1);
|
||||||
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
||||||
@@ -2040,7 +2042,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: {
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||||
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
|
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
|
||||||
@@ -2087,7 +2089,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: {
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||||
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
|
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
|
||||||
@@ -2112,7 +2114,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
];
|
];
|
||||||
const next = applyTurnResult(state, {
|
const next = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(next.markers).toHaveLength(1);
|
expect(next.markers).toHaveLength(1);
|
||||||
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
||||||
@@ -2128,14 +2130,14 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
// 1) uk releases the region → the marker becomes ownerless
|
// 1) uk releases the region → the marker becomes ownerless
|
||||||
const released = applyTurnResult(state, {
|
const released = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(released.markers[0].ownerNationId).toBeNull();
|
expect(released.markers[0].ownerNationId).toBeNull();
|
||||||
// 2) de re-claims the region → the marker's owner follows to de
|
// 2) de re-claims the region → the marker's owner follows to de
|
||||||
const reclaimed = applyTurnResult(released, {
|
const reclaimed = applyTurnResult(released, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
||||||
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
||||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||||
});
|
});
|
||||||
@@ -2205,7 +2207,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||||
});
|
});
|
||||||
const { state } = await runTurn({
|
const { state } = await runTurn({
|
||||||
llm: fakeLlm([result]),
|
llm: fakeLlm([result]),
|
||||||
@@ -2282,6 +2284,8 @@ function mkTurnState(
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2306,6 +2310,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
|||||||
disasters: partial.disasters ?? { events: [] },
|
disasters: partial.disasters ?? { events: [] },
|
||||||
migration: partial.migration ?? { ops: [] },
|
migration: partial.migration ?? { ops: [] },
|
||||||
sea: partial.sea ?? { control: [] },
|
sea: partial.sea ?? { control: [] },
|
||||||
|
contested: partial.contested ?? { open: [], resolve: [] },
|
||||||
markers: partial.markers ?? { add: [], remove: [] },
|
markers: partial.markers ?? { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3031,7 +3036,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withTakeovers).not.toBeNull();
|
expect(withTakeovers).not.toBeNull();
|
||||||
// the valid one survives, the empty-name one is dropped
|
// the valid one survives, the empty-name one is dropped
|
||||||
@@ -3419,7 +3424,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -3440,7 +3445,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -3461,7 +3466,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
|||||||
hybrid: { ops: [] },
|
hybrid: { ops: [] },
|
||||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||||
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
markerId,
|
markerId,
|
||||||
MAX_MARKERS,
|
MAX_MARKERS,
|
||||||
nationForPrompt,
|
nationForPrompt,
|
||||||
|
type ContestedRegion,
|
||||||
type MarkerInfo,
|
type MarkerInfo,
|
||||||
type MarkerSpec,
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
@@ -113,6 +114,11 @@ export {
|
|||||||
SEA_ZONE_IDS,
|
SEA_ZONE_IDS,
|
||||||
isSeaZoneId,
|
isSeaZoneId,
|
||||||
seaZoneName,
|
seaZoneName,
|
||||||
|
validateContestedOps,
|
||||||
|
applyContested,
|
||||||
|
CONTESTED_FILL,
|
||||||
|
CONTESTED_STROKE,
|
||||||
|
contestedTooltipLabel,
|
||||||
} from './abilities';
|
} from './abilities';
|
||||||
|
|
||||||
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
||||||
@@ -393,6 +399,14 @@ export function buildUserPrompt(
|
|||||||
* `TurnResult.sea`. Omitted entirely when undefined.
|
* `TurnResult.sea`. Omitted entirely when undefined.
|
||||||
*/
|
*/
|
||||||
seaZones?: SeaZoneClaim[],
|
seaZones?: SeaZoneClaim[],
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) The game's current contested regions (each a `{ regionKey,
|
||||||
|
* parties }` pair — a war frontline in no nation's territory and not in
|
||||||
|
* `unowned`). Emitted verbatim (even when empty) so the model knows which
|
||||||
|
* regions are frontlines (and can `resolve` them or see their parties);
|
||||||
|
* omitted entirely when undefined.
|
||||||
|
*/
|
||||||
|
contested?: ContestedRegion[],
|
||||||
): string {
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
@@ -503,6 +517,7 @@ export function buildUserPrompt(
|
|||||||
nationResources,
|
nationResources,
|
||||||
industryProjects,
|
industryProjects,
|
||||||
seaZones,
|
seaZones,
|
||||||
|
contested,
|
||||||
};
|
};
|
||||||
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
|
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
|
||||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||||
@@ -512,6 +527,7 @@ export function buildUserPrompt(
|
|||||||
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
|
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
|
||||||
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
|
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
|
||||||
Object.assign(payload, ABILITIES[10].payload(upCtx)); // sea → seaZones (always emitted)
|
Object.assign(payload, ABILITIES[10].payload(upCtx)); // sea → seaZones (always emitted)
|
||||||
|
Object.assign(payload, ABILITIES[11].payload(upCtx)); // contested → contested (only when non-empty)
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,6 +669,7 @@ export function validateTurnResult(
|
|||||||
disasters: { events: [] },
|
disasters: { events: [] },
|
||||||
migration: { ops: [] },
|
migration: { ops: [] },
|
||||||
sea: { control: [] },
|
sea: { control: [] },
|
||||||
|
contested: { open: [], resolve: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -940,6 +957,9 @@ export function applyTurnResult(
|
|||||||
// (v0.43.0, #65) sea-zone control; a legacy/corrupt clone without the field
|
// (v0.43.0, #65) sea-zone control; a legacy/corrupt clone without the field
|
||||||
// starts empty (the turn then upserts this turn's claims into it).
|
// starts empty (the turn then upserts this turn's claims into it).
|
||||||
next.seaZones = next.seaZones ?? [];
|
next.seaZones = next.seaZones ?? [];
|
||||||
|
// (v0.44.0, #66) contested regions (war frontlines); a legacy/corrupt clone
|
||||||
|
// without the field starts empty (the turn then opens/resolves contests).
|
||||||
|
next.contested = next.contested ?? [];
|
||||||
const actingTurn = state.turn; // this action resolves the current turn
|
const actingTurn = state.turn; // this action resolves the current turn
|
||||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||||
@@ -1035,12 +1055,34 @@ export function applyTurnResult(
|
|||||||
}
|
}
|
||||||
set.add(gainer);
|
set.add(gainer);
|
||||||
}
|
}
|
||||||
|
// (v0.44.0, #66) Contested-region guard for the claims step below: a region
|
||||||
|
// that is currently a war frontline (in `state.contested`) sits in no
|
||||||
|
// nation's territory, so a normal `gain` would otherwise treat it as a free
|
||||||
|
// re-claim. A contested key may only be claimed by a normal claim when this
|
||||||
|
// same turn resolves the contest AND the gainer is one of the contesting
|
||||||
|
// parties (a party re-claim); otherwise the frontline stays contested for
|
||||||
|
// the contested layer (later in the turn) to decide its fate.
|
||||||
|
const contestedBefore = new Set((state.contested ?? []).map((c) => c.regionKey));
|
||||||
|
const resolveThisTurn = new Set(result.contested.resolve);
|
||||||
|
const contestedParties = new Map(
|
||||||
|
(state.contested ?? []).map((c) => [c.regionKey, c.parties]),
|
||||||
|
);
|
||||||
for (const [key, claiming] of claimants) {
|
for (const [key, claiming] of claimants) {
|
||||||
if (claiming.size !== 1) continue; // double claim: the key stays as it was
|
if (claiming.size !== 1) continue; // double claim: the key stays as it was
|
||||||
const gainer = [...claiming][0];
|
const gainer = [...claiming][0];
|
||||||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||||||
if (owner === '') continue; // pre-existing double ownership: never touched
|
if (owner === '') continue; // pre-existing double ownership: never touched
|
||||||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
||||||
|
// (v0.44.0, #66) a currently-contested frontline is not a normal claim
|
||||||
|
// target: a claim on it only proceeds when this turn resolves the contest
|
||||||
|
// and the gainer is one of the contesting parties (a party re-claim);
|
||||||
|
// otherwise the frontline stays contested for the contested layer below.
|
||||||
|
if (
|
||||||
|
contestedBefore.has(key) &&
|
||||||
|
(!resolveThisTurn.has(key) || !contestedParties.get(key)?.includes(gainer))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
|
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
|
||||||
// different sole owner) only proceeds when the attacker's derived military
|
// different sole owner) only proceeds when the attacker's derived military
|
||||||
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
|
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
|
||||||
@@ -1620,6 +1662,10 @@ export async function runTurn(opts: {
|
|||||||
// (v0.43.0, #65) the current sea-zone control (always emitted — even when
|
// (v0.43.0, #65) the current sea-zone control (always emitted — even when
|
||||||
// empty — so the model knows who controls which of the fixed sea zones).
|
// empty — so the model knows who controls which of the fixed sea zones).
|
||||||
state.seaZones ?? [],
|
state.seaZones ?? [],
|
||||||
|
// (v0.44.0, #66) the current contested regions (war frontlines) — emitted
|
||||||
|
// verbatim (even when empty) so the model knows which regions are
|
||||||
|
// frontlines and can resolve them.
|
||||||
|
state.contested ?? [],
|
||||||
);
|
);
|
||||||
const nudge =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const state: GameState = {
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ function mkState(
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
expect(allTerritoryKeys(state).size).toBe(58);
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
|
|||||||
+15
-2
@@ -108,6 +108,13 @@ export interface StubPayload {
|
|||||||
* deterministicTurn), so the sea path is covered by unit tests only.
|
* deterministicTurn), so the sea path is covered by unit tests only.
|
||||||
*/
|
*/
|
||||||
seaZones?: Array<{ zone: string; by: string }>;
|
seaZones?: Array<{ zone: string; by: string }>;
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) The currently contested regions (war frontlines,
|
||||||
|
* `{ regionKey, parties }`), emitted by the engine's user prompt. The stub
|
||||||
|
* ignores this field — it never opens or resolves a contest (see
|
||||||
|
* deterministicTurn), so the contested path is covered by unit tests only.
|
||||||
|
*/
|
||||||
|
contested?: Array<{ regionKey: string; parties: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(s: string, n: number): string {
|
function truncate(s: string, n: number): string {
|
||||||
@@ -193,6 +200,11 @@ function truncate(s: string, n: number): string {
|
|||||||
* (v0.43.0, #65) The stub likewise NEVER claims, contests or re-claims a sea
|
* (v0.43.0, #65) The stub likewise NEVER claims, contests or re-claims a sea
|
||||||
* zone (it always returns `sea: { control: [] }`) — the sea-control path is
|
* zone (it always returns `sea: { control: [] }`) — the sea-control path is
|
||||||
* covered by unit tests only, keeping the e2e assertions untouched.
|
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.44.0, #66) The stub likewise NEVER opens or resolves a contested region
|
||||||
|
* (a war frontline) — it always returns `contested: { open: [], resolve: [] }`
|
||||||
|
* — the contested path is covered by unit tests only, keeping the e2e
|
||||||
|
* assertions untouched.
|
||||||
*/
|
*/
|
||||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -272,13 +284,14 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||||
};
|
};
|
||||||
|
|
||||||
// The 11 ability fields, filled by the registry in its canonical order. The
|
// The 12 ability fields, filled by the registry in its canonical order. The
|
||||||
// stub is ability-inert: every ability contributes its own no-op default
|
// stub is ability-inert: every ability contributes its own no-op default
|
||||||
// (each ability's `stub()` — see the per-ability doc comments above), so the
|
// (each ability's `stub()` — see the per-ability doc comments above), so the
|
||||||
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
|
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
|
||||||
// historical key order pinned by the stub's deterministic output
|
// historical key order pinned by the stub's deterministic output
|
||||||
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
|
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
|
||||||
// ideology → disasters → migration → sea), which must stay byte-identical.
|
// ideology → disasters → migration → sea → contested), which must stay
|
||||||
|
// byte-identical.
|
||||||
const abilities = {} as Record<AbilityField, unknown>;
|
const abilities = {} as Record<AbilityField, unknown>;
|
||||||
for (const a of ABILITIES) abilities[a.field] = a.stub();
|
for (const a of ABILITIES) abilities[a.field] = a.stub();
|
||||||
|
|
||||||
|
|||||||
@@ -783,6 +783,53 @@ export interface SeaOps {
|
|||||||
control: SeaControlOp[];
|
control: SeaControlOp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) A single `open` op: make a region **contested** — a war
|
||||||
|
* frontline that is in **no** nation's `territory` and not in `unowned` (a
|
||||||
|
* third region state, besides "owned by one nation" and "no man's land").
|
||||||
|
* `regionKey` is the region key (or a resolvable region name); `parties` are
|
||||||
|
* two or more nation refs (ids or names), resolved via the shared
|
||||||
|
* `resolveNationRef` — the stored `ContestedRegion.parties` are the resolved
|
||||||
|
* nation ids.
|
||||||
|
*/
|
||||||
|
export interface ContestOp {
|
||||||
|
/** The region key to make contested (the model names it by key or name). */
|
||||||
|
regionKey: string;
|
||||||
|
/**
|
||||||
|
* The nations contesting the region — two or more refs (ids or names). At
|
||||||
|
* least two distinct, resolvable nations are required, and the region's
|
||||||
|
* sole owner (if any) must be at war with one of them (or, for a
|
||||||
|
* no-man's-land region, two of the parties must be at war with each other).
|
||||||
|
*/
|
||||||
|
parties: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contested-region block of a `TurnResult`. Required; default
|
||||||
|
* `{ open: [], resolve: [] }`. `open` makes a region a contested frontline
|
||||||
|
* (it leaves its owner's `territory` and `unowned` — sole ownership is
|
||||||
|
* preserved); `resolve` ends a contest, releasing the region to no man's
|
||||||
|
* land — unless a concurrent winning claim by one of the parties re-owns it
|
||||||
|
* (re-initialising its `regionStats`).
|
||||||
|
*/
|
||||||
|
export interface ContestedOps {
|
||||||
|
/** Regions to make contested this turn. */
|
||||||
|
open: ContestOp[];
|
||||||
|
/** Region keys to resolve (release to no man's land) this turn. */
|
||||||
|
resolve: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contested region — a region key that is a war frontline: in **no**
|
||||||
|
* nation's `territory` and not in `unowned`, contested by `parties` (resolved
|
||||||
|
* nation ids, two or more). Stored in `GameState.contested` (unique by
|
||||||
|
* `regionKey`).
|
||||||
|
*/
|
||||||
|
export interface ContestedRegion {
|
||||||
|
regionKey: string;
|
||||||
|
parties: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||||
export interface TurnResult {
|
export interface TurnResult {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -992,6 +1039,19 @@ export interface TurnResult {
|
|||||||
* `{ control: [] }`.
|
* `{ control: [] }`.
|
||||||
*/
|
*/
|
||||||
sea: SeaOps;
|
sea: SeaOps;
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) Contested regions / frontlines: `open` makes a region a
|
||||||
|
* contested war frontline (it leaves its owner's `territory` and `unowned`
|
||||||
|
* — a third region state in no territory and not no man's land — and is
|
||||||
|
* contested by two or more nations at war); `resolve` ends a contest,
|
||||||
|
* releasing the region to no man's land unless a concurrent winning claim
|
||||||
|
* by one of the parties re-owns it. A region with a sole owner not at war
|
||||||
|
* with any party, or two or more owners, cannot be contested. Sole
|
||||||
|
* ownership is never broken; a still-contested key is not a valid normal
|
||||||
|
* claim target. At most 1–2 per turn, each narrated in `events`. Required;
|
||||||
|
* default `{ open: [], resolve: [] }`.
|
||||||
|
*/
|
||||||
|
contested: ContestedOps;
|
||||||
/**
|
/**
|
||||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||||
* (the engine positions each at its region's centroid + a small
|
* (the engine positions each at its region's centroid + a small
|
||||||
@@ -1233,6 +1293,27 @@ export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
|
|||||||
/** The border of an owned region (brighter than unowned). */
|
/** The border of an owned region (brighter than unowned). */
|
||||||
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
|
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct, visible fill + stroke for a **contested** region (a war
|
||||||
|
* frontline — a key that is in no nation's `territory` and not in `unowned`).
|
||||||
|
* Reddish/orange to read as a "hot" frontline on the dark basemap — clearly
|
||||||
|
* distinct from a nation color and from the no-man's-land grey. The contested
|
||||||
|
* state is decided by the client (via `GameState.contested`), never by the
|
||||||
|
* `RegionMap` (which only knows owned vs absent).
|
||||||
|
*/
|
||||||
|
export const CONTESTED_FILL = '#f97316';
|
||||||
|
export const CONTESTED_STROKE = 'rgba(255,237,213,0.55)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hover-tooltip sub-line for a contested region: a short "Contested:
|
||||||
|
* A vs B" line naming the contesting parties. A missing party name (a nation
|
||||||
|
* removed by a merge/split) degrades to a dash, never crashes.
|
||||||
|
*/
|
||||||
|
export function contestedTooltipLabel(parties: string[]): string {
|
||||||
|
const names = parties.map((p) => p || '—');
|
||||||
|
return `Contested: ${names.join(' vs ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** The hover-tooltip sub-line for a region with no owning nation. */
|
/** The hover-tooltip sub-line for a region with no owning nation. */
|
||||||
export function unownedTooltipLabel(isNoMansLand: boolean): string {
|
export function unownedTooltipLabel(isNoMansLand: boolean): string {
|
||||||
return isNoMansLand ? "No man's land" : 'No owner';
|
return isNoMansLand ? "No man's land" : 'No owner';
|
||||||
|
|||||||
@@ -463,6 +463,8 @@ export function worldDraftToGameState(
|
|||||||
industry: [],
|
industry: [],
|
||||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
|
contested: [],
|
||||||
};
|
};
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -3,7 +3,7 @@
|
|||||||
// (The single `import type` below erases at compile time, so no runtime
|
// (The single `import type` below erases at compile time, so no runtime
|
||||||
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
|
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
|
||||||
// doc comment.)
|
// doc comment.)
|
||||||
import type { NationOp } from './protocol';
|
import type { NationOp, ContestedRegion } from './protocol';
|
||||||
|
|
||||||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||||||
export interface NationStats {
|
export interface NationStats {
|
||||||
@@ -474,6 +474,18 @@ export interface GameState {
|
|||||||
* server/db.ts) — additive, no data wipe.
|
* server/db.ts) — additive, no data wipe.
|
||||||
*/
|
*/
|
||||||
seaZones: SeaZoneClaim[];
|
seaZones: SeaZoneClaim[];
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) Contested regions / frontlines (see {@link ContestedRegion}):
|
||||||
|
* one entry per region key that is a war frontline — a key in **no** nation's
|
||||||
|
* `territory` and not in `unowned`, contested by two or more nations at war.
|
||||||
|
* A region becomes contested when a turn's `contested.open` succeeds (it
|
||||||
|
* leaves its owner's `territory` and `unowned` — sole ownership is preserved);
|
||||||
|
* it is resolved (released to no man's land, or re-claimed by a party) by a
|
||||||
|
* later `contested.resolve`. At most ONE entry per region key (unique by
|
||||||
|
* `regionKey`). Always `[]` on a fresh game; rows saved before v0.44 load
|
||||||
|
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
|
||||||
|
*/
|
||||||
|
contested: ContestedRegion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -834,6 +834,7 @@ function GameScreen({
|
|||||||
unowned={game.unowned ?? []}
|
unowned={game.unowned ?? []}
|
||||||
markers={game.markers ?? []}
|
markers={game.markers ?? []}
|
||||||
regionStats={game.regionStats ?? {}}
|
regionStats={game.regionStats ?? {}}
|
||||||
|
contested={game.contested ?? []}
|
||||||
onFocusNation={focusNation}
|
onFocusNation={focusNation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
|||||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||||
maplibregl.setWorkerUrl(workerUrl);
|
maplibregl.setWorkerUrl(workerUrl);
|
||||||
import {
|
import {
|
||||||
|
CONTESTED_FILL,
|
||||||
|
CONTESTED_STROKE,
|
||||||
|
contestedTooltipLabel,
|
||||||
mapFeatureOwner,
|
mapFeatureOwner,
|
||||||
MARKER_TYPE_INFO,
|
MARKER_TYPE_INFO,
|
||||||
nationColor,
|
nationColor,
|
||||||
regionFillStyle,
|
regionFillStyle,
|
||||||
unownedTooltipLabel,
|
unownedTooltipLabel,
|
||||||
|
type ContestedRegion,
|
||||||
type RegionMap,
|
type RegionMap,
|
||||||
type RegionOwner,
|
type RegionOwner,
|
||||||
} from '@shared/protocol';
|
} from '@shared/protocol';
|
||||||
@@ -146,6 +150,12 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
|||||||
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
|
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
|
||||||
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
|
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
|
||||||
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
|
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
|
||||||
|
*
|
||||||
|
* (v0.44.0, #66) A **contested** region (a war frontline — `contestedSet`, from
|
||||||
|
* `GameState.contested`) takes precedence over both unowned looks: it is in no
|
||||||
|
* nation's territory and not no man's land, so it renders the distinct
|
||||||
|
* reddish/orange `CONTESTED_FILL` / `CONTESTED_STROKE`. The state is decided by
|
||||||
|
* the client, never by the RegionMap (which has no entry for a contested key).
|
||||||
*/
|
*/
|
||||||
function applyRegions(
|
function applyRegions(
|
||||||
geo: FeatureCollection,
|
geo: FeatureCollection,
|
||||||
@@ -153,6 +163,7 @@ function applyRegions(
|
|||||||
playerNationId: string,
|
playerNationId: string,
|
||||||
idKey: Record<string, string>,
|
idKey: Record<string, string>,
|
||||||
unownedSet: ReadonlySet<string>,
|
unownedSet: ReadonlySet<string>,
|
||||||
|
contestedSet: ReadonlySet<string>,
|
||||||
): FeatureCollection {
|
): FeatureCollection {
|
||||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||||
for (const f of out.features) {
|
for (const f of out.features) {
|
||||||
@@ -165,6 +176,18 @@ function applyRegions(
|
|||||||
// key the RegionMap by it — never the sub-region id or the repacked id.
|
// key the RegionMap by it — never the sub-region id or the repacked id.
|
||||||
const key = idKey[subId] ?? '';
|
const key = idKey[subId] ?? '';
|
||||||
f.properties.regionKey = key;
|
f.properties.regionKey = key;
|
||||||
|
// (v0.44.0, #66) A contested frontline takes precedence over every other
|
||||||
|
// state (owned, unowned, no man's land): the key is in no nation's
|
||||||
|
// territory and not no man's land, so it renders the distinct contested
|
||||||
|
// fill/stroke (decided by the client from `GameState.contested`, never by
|
||||||
|
// the RegionMap, which has no entry for it).
|
||||||
|
if (key !== '' && contestedSet.has(key)) {
|
||||||
|
f.properties.fill = CONTESTED_FILL;
|
||||||
|
f.properties.stroke = CONTESTED_STROKE;
|
||||||
|
f.properties.owner = '';
|
||||||
|
f.properties.ownerId = '';
|
||||||
|
f.properties.isPlayer = 0;
|
||||||
|
} else {
|
||||||
const r = key ? regions[key] : undefined;
|
const r = key ? regions[key] : undefined;
|
||||||
if (r) {
|
if (r) {
|
||||||
const style = regionFillStyle(r, false);
|
const style = regionFillStyle(r, false);
|
||||||
@@ -184,6 +207,7 @@ function applyRegions(
|
|||||||
f.properties.isPlayer = 0;
|
f.properties.isPlayer = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,8 +436,14 @@ function hoverHtml(
|
|||||||
noMansLand = false,
|
noMansLand = false,
|
||||||
regionKey?: string,
|
regionKey?: string,
|
||||||
regionStats?: Record<string, RegionStats>,
|
regionStats?: Record<string, RegionStats>,
|
||||||
|
contestedParties?: string[],
|
||||||
): string {
|
): string {
|
||||||
if (!region || !nation) {
|
if (!region || !nation) {
|
||||||
|
// (v0.44.0, #66) A contested frontline (a region in no nation's territory
|
||||||
|
// and not no man's land) names its contesting parties in the sub-line.
|
||||||
|
if (contestedParties && contestedParties.length > 0) {
|
||||||
|
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Contested')}</div><div class="hm-popup-sub">${contestedTooltipLabel(contestedParties)}</div></div>`;
|
||||||
|
}
|
||||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
|
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
|
||||||
}
|
}
|
||||||
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
|
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
|
||||||
@@ -479,6 +509,15 @@ interface Props {
|
|||||||
* stats.
|
* stats.
|
||||||
*/
|
*/
|
||||||
regionStats?: Record<string, RegionStats>;
|
regionStats?: Record<string, RegionStats>;
|
||||||
|
/**
|
||||||
|
* (v0.44.0, #66) The game's contested regions / war frontlines
|
||||||
|
* (`GameState.contested` — a region key + its contesting party ids). Such a
|
||||||
|
* region is in no nation's territory and not no man's land (a third region
|
||||||
|
* state): it renders the distinct reddish/orange `CONTESTED_FILL` and its
|
||||||
|
* hover tooltip names the parties. The map legend is unaffected (a
|
||||||
|
* contested key is absent from the RegionMap, so it adds no entry).
|
||||||
|
*/
|
||||||
|
contested?: ContestedRegion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -536,6 +575,7 @@ export default function WorldMap({
|
|||||||
onFocusNation,
|
onFocusNation,
|
||||||
markers,
|
markers,
|
||||||
regionStats,
|
regionStats,
|
||||||
|
contested,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
@@ -553,6 +593,10 @@ export default function WorldMap({
|
|||||||
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
||||||
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
|
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
|
||||||
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
|
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
|
||||||
|
// (v0.44.0, #66) latest contested regions, region key -> contesting party
|
||||||
|
// NAMES (the stored parties are resolved nation ids, resolved here so the
|
||||||
|
// hover tooltip can show "Contested: A vs B").
|
||||||
|
const contestedRef = useRef<Map<string, string[]>>(new Map());
|
||||||
regionsRef.current = regions;
|
regionsRef.current = regions;
|
||||||
nationsRef.current = nations;
|
nationsRef.current = nations;
|
||||||
playerRef.current = playerNationId;
|
playerRef.current = playerNationId;
|
||||||
@@ -561,6 +605,11 @@ export default function WorldMap({
|
|||||||
onFocusNationRef.current = onFocusNation;
|
onFocusNationRef.current = onFocusNation;
|
||||||
markersRef.current = markers ?? [];
|
markersRef.current = markers ?? [];
|
||||||
regionStatsRef.current = regionStats ?? {};
|
regionStatsRef.current = regionStats ?? {};
|
||||||
|
{
|
||||||
|
const m = new Map<string, string[]>();
|
||||||
|
for (const c of contested ?? []) m.set(c.regionKey, c.parties.map((id) => nations[id]?.name ?? id));
|
||||||
|
contestedRef.current = m;
|
||||||
|
}
|
||||||
|
|
||||||
// Diffing state for the transient territory-change flash (owned by this component
|
// Diffing state for the transient territory-change flash (owned by this component
|
||||||
// so a resync with identical data never re-flashes).
|
// so a resync with identical data never re-flashes).
|
||||||
@@ -721,9 +770,12 @@ export default function WorldMap({
|
|||||||
const pKey = f.properties?.regionKey;
|
const pKey = f.properties?.regionKey;
|
||||||
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
|
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
|
||||||
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
|
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
|
||||||
|
// (v0.44.0, #66) a contested frontline (in no territory, not no man's
|
||||||
|
// land) names its contesting parties in the tooltip sub-line.
|
||||||
|
const contestedParties = !region && keyStr !== undefined ? contestedRef.current.get(keyStr) : undefined;
|
||||||
hoverPopup
|
hoverPopup
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(e.lngLat)
|
||||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
|
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current, contestedParties))
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
map.getCanvas().style.cursor = 'pointer';
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
};
|
};
|
||||||
@@ -799,7 +851,7 @@ export default function WorldMap({
|
|||||||
const idKey = idKeyRef.current;
|
const idKey = idKeyRef.current;
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !idKey || !src) return;
|
if (!geo || !idKey || !src) return;
|
||||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
|
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current, new Set(contestedRef.current.keys())));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stamp the current markers onto the marker source (v0.27, #44).
|
// Stamp the current markers onto the marker source (v0.27, #44).
|
||||||
@@ -977,7 +1029,7 @@ export default function WorldMap({
|
|||||||
|
|
||||||
if (map && geo && idKey) {
|
if (map && geo && idKey) {
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
|
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet, new Set(contestedRef.current.keys())));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasPrimed && prev && idKey) {
|
if (wasPrimed && prev && idKey) {
|
||||||
@@ -989,7 +1041,9 @@ export default function WorldMap({
|
|||||||
// up as ownerId changes above, so they flash like any other transfer.
|
// up as ownerId changes above, so they flash like any other transfer.
|
||||||
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
||||||
}
|
}
|
||||||
}, [regions, playerNationId, unowned]);
|
// (v0.44.0, #66) re-paint when the contested set changes too — a contested
|
||||||
|
// key is absent from the RegionMap, so the diff alone wouldn't repaint it.
|
||||||
|
}, [regions, playerNationId, unowned, contested]);
|
||||||
|
|
||||||
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
|
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
|
||||||
// changes (a turn placed/removed markers) or the nations record changes
|
// changes (a turn placed/removed markers) or the nations record changes
|
||||||
|
|||||||
Reference in New Issue
Block a user