@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.45.0 — 2026-10-17 08:35
|
||||||
|
- **#80: Alliances / mutual-defense treaties — a `TurnResult.alliances` lever letting the LLM form (`form`) and break (`break`) durable, symmetric pacts between two distinct nations.** A `form` op creates a pact and sets the pair's relation to `ally` (unless an active war overrides — the pact still forms but `war` stays in force); a `break` op removes the pact and sets the pair back to `neutral` (again unless an active war still binds them). A pair holds at most one pact (re-forming is a no-op). Pacts live in `GameState.alliances` and the user payload **always** carries them (even when empty). It lands as the **13th ability** of the v0.40.0 modular system, in a new `server/abilities/alliances.ts` module, **registered last** in the `ABILITIES` registry (before `space`). Like `sea` / `wars` / `industry` it is **durable**: `GameState.alliances` persists across turns and in SQLite. Alliances never move borders (sole ownership is untouched).
|
||||||
|
- **#81: Space program / satellites — a `TurnResult.space` lever letting the LLM launch (`launch`) satellites over regions it owns and deorbit (`deorbit`) active ones by id.** Each satellite is one of four types (`recon`, `comms`, `navigation`, `weather`), gets a fresh stable id (`s1`, `s2`, …), and is stamped with the turn and date. Every active satellite grants its owner +1 stability per committed turn (`SATELLITE_STABILITY_BONUS`), mirrored to every region the owner holds and re-derived — a nation with several satellites stacks the bonus. Launches/deorbits never move borders (sole ownership is untouched). It lands as the **14th ability** of the v0.40.0 modular system, in a new `server/abilities/space.ts` module, **registered last** in the `ABILITIES` registry (after `alliances`). Like `sea` / `wars` / `industry` it is **durable**: `GameState.satellites` persists across turns and in SQLite, and the user payload **always** carries the current satellite list.
|
||||||
|
- **Types (`shared/protocol.ts`).** New `Alliance` (`{ id, parties, formedTurn, formedDate }`), `AllianceFormOp`, `AllianceBreakOp`, `AllianceOps` (`{ form, break }`); `SatelliteType` (`recon` | `comms` | `navigation` | `weather`), `SATELLITE_TYPES`, `SatelliteLaunchOp`, `SatelliteDeorbitOp`, `SpaceOps` (`{ launch, deorbit }`), `Satellite` (`{ id, by, regionKey, type, launchedTurn, launchedDate }`); `TurnResult.alliances` (required, between `contested` and `space`) and `TurnResult.space` (required, between `alliances` and `markers`).
|
||||||
|
- **Types (`shared/types.ts`).** `GameState.alliances: Alliance[]` (after `contested`) and `GameState.satellites: Satellite[]` (after `alliances`) — both required, `[]` at creation, migrated on legacy rows via the generic `ABILITIES` `stateFields` loop in `db.ts` (no `db.ts` code change needed).
|
||||||
|
- **Ability modules.** `server/abilities/alliances.ts` exports `validateAlliancesOps`, `applyAlliances`, `freshAllianceId`, and the `alliances: Ability<'alliances'>` const (`stateFields: ['alliances']`, payload always emits `{ alliances }`). `server/abilities/space.ts` exports `validateSpaceOps`, `applySpaceControl`, `applySatelliteBonuses`, `SATELLITE_STABILITY_BONUS`, `SATELLITE_TYPES`, `isSatelliteType`, `freshSatelliteId`, and the `space: Ability<'space'>` const (`stateFields: ['satellites']`, payload always emits `{ satellites }`).
|
||||||
|
- **Engine (`server/engine.ts`).** `buildUserPrompt` merges `ABILITIES[12].payload()` (alliances → `alliances`) and `ABILITIES[13].payload()` (space → `satellites`) after the contested payload merge (both always emitted, like `sea`).
|
||||||
|
- **Registry & prompt.** `alliances` and `space` appended **last** to `ABILITIES` (existing order untouched — entries 13 and 14); the system prompt gains both schema lines and constraint bullets; the user prompt gains `"alliances"` and `"satellites"` keys. The two system-prompt **goldens** and `user.txt` are regenerated.
|
||||||
|
- **Tests.** New `server/abilities/alliances.test.ts` (26 tests) and `server/abilities/space.test.ts` (30 tests), mirroring `sea.test.ts` style: `default()` / `stub()` freshness, `validate` (valid/invalid/field caps/malformed top-level), `apply` (form creates pact + `ally` relation; break removes + `neutral`; idempotent re-form; break-then-re-form reuses id; war overrides neutral/ally; unresolvable / ambiguous refs dropped; never touches `territory` / `unowned`), `isMeaningful`, `payload` (always emits), `stateFields`, and `applySatelliteBonuses` (+1 stability per satellite, clamped, mirrored, re-derived, stacks). `registry.test.ts` updated for the **14-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable `stateFields` now 8 entries, composes 15 / `extended[14]`).
|
||||||
|
- **Version** bumped `0.44.0 → 0.45.0`.
|
||||||
|
|
||||||
## v0.44.0 — 2026-10-16 15:10
|
## 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.
|
- **#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).
|
- **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).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.44.0",
|
"version": "0.45.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).",
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// Unit tests for the ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) —
|
||||||
|
// the modular, DURABLE LLM-ability that lets nations form symmetric pacts
|
||||||
|
// (`alliances.form`) and break them (`alliances.break`): a pact sets the pair's
|
||||||
|
// relation to `ally` (unless an active war overrides), breaking sets it back to
|
||||||
|
// `neutral` (unless an active war still binds them), a pair holds at most one
|
||||||
|
// pact, and the pacts live in `GameState.alliances`. The layer never touches
|
||||||
|
// `territory` / `unowned`.
|
||||||
|
// This is the ability's own test file (the registry's cross-cutting guarantees
|
||||||
|
// — order, distinct fields, the system/user goldens — are covered by
|
||||||
|
// ./registry.test.ts). The stub never proposes an alliance op, so the apply
|
||||||
|
// path is exercised here, not by the e2e suite.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyAlliances,
|
||||||
|
alliances,
|
||||||
|
freshAllianceId,
|
||||||
|
validateAlliancesOps,
|
||||||
|
} from './alliances';
|
||||||
|
|
||||||
|
import type { Alliance } from '../../shared/protocol';
|
||||||
|
import type { GameState, Nation, NationStats } from '../../shared/types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixture helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NS(military: number, economy: number, stability: number, population: number): NationStats {
|
||||||
|
return { military, economy, stability, population };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkNation(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
stats: NationStats,
|
||||||
|
territory: string[],
|
||||||
|
relations: Record<string, 'ally' | 'neutral' | 'rival' | 'hostile' | 'war'> = {},
|
||||||
|
): Nation {
|
||||||
|
return { id, name, type: 'major', stats, territory, relations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkState(
|
||||||
|
nations: Record<string, Nation>,
|
||||||
|
alliances: Alliance[] = [],
|
||||||
|
): GameState {
|
||||||
|
return {
|
||||||
|
id: 'g1',
|
||||||
|
presetId: 'test',
|
||||||
|
presetTitle: 'Test',
|
||||||
|
playerNationId: Object.keys(nations)[0] ?? 'a1',
|
||||||
|
turn: 1,
|
||||||
|
date: '1939-09-01',
|
||||||
|
nations,
|
||||||
|
events: [],
|
||||||
|
pending: [],
|
||||||
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
|
regionStats: {},
|
||||||
|
wars: [],
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
industry: [],
|
||||||
|
seaZones: [],
|
||||||
|
contested: [],
|
||||||
|
alliances,
|
||||||
|
satellites: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = (state: GameState) => ({ previous: state, actingTurn: state.turn });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// freshAllianceId
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('freshAllianceId', () => {
|
||||||
|
it('returns the first free alN id', () => {
|
||||||
|
expect(freshAllianceId(new Set())).toBe('al1');
|
||||||
|
expect(freshAllianceId(new Set(['al1']))).toBe('al2');
|
||||||
|
expect(freshAllianceId(new Set(['al1', 'al2']))).toBe('al3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips ids already taken (a removed pact\'s id is never reused within a turn)', () => {
|
||||||
|
expect(freshAllianceId(new Set(['al1', 'al3']))).toBe('al2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// validateAlliancesOps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('validateAlliancesOps', () => {
|
||||||
|
it('accepts the { form, break } object shape', () => {
|
||||||
|
expect(
|
||||||
|
validateAlliancesOps({
|
||||||
|
form: [{ a: 'a1', b: 'b2' }],
|
||||||
|
break: [{ a: 'b2', b: 'c3' }],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
form: [{ a: 'a1', b: 'b2' }],
|
||||||
|
break: [{ a: 'b2', b: 'c3' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops ops with empty / missing a or b, or a === b (self-pairing)', () => {
|
||||||
|
expect(validateAlliancesOps({ form: [{ a: 'a1', b: '' }], break: [] })).toEqual({
|
||||||
|
form: [],
|
||||||
|
break: [],
|
||||||
|
});
|
||||||
|
expect(validateAlliancesOps({ form: [{ a: '', b: 'b2' }], break: [] })).toEqual({
|
||||||
|
form: [],
|
||||||
|
break: [],
|
||||||
|
});
|
||||||
|
expect(validateAlliancesOps({ form: [{ a: 'a1', b: 'a1' }], break: [] })).toEqual({
|
||||||
|
form: [],
|
||||||
|
break: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops ops with non-object entries', () => {
|
||||||
|
expect(
|
||||||
|
validateAlliancesOps({
|
||||||
|
form: [42, 'nope', null, { a: 'a1', b: 'b2' }],
|
||||||
|
break: [],
|
||||||
|
}),
|
||||||
|
).toEqual({ form: [{ a: 'a1', b: 'b2' }], break: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a missing / malformed top-level value yields { form: [], break: [] }', () => {
|
||||||
|
expect(validateAlliancesOps(undefined)).toEqual({ form: [], break: [] });
|
||||||
|
expect(validateAlliancesOps(null)).toEqual({ form: [], break: [] });
|
||||||
|
expect(validateAlliancesOps('nope')).toEqual({ form: [], break: [] });
|
||||||
|
expect(validateAlliancesOps({ form: 'nope' })).toEqual({ form: [], break: [] });
|
||||||
|
expect(validateAlliancesOps({ break: 'nope' })).toEqual({ form: [], break: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps `a` and `b` at 200 chars', () => {
|
||||||
|
const longA = 'f'.repeat(500);
|
||||||
|
const longB = 'g'.repeat(500); // different string so a !== b after capping
|
||||||
|
const out = validateAlliancesOps({ form: [{ a: longA, b: longB }], break: [] });
|
||||||
|
expect(out.form).toHaveLength(1);
|
||||||
|
expect(out.form[0].a).toHaveLength(200);
|
||||||
|
expect(out.form[0].b).toHaveLength(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// applyAlliances
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('applyAlliances', () => {
|
||||||
|
it('form creates a pact and sets the pair to ally', () => {
|
||||||
|
const state = mkState({
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
});
|
||||||
|
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
|
||||||
|
|
||||||
|
expect(state.alliances).toEqual([
|
||||||
|
{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' },
|
||||||
|
]);
|
||||||
|
expect(state.nations.a1.relations.b2).toBe('ally');
|
||||||
|
expect(state.nations.b2.relations.a1).toBe('ally');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves `a`/`b` by unique name (not just id)', () => {
|
||||||
|
const state = mkState({
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
});
|
||||||
|
applyAlliances(state, { form: [{ a: 'Alpha', b: 'Bravo' }], break: [] }, ctx(state));
|
||||||
|
expect(state.alliances).toHaveLength(1);
|
||||||
|
expect(state.alliances[0].parties).toEqual(['a1', 'b2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unresolvable ref drops the op', () => {
|
||||||
|
const state = mkState({
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
});
|
||||||
|
applyAlliances(state, { form: [{ a: 'a1', b: 'ghost' }], break: [] }, ctx(state));
|
||||||
|
expect(state.alliances).toEqual([]);
|
||||||
|
expect(state.nations.a1.relations).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ambiguous name (two nations share it) drops the op', () => {
|
||||||
|
const state = mkState({
|
||||||
|
a1: mkNation('a1', 'Twin', NS(60, 55, 70, 80), []),
|
||||||
|
a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), []),
|
||||||
|
});
|
||||||
|
applyAlliances(state, { form: [{ a: 'Twin', b: 'a1' }], break: [] }, ctx(state));
|
||||||
|
// 'Twin' is ambiguous → dropped
|
||||||
|
expect(state.alliances).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('break removes an existing pact and sets the pair to neutral', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'ally' }),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'ally' }),
|
||||||
|
},
|
||||||
|
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state));
|
||||||
|
|
||||||
|
expect(state.alliances).toEqual([]);
|
||||||
|
expect(state.nations.a1.relations.b2).toBe('neutral');
|
||||||
|
expect(state.nations.b2.relations.a1).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaking a non-existent pact is a no-op', () => {
|
||||||
|
const state = mkState({
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
});
|
||||||
|
applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state));
|
||||||
|
expect(state.alliances).toEqual([]);
|
||||||
|
expect(state.nations.a1.relations).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-forming an existing pact is idempotent (no duplicate)', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
},
|
||||||
|
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
|
||||||
|
expect(state.alliances).toHaveLength(1);
|
||||||
|
expect(state.alliances[0].formedTurn).toBe(1); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('break then re-form the same pair in one turn ends with a fresh pact', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
},
|
||||||
|
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applyAlliances(
|
||||||
|
state,
|
||||||
|
{ form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] },
|
||||||
|
ctx(state),
|
||||||
|
);
|
||||||
|
expect(state.alliances).toHaveLength(1);
|
||||||
|
expect(state.alliances[0].id).toBe('al1'); // id reused (al1 was freed by break)
|
||||||
|
expect(state.alliances[0].formedTurn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('form when the pair is at war: the pact forms but the relation stays war', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }),
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
// wire up an active war so atWar(a1, b2) is true
|
||||||
|
);
|
||||||
|
state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }];
|
||||||
|
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
|
||||||
|
|
||||||
|
expect(state.alliances).toHaveLength(1); // pact still formed
|
||||||
|
expect(state.alliances[0].parties).toEqual(['a1', 'b2']);
|
||||||
|
// the relation stays 'war' (war overrides ally)
|
||||||
|
expect(state.nations.a1.relations.b2).toBe('war');
|
||||||
|
expect(state.nations.b2.relations.a1).toBe('war');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('break when the pair is at war: the pact breaks but the relation stays war', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }),
|
||||||
|
},
|
||||||
|
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }];
|
||||||
|
applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state));
|
||||||
|
|
||||||
|
expect(state.alliances).toEqual([]); // pact removed
|
||||||
|
// the relation stays 'war' (war overrides neutral)
|
||||||
|
expect(state.nations.a1.relations.b2).toBe('war');
|
||||||
|
expect(state.nations.b2.relations.a1).toBe('war');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs are order-independent: form {a: b2, b: a1} matches an existing {a1, b2} pact', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
},
|
||||||
|
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applyAlliances(state, { form: [{ a: 'b2', b: 'a1' }], break: [] }, ctx(state));
|
||||||
|
// already allied (order-independent) → no duplicate
|
||||||
|
expect(state.alliances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches `territory` or `unowned`', () => {
|
||||||
|
const state = mkState({
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), ['a-r1']),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), ['b-r1']),
|
||||||
|
});
|
||||||
|
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
|
||||||
|
expect(state.nations.a1.territory).toEqual(['a-r1']);
|
||||||
|
expect(state.nations.b2.territory).toEqual(['b-r1']);
|
||||||
|
expect(state.unowned).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The Ability object contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('alliances ability', () => {
|
||||||
|
it('owns the "alliances" field; default() is a fresh empty { form: [], break: [] }', () => {
|
||||||
|
expect(alliances.field).toBe('alliances');
|
||||||
|
expect(alliances.default()).toEqual({ form: [], break: [] });
|
||||||
|
expect(alliances.default()).not.toBe(alliances.default());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default() is not meaningful; a populated value is', () => {
|
||||||
|
expect(alliances.isMeaningful(alliances.default())).toBe(false);
|
||||||
|
expect(alliances.isMeaningful({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toBe(true);
|
||||||
|
expect(alliances.isMeaningful({ form: [], break: [{ a: 'a1', b: 'b2' }] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validate() wraps the validator into the AllianceOps shape', () => {
|
||||||
|
expect(alliances.validate({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toEqual({
|
||||||
|
form: [{ a: 'a1', b: 'b2' }],
|
||||||
|
break: [],
|
||||||
|
});
|
||||||
|
expect(alliances.validate(undefined)).toEqual({ form: [], break: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the stub value equals default() (the stub never proposes an alliance op)', () => {
|
||||||
|
expect(alliances.stub()).toEqual({ form: [], break: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stateFields is [alliances] (the durable GameState field)', () => {
|
||||||
|
expect(alliances.stateFields).toEqual(['alliances']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('payload() emits alliances (even when empty / undefined)', () => {
|
||||||
|
expect(alliances.payload({ state: { alliances: [] } } as never)).toEqual({ alliances: [] });
|
||||||
|
expect(alliances.payload({ state: {} as never })).toEqual({ alliances: [] });
|
||||||
|
expect(
|
||||||
|
alliances.payload({
|
||||||
|
state: { alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }] },
|
||||||
|
} as never),
|
||||||
|
).toEqual({
|
||||||
|
alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('apply() runs form then break (break processed first)', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
|
||||||
|
},
|
||||||
|
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
const c = ctx(state);
|
||||||
|
alliances.apply(
|
||||||
|
state,
|
||||||
|
{ form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] },
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
// break first removes al1 (freeing the id), then form creates al1 again (id reused)
|
||||||
|
expect(state.alliances).toEqual([
|
||||||
|
{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// The ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) as a modular
|
||||||
|
// LLM-ability (v0.40.0, #67). A durable, symmetric pact between two distinct
|
||||||
|
// nations: the LLM proposes `form` / `break` ops each turn and the engine
|
||||||
|
// applies them directly (against the post-territory `next` state) and persists
|
||||||
|
// the active pacts in `GameState.alliances`.
|
||||||
|
//
|
||||||
|
// A pair holds at most one pact (re-forming is a no-op; breaking a non-
|
||||||
|
// existent pact is a no-op). Forming a pact sets the pair's relation to
|
||||||
|
// `ally`; breaking one sets it back to `neutral`. In both cases an ACTIVE WAR
|
||||||
|
// between the pair overrides: the pact still forms / still breaks, but the
|
||||||
|
// `war` relation stays in force — the layer never makes peace on its own.
|
||||||
|
// Alliances never move borders (sole ownership is untouched) and are
|
||||||
|
// 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.alliances` field, and the user
|
||||||
|
// payload ALWAYS carries the current pacts (even when empty) so the model
|
||||||
|
// knows who is allied with whom.
|
||||||
|
|
||||||
|
import { atWar } from './apply-helpers';
|
||||||
|
import type { Alliance, AllianceOps } from '../../shared/protocol';
|
||||||
|
import type { GameState } from '../../shared/types';
|
||||||
|
import { resolveNationRef, str } from './shared';
|
||||||
|
import type { Ability, AbilityCtx } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #80) Generate a fresh, unused alliance id: the first free of
|
||||||
|
* `al1`, `al2`, .... The caller adds each id it creates to `taken` as it goes,
|
||||||
|
* so ids stay unique within a turn and are never reused from a removed pact.
|
||||||
|
*/
|
||||||
|
export function freshAllianceId(taken: ReadonlySet<string>): string {
|
||||||
|
for (let i = 1; ; i++) {
|
||||||
|
const id = `al${i}`;
|
||||||
|
if (!taken.has(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #80) The two nation ids of an alliance entry, sorted (so a pair
|
||||||
|
* can be looked up regardless of the order the LLM names them in).
|
||||||
|
*/
|
||||||
|
function sortedParties(a: string, b: string): [string, string] {
|
||||||
|
return a < b ? [a, b] : [b, a];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #80) Find the index of the pact (if any) covering the pair `a` /
|
||||||
|
* `b`, order-independent. Returns -1 when no such pact exists.
|
||||||
|
*/
|
||||||
|
function pactIndex(alliances: Alliance[], a: string, b: string): number {
|
||||||
|
const [x, y] = sortedParties(a, b);
|
||||||
|
return alliances.findIndex((c) => c.parties[0] === x && c.parties[1] === y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #80) Validate the raw `alliances` field of a turn into a safe
|
||||||
|
* {@link AllianceOps}. Defensive like the rest of the parser — never throws.
|
||||||
|
* Accepts the object `{ form: [...], break: [...] }` (lenient: a bare object
|
||||||
|
* with `form`/`break` arrays). Each op `{a, b}` needs non-empty `a` and `b`
|
||||||
|
* (each capped at 200 chars) with `a !== b`; a malformed op is dropped.
|
||||||
|
* Resolution of `a`/`b` to concrete nation ids happens at apply time
|
||||||
|
* (`applyAlliances`), not here. A missing / malformed top-level value yields
|
||||||
|
* `{ form: [], break: [] }`.
|
||||||
|
*/
|
||||||
|
export function validateAlliancesOps(raw: unknown): AllianceOps {
|
||||||
|
const out: AllianceOps = { form: [], break: [] };
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
for (const key of ['form', 'break'] as const) {
|
||||||
|
const list = o[key];
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const e of list) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const p = e as Record<string, unknown>;
|
||||||
|
const a = str(p.a, 200);
|
||||||
|
const b = str(p.b, 200);
|
||||||
|
if (!a || !b || a === b) continue; // empty / missing / self-pairing: drop
|
||||||
|
out[key].push({ a, b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #80) Apply the turn's alliance ops to `next`, mutating in place
|
||||||
|
* and never throwing. `break` is processed FIRST, then `form`, so a turn that
|
||||||
|
* breaks and re-forms the same pair ends with a fresh pact. An unresolvable
|
||||||
|
* ref (unknown / ambiguous nation) drops that op.
|
||||||
|
*
|
||||||
|
* (1) BREAK: for each op, if both refs resolve to distinct nations and a pact
|
||||||
|
* covers the pair, remove the pact and set a↔b to `neutral` — UNLESS the
|
||||||
|
* pair is at war (an active war overrides: the pact breaks but the
|
||||||
|
* `war` relation stays).
|
||||||
|
* (2) FORM: for each op, if both refs resolve to distinct nations and no pact
|
||||||
|
* covers the pair, create `{ id, parties: [sorted a, b], formedTurn,
|
||||||
|
* formedDate }` and set a↔b to `ally` — UNLESS the pair is at war (the
|
||||||
|
* pact still forms, but the `war` relation stays in force). Re-forming an
|
||||||
|
* existing pact is a no-op.
|
||||||
|
*
|
||||||
|
* Sole ownership is never touched; only `alliances` and the pair's mutual
|
||||||
|
* relation change.
|
||||||
|
*/
|
||||||
|
export function applyAlliances(
|
||||||
|
next: GameState,
|
||||||
|
ops: AllianceOps,
|
||||||
|
ctx: AbilityCtx,
|
||||||
|
): void {
|
||||||
|
const alliances = next.alliances ?? [];
|
||||||
|
const wars = next.wars ?? [];
|
||||||
|
|
||||||
|
// (1) BREAK first: end existing pacts.
|
||||||
|
for (const op of ops?.break ?? []) {
|
||||||
|
const a = resolveNationRef(next, op.a);
|
||||||
|
const b = resolveNationRef(next, op.b);
|
||||||
|
if (!a || !b || a === b) continue;
|
||||||
|
const i = pactIndex(alliances, a, b);
|
||||||
|
if (i === -1) continue; // no pact to break: no-op
|
||||||
|
alliances.splice(i, 1);
|
||||||
|
// Set the pair back to neutral, unless an active war still binds them.
|
||||||
|
if (!atWar(wars, a, b)) {
|
||||||
|
const na = next.nations[a];
|
||||||
|
const nb = next.nations[b];
|
||||||
|
if (na) na.relations[b] = 'neutral';
|
||||||
|
if (nb) nb.relations[a] = 'neutral';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) FORM: create new pacts (idempotent — re-forming is a no-op).
|
||||||
|
const taken = new Set(alliances.map((c) => c.id));
|
||||||
|
for (const op of ops?.form ?? []) {
|
||||||
|
const a = resolveNationRef(next, op.a);
|
||||||
|
const b = resolveNationRef(next, op.b);
|
||||||
|
if (!a || !b || a === b) continue;
|
||||||
|
if (pactIndex(alliances, a, b) !== -1) continue; // already allied: no-op
|
||||||
|
const [x, y] = sortedParties(a, b);
|
||||||
|
alliances.push({
|
||||||
|
id: freshAllianceId(taken),
|
||||||
|
parties: [x, y],
|
||||||
|
formedTurn: ctx.actingTurn,
|
||||||
|
formedDate: next.date,
|
||||||
|
});
|
||||||
|
taken.add(alliances[alliances.length - 1].id);
|
||||||
|
// Set the pair to ally, unless an active war still binds them.
|
||||||
|
if (!atWar(wars, a, b)) {
|
||||||
|
const na = next.nations[a];
|
||||||
|
const nb = next.nations[b];
|
||||||
|
if (na) na.relations[b] = 'ally';
|
||||||
|
if (nb) nb.relations[a] = 'ally';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.alliances = alliances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alliances: Ability<'alliances'> = {
|
||||||
|
field: 'alliances',
|
||||||
|
default: () => ({ form: [], break: [] }),
|
||||||
|
validate: (raw) => validateAlliancesOps(raw),
|
||||||
|
apply: (state, data, ctx) => applyAlliances(state, data, ctx),
|
||||||
|
schemaLine: () =>
|
||||||
|
' "alliances": {"form":[{"a":"<nation id or name>","b":"<nation id or name>"}],"break":[{"a":"<nation id or name>","b":"<nation id or name>"}]},',
|
||||||
|
constraint: () =>
|
||||||
|
'- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"<nation>","b":"<nation>"}) creates a pact and sets the pair\'s relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message\'s "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).',
|
||||||
|
isMeaningful: (data) => data.form.length > 0 || data.break.length > 0,
|
||||||
|
stateFields: ['alliances'],
|
||||||
|
stub: () => ({ form: [], break: [] }),
|
||||||
|
// Alliances are DURABLE (like sea / wars / industry): the user payload
|
||||||
|
// ALWAYS carries the current pacts (even when empty) so the model knows who
|
||||||
|
// is allied with whom and can `break` a pact or avoid re-forming one.
|
||||||
|
payload: (ctx) => ({ alliances: ctx.state.alliances ?? [] }),
|
||||||
|
};
|
||||||
@@ -101,6 +101,10 @@ function mkState(
|
|||||||
industry: [],
|
industry: [],
|
||||||
seaZones: [],
|
seaZones: [],
|
||||||
contested: opts.contested ?? [],
|
contested: opts.contested ?? [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ function mkState(
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
|
|||||||
"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>"]},
|
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
|
||||||
|
"alliances": {"form":[{"a":"<nation id or name>","b":"<nation id or name>"}],"break":[{"a":"<nation id or name>","b":"<nation id or name>"}]},
|
||||||
|
"space": {"launch":[{"by":"<nation id or name>","regionKey":"<region key>","type":"<recon|comms|navigation|weather>"}],"deorbit":["<satellite id>"]},
|
||||||
"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>"]}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,8 @@ HARD CONSTRAINTS
|
|||||||
- 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).
|
- 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).
|
||||||
|
- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"<nation>","b":"<nation>"}) creates a pact and sets the pair's relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message's "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).
|
||||||
|
- Space program ("space") is the orbital layer: a "launch" op ({"by":"<nation>","regionKey":"<key>","type":"<recon|comms|navigation|weather>"}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op (["<satellite id>"]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message's "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).
|
||||||
- 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.
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ You have a getNeighbours tool: given region keys it returns the region keys that
|
|||||||
"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>"]},
|
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
|
||||||
|
"alliances": {"form":[{"a":"<nation id or name>","b":"<nation id or name>"}],"break":[{"a":"<nation id or name>","b":"<nation id or name>"}]},
|
||||||
|
"space": {"launch":[{"by":"<nation id or name>","regionKey":"<region key>","type":"<recon|comms|navigation|weather>"}],"deorbit":["<satellite id>"]},
|
||||||
"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>"]}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,8 @@ HARD CONSTRAINTS
|
|||||||
- 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).
|
- 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).
|
||||||
|
- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"<nation>","b":"<nation>"}) creates a pact and sets the pair's relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message's "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).
|
||||||
|
- Space program ("space") is the orbital layer: a "launch" op ({"by":"<nation>","regionKey":"<key>","type":"<recon|comms|navigation|weather>"}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op (["<satellite id>"]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message's "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).
|
||||||
- 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.
|
||||||
|
|||||||
@@ -215,5 +215,7 @@
|
|||||||
"Bravo"
|
"Bravo"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"alliances": [],
|
||||||
|
"satellites": []
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,8 @@ 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 { contested } from './contested';
|
||||||
|
import { alliances } from './alliances';
|
||||||
|
import { space } from './space';
|
||||||
import type { Ability, AbilityField } from './types';
|
import type { Ability, AbilityField } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +51,8 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
|
|||||||
migration,
|
migration,
|
||||||
sea,
|
sea,
|
||||||
contested,
|
contested,
|
||||||
|
alliances,
|
||||||
|
space,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── type re-exports ────────────────────────────────────────────────────────────
|
// ── type re-exports ────────────────────────────────────────────────────────────
|
||||||
@@ -185,3 +189,23 @@ export {
|
|||||||
CONTESTED_STROKE,
|
CONTESTED_STROKE,
|
||||||
contestedTooltipLabel,
|
contestedTooltipLabel,
|
||||||
} from './contested';
|
} from './contested';
|
||||||
|
|
||||||
|
// ── alliances (mutual-defense treaties) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export {
|
||||||
|
validateAlliancesOps,
|
||||||
|
applyAlliances,
|
||||||
|
freshAllianceId,
|
||||||
|
} from './alliances';
|
||||||
|
|
||||||
|
// ── space (satellites) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export {
|
||||||
|
validateSpaceOps,
|
||||||
|
applySpaceControl,
|
||||||
|
applySatelliteBonuses,
|
||||||
|
SATELLITE_STABILITY_BONUS,
|
||||||
|
SATELLITE_TYPES,
|
||||||
|
isSatelliteType,
|
||||||
|
freshSatelliteId,
|
||||||
|
} from './space';
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ function mkState(
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ const state: GameState = {
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- buildUserPrompt arguments: every optional branch exercised. ---
|
// --- buildUserPrompt arguments: every optional branch exercised. ---
|
||||||
@@ -254,6 +258,8 @@ function fullDefaultTurn(): TurnResult {
|
|||||||
migration: { ops: [] },
|
migration: { ops: [] },
|
||||||
sea: { control: [] },
|
sea: { control: [] },
|
||||||
contested: { open: [], resolve: [] },
|
contested: { open: [], resolve: [] },
|
||||||
|
alliances: { form: [], break: [] },
|
||||||
|
space: { launch: [], deorbit: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -285,6 +291,10 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
|
|||||||
return { control: [{ zone: 'pacific', by: 'a1' }] };
|
return { control: [{ zone: 'pacific', by: 'a1' }] };
|
||||||
case 'contested':
|
case 'contested':
|
||||||
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
|
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
|
||||||
|
case 'alliances':
|
||||||
|
return { form: [{ a: 'a1', b: 'b2' }], break: [] };
|
||||||
|
case 'space':
|
||||||
|
return { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,13 +313,15 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
|
|||||||
'migration',
|
'migration',
|
||||||
'sea',
|
'sea',
|
||||||
'contested',
|
'contested',
|
||||||
|
'alliances',
|
||||||
|
'space',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists 12 distinct fields, each a key of TurnResult', () => {
|
it('lists 14 distinct fields, each a key of TurnResult', () => {
|
||||||
const fields = ABILITIES.map((a) => a.field);
|
const fields = ABILITIES.map((a) => a.field);
|
||||||
expect(fields).toHaveLength(12);
|
expect(fields).toHaveLength(14);
|
||||||
expect(new Set(fields).size).toBe(12);
|
expect(new Set(fields).size).toBe(14);
|
||||||
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);
|
||||||
});
|
});
|
||||||
@@ -370,7 +382,7 @@ 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(['contested', 'embargoes', 'industry', 'seaZones', 'trades', 'wars']);
|
expect([...owned].sort()).toEqual(['alliances', 'contested', 'embargoes', 'industry', 'satellites', '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', () => {
|
||||||
@@ -395,9 +407,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(13);
|
expect(extended.map((a) => a.schemaLine())).toHaveLength(15);
|
||||||
expect(extended.map((a) => a.constraint())).toHaveLength(13);
|
expect(extended.map((a) => a.constraint())).toHaveLength(15);
|
||||||
expect(extended[12].schemaLine()).toBe(' "custom": [],');
|
expect(extended[14].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', () => {
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ function mkState(
|
|||||||
seaZones,
|
seaZones,
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
// Unit tests for the SPACE PROGRAM / satellites ability (v0.45.0, #81) — the
|
||||||
|
// modular, DURABLE LLM-ability that lets nations launch satellites
|
||||||
|
// (`space.launch`) over regions they own and deorbit them (`space.deorbit`):
|
||||||
|
// each active satellite grants its owner +1 stability per committed turn
|
||||||
|
// (`SATELLITE_STABILITY_BONUS`), mirrored to its regions and re-derived, and a
|
||||||
|
// nation with several satellites stacks the bonus. The layer never touches
|
||||||
|
// `territory` / `unowned`.
|
||||||
|
// This is the ability's own test file (the registry's cross-cutting guarantees
|
||||||
|
// — order, distinct fields, the system/user goldens — are covered by
|
||||||
|
// ./registry.test.ts). The stub never proposes a space op, so the apply path is
|
||||||
|
// exercised here, not by the e2e suite.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applySatelliteBonuses,
|
||||||
|
applySpaceControl,
|
||||||
|
isSatelliteType,
|
||||||
|
space,
|
||||||
|
validateSpaceOps,
|
||||||
|
SATELLITE_STABILITY_BONUS,
|
||||||
|
SATELLITE_TYPES,
|
||||||
|
} from './space';
|
||||||
|
|
||||||
|
import type { GameState, Nation, NationStats, RegionStats } from '../../shared/types';
|
||||||
|
import type { Satellite } from '../../shared/protocol';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixture helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NS(military: number, economy: number, stability: number, population: number): NationStats {
|
||||||
|
return { military, economy, stability, population };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RS(military: number, economy: number, stability: number, population: number): RegionStats {
|
||||||
|
return { military, economy, stability, population };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkNation(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
stats: NationStats,
|
||||||
|
territory: string[],
|
||||||
|
): Nation {
|
||||||
|
return { id, name, type: 'major', stats, territory, relations: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkState(
|
||||||
|
nations: Record<string, Nation>,
|
||||||
|
regionStats: Record<string, RegionStats>,
|
||||||
|
satellites: Satellite[] = [],
|
||||||
|
): GameState {
|
||||||
|
return {
|
||||||
|
id: 'g1',
|
||||||
|
presetId: 'test',
|
||||||
|
presetTitle: 'Test',
|
||||||
|
playerNationId: Object.keys(nations)[0] ?? 'a1',
|
||||||
|
turn: 1,
|
||||||
|
date: '1939-09-01',
|
||||||
|
nations,
|
||||||
|
events: [],
|
||||||
|
pending: [],
|
||||||
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
|
regionStats,
|
||||||
|
wars: [],
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
industry: [],
|
||||||
|
seaZones: [],
|
||||||
|
contested: [],
|
||||||
|
alliances: [],
|
||||||
|
satellites,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = (state: GameState) => ({ previous: state, actingTurn: state.turn, validRegionKeys: new Set(['a-r1', 'b-r1', 'c-r1']) });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SATELLITE_TYPES / isSatelliteType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('SATELLITE_TYPES / isSatelliteType', () => {
|
||||||
|
it('exposes exactly the 4 satellite types', () => {
|
||||||
|
expect(SATELLITE_TYPES).toEqual(['recon', 'comms', 'navigation', 'weather']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isSatelliteType accepts each known type and rejects unknown / non-strings', () => {
|
||||||
|
for (const t of SATELLITE_TYPES) expect(isSatelliteType(t)).toBe(true);
|
||||||
|
expect(isSatelliteType('weapon')).toBe(false);
|
||||||
|
expect(isSatelliteType('')).toBe(false);
|
||||||
|
expect(isSatelliteType(42 as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SATELLITE_STABILITY_BONUS is +1', () => {
|
||||||
|
expect(SATELLITE_STABILITY_BONUS).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// validateSpaceOps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('validateSpaceOps', () => {
|
||||||
|
it('accepts the { launch, deorbit } object shape', () => {
|
||||||
|
const op = { by: 'a1', regionKey: 'a-r1', type: 'recon' };
|
||||||
|
expect(validateSpaceOps({ launch: [op], deorbit: ['s1'] })).toEqual({
|
||||||
|
launch: [op],
|
||||||
|
deorbit: ['s1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a launch op with an unknown / missing type', () => {
|
||||||
|
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'weapon' }], deorbit: [] })).toEqual({
|
||||||
|
launch: [],
|
||||||
|
deorbit: [],
|
||||||
|
});
|
||||||
|
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1' }], deorbit: [] })).toEqual({
|
||||||
|
launch: [],
|
||||||
|
deorbit: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a launch op with an empty / missing `by` or `regionKey`', () => {
|
||||||
|
expect(validateSpaceOps({ launch: [{ by: '', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({
|
||||||
|
launch: [],
|
||||||
|
deorbit: [],
|
||||||
|
});
|
||||||
|
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: '', type: 'recon' }], deorbit: [] })).toEqual({
|
||||||
|
launch: [],
|
||||||
|
deorbit: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a deorbit entry that is empty / non-string', () => {
|
||||||
|
expect(validateSpaceOps({ launch: [], deorbit: ['', 42, null, 's1'] as unknown as string[] })).toEqual({
|
||||||
|
launch: [],
|
||||||
|
deorbit: ['s1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes deorbit ids', () => {
|
||||||
|
expect(validateSpaceOps({ launch: [], deorbit: ['s1', 's1', 's2', 's2'] })).toEqual({
|
||||||
|
launch: [],
|
||||||
|
deorbit: ['s1', 's2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps `by` at 200 and `regionKey` at 120 chars', () => {
|
||||||
|
const longBy = 'f'.repeat(500);
|
||||||
|
const longKey = 'g'.repeat(500);
|
||||||
|
const out = validateSpaceOps({ launch: [{ by: longBy, regionKey: longKey, type: 'recon' }], deorbit: [] });
|
||||||
|
expect(out.launch).toHaveLength(1);
|
||||||
|
expect(out.launch[0].by).toHaveLength(200);
|
||||||
|
expect(out.launch[0].regionKey).toHaveLength(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a missing / malformed top-level value yields { launch: [], deorbit: [] }', () => {
|
||||||
|
expect(validateSpaceOps(undefined)).toEqual({ launch: [], deorbit: [] });
|
||||||
|
expect(validateSpaceOps(null)).toEqual({ launch: [], deorbit: [] });
|
||||||
|
expect(validateSpaceOps('nope')).toEqual({ launch: [], deorbit: [] });
|
||||||
|
expect(validateSpaceOps({ launch: 'nope' })).toEqual({ launch: [], deorbit: [] });
|
||||||
|
expect(validateSpaceOps({ deorbit: 'nope' })).toEqual({ launch: [], deorbit: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// applySpaceControl (deorbit first, then launch)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('applySpaceControl', () => {
|
||||||
|
it('a launch adds a fresh satellite stamped with the turn and date', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||||
|
|
||||||
|
expect(state.satellites).toEqual([
|
||||||
|
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves `by` by unique name (not just id)', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
applySpaceControl(state, { launch: [{ by: 'Alpha', regionKey: 'a-r1', type: 'comms' }], deorbit: [] }, ctx(state));
|
||||||
|
expect(state.satellites).toHaveLength(1);
|
||||||
|
expect(state.satellites[0].by).toBe('a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unresolvable `by` drops the launch', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
applySpaceControl(state, { launch: [{ by: 'ghost', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||||
|
expect(state.satellites).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unowned or invalid regionKey drops the launch', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
// a-r2 is not owned by a1
|
||||||
|
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r2', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||||
|
expect(state.satellites).toEqual([]);
|
||||||
|
|
||||||
|
// not-a-key is not a valid key
|
||||||
|
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'not-a-key', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||||
|
expect(state.satellites).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deorbit removes satellites by id; an absent id is a no-op', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applySpaceControl(state, { launch: [], deorbit: ['s1', 's999'] }, ctx(state));
|
||||||
|
expect(state.satellites).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deorbit is processed before launch in the same turn', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applySpaceControl(
|
||||||
|
state,
|
||||||
|
{ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'comms' }], deorbit: ['s1'] },
|
||||||
|
ctx(state),
|
||||||
|
);
|
||||||
|
// s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1 — id reused
|
||||||
|
expect(state.satellites).toEqual([
|
||||||
|
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches `territory` or `unowned`', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
);
|
||||||
|
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||||
|
expect(state.nations.a1.territory).toEqual(['a-r1']);
|
||||||
|
expect(state.unowned).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// applySatelliteBonuses
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('applySatelliteBonuses', () => {
|
||||||
|
it('each active satellite grants its owner +1 stability (clamped, mirrored, re-derived)', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applySatelliteBonuses(state);
|
||||||
|
|
||||||
|
// +1 stability, mirrored to the region, re-derived (single region → back to region's value)
|
||||||
|
expect(state.nations.a1.stats).toEqual(NS(50, 50, 51, 50));
|
||||||
|
expect(state.regionStats['a-r1']).toEqual(RS(50, 50, 51, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps to 0 and 100', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 99, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 99, 50) },
|
||||||
|
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applySatelliteBonuses(state);
|
||||||
|
expect(state.nations.a1.stats.stability).toBe(100);
|
||||||
|
expect(state.regionStats['a-r1'].stability).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a nation with several satellites stacks the bonus (+1 per satellite)', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
[
|
||||||
|
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||||
|
{ id: 's2', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||||
|
{ id: 's3', by: 'a1', regionKey: 'a-r1', type: 'weather', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
applySatelliteBonuses(state);
|
||||||
|
expect(state.nations.a1.stats.stability).toBe(53);
|
||||||
|
expect(state.regionStats['a-r1'].stability).toBe(53);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a 0-region nation keeps its directly-mutated stats (no re-derive)', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), []) },
|
||||||
|
{},
|
||||||
|
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applySatelliteBonuses(state);
|
||||||
|
// direct mutation, no re-derive (no regions)
|
||||||
|
expect(state.nations.a1.stats.stability).toBe(51);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stale satellite (nation no longer in `nations`) is skipped; the satellite is kept', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
[{ id: 's1', by: 'ghost', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
applySatelliteBonuses(state);
|
||||||
|
// ghost is not in nations → skipped; a1 is unaffected
|
||||||
|
expect(state.nations.a1.stats.stability).toBe(50);
|
||||||
|
// the satellite is kept (not removed)
|
||||||
|
expect(state.satellites).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty satellites list is a no-op', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
applySatelliteBonuses(state);
|
||||||
|
expect(state.nations.a1.stats.stability).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The Ability object contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('space ability', () => {
|
||||||
|
it('owns the "space" field; default() is a fresh empty { launch: [], deorbit: [] }', () => {
|
||||||
|
expect(space.field).toBe('space');
|
||||||
|
expect(space.default()).toEqual({ launch: [], deorbit: [] });
|
||||||
|
expect(space.default()).not.toBe(space.default());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default() is not meaningful; a populated value is', () => {
|
||||||
|
expect(space.isMeaningful(space.default())).toBe(false);
|
||||||
|
expect(space.isMeaningful({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toBe(true);
|
||||||
|
expect(space.isMeaningful({ launch: [], deorbit: ['s1'] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validate() wraps the validator into the SpaceOps shape', () => {
|
||||||
|
expect(space.validate({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({
|
||||||
|
launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }],
|
||||||
|
deorbit: [],
|
||||||
|
});
|
||||||
|
expect(space.validate(undefined)).toEqual({ launch: [], deorbit: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the stub value equals default() (the stub never proposes a space op)', () => {
|
||||||
|
expect(space.stub()).toEqual({ launch: [], deorbit: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stateFields is [satellites] (the durable GameState field)', () => {
|
||||||
|
expect(space.stateFields).toEqual(['satellites']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('payload() emits satellites (even when empty / undefined)', () => {
|
||||||
|
expect(space.payload({ state: { satellites: [] } } as never)).toEqual({ satellites: [] });
|
||||||
|
expect(space.payload({ state: {} as never })).toEqual({ satellites: [] });
|
||||||
|
expect(
|
||||||
|
space.payload({
|
||||||
|
state: { satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }] },
|
||||||
|
} as never),
|
||||||
|
).toEqual({
|
||||||
|
satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('apply() runs deorbit, then launch, then the per-turn bonus (no throw on a bad ref)', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{
|
||||||
|
a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']),
|
||||||
|
b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']),
|
||||||
|
},
|
||||||
|
{ 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) },
|
||||||
|
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||||
|
);
|
||||||
|
const c = ctx(state);
|
||||||
|
space.apply(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
launch: [
|
||||||
|
{ by: 'a1', regionKey: 'a-r1', type: 'comms' },
|
||||||
|
// an unresolvable ref — must be dropped, not throw
|
||||||
|
{ by: 'ghost', regionKey: 'c-r1', type: 'weather' },
|
||||||
|
],
|
||||||
|
deorbit: ['s1'],
|
||||||
|
},
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
// s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1
|
||||||
|
expect(state.satellites).toEqual([
|
||||||
|
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||||
|
]);
|
||||||
|
// a1 gets +1 stability (one satellite)
|
||||||
|
expect(state.nations.a1.stats.stability).toBe(51);
|
||||||
|
// b2 is unaffected
|
||||||
|
expect(state.nations.b2.stats.stability).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// The SPACE PROGRAM / satellites ability (v0.45.0, #81) as a modular
|
||||||
|
// LLM-ability (v0.40.0, #67). Durable per-turn space ops: the LLM launches
|
||||||
|
// satellites (one of four `SatelliteType`s) over regions it owns and deorbits
|
||||||
|
// active ones by id. Every active satellite grants its owner a deterministic
|
||||||
|
// +1 stability per committed turn (`SATELLITE_STABILITY_BONUS`), mirrored to
|
||||||
|
// every region the owner holds (`regionStats`), then the owner's derived
|
||||||
|
// `stats` are re-derived — so a nation with several satellites stacks the
|
||||||
|
// bonus. Launches/deorbits never move borders (sole ownership is untouched).
|
||||||
|
//
|
||||||
|
// Each turn the LLM may `launch` new satellites (each op names `by`, a valid
|
||||||
|
// region key owned by `by`, and a type) and/or `deorbit` active satellites
|
||||||
|
// (by their stable id). A launch is dropped when `by` does not resolve, when
|
||||||
|
// `regionKey` is not a valid key owned by `by`, or when `type` is not a known
|
||||||
|
// `SatelliteType`; a deorbit of an unknown id is a no-op. New satellites get
|
||||||
|
// a fresh id (`s1`, `s2`, …) and are stamped with `launchedTurn` /
|
||||||
|
// `launchedDate`.
|
||||||
|
//
|
||||||
|
// This is a DURABLE ability (unlike the turn-transient hybrid / disasters /
|
||||||
|
// migration layers): it owns the `GameState.satellites` field, and the user
|
||||||
|
// payload ALWAYS carries the current satellite list (even when empty) so the
|
||||||
|
// model knows what is in orbit and can deorbit it.
|
||||||
|
|
||||||
|
import { clampStat, deriveNationStats, isSatelliteType } from '../../shared/protocol';
|
||||||
|
import type { SpaceOps } from '../../shared/protocol';
|
||||||
|
export { SATELLITE_TYPES, isSatelliteType } from '../../shared/protocol';
|
||||||
|
import type { GameState, Nation } from '../../shared/types';
|
||||||
|
import { resolveNationRef, str } from './shared';
|
||||||
|
import type { Ability, AbilityCtx } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) The deterministic per-turn stability bonus each active
|
||||||
|
* satellite grants its owner (clamped to 0–100, mirrored to every region the
|
||||||
|
* owner holds, then the owner's derived `stats` re-derived). A nation with
|
||||||
|
* several satellites stacks the bonus (one `+1` per satellite).
|
||||||
|
*/
|
||||||
|
export const SATELLITE_STABILITY_BONUS = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) Generate a fresh, unused satellite id: the first free of
|
||||||
|
* `s1`, `s2`, .... The caller adds each id it creates to `taken` as it goes.
|
||||||
|
*/
|
||||||
|
export function freshSatelliteId(taken: ReadonlySet<string>): string {
|
||||||
|
for (let i = 1; ; i++) {
|
||||||
|
const id = `s${i}`;
|
||||||
|
if (!taken.has(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) Validate the raw `space` field of a turn into a safe
|
||||||
|
* {@link SpaceOps}. Defensive like the rest of the parser — never throws.
|
||||||
|
* Accepts the object `{ launch: [...], deorbit: [...] }`. Each launch op
|
||||||
|
* needs a non-empty `by` (capped at 200 chars), a non-empty `regionKey`
|
||||||
|
* (capped at 120 chars), and a `type` that is one of {@link SATELLITE_TYPES};
|
||||||
|
* an invalid op is dropped. Each deorbit entry needs a non-empty id (capped
|
||||||
|
* at 120 chars); deorbit ids are deduped. Resolution of `by` to a concrete
|
||||||
|
* nation and the ownership check on `regionKey` happen at apply time
|
||||||
|
* (`applySpaceControl`), not here. A missing / malformed top-level value
|
||||||
|
* yields `{ launch: [], deorbit: [] }`.
|
||||||
|
*/
|
||||||
|
export function validateSpaceOps(raw: unknown): SpaceOps {
|
||||||
|
const out: SpaceOps = { launch: [], deorbit: [] };
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (Array.isArray(o.launch)) {
|
||||||
|
const seenLaunch = new Set<string>();
|
||||||
|
for (const e of o.launch) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const p = e as Record<string, unknown>;
|
||||||
|
const by = str(p.by, 200);
|
||||||
|
const regionKey = str(p.regionKey, 120);
|
||||||
|
if (!by || !regionKey) continue;
|
||||||
|
if (!isSatelliteType(p.type)) continue;
|
||||||
|
const id = `${by}\u0000${regionKey}\u0000${p.type}`;
|
||||||
|
if (seenLaunch.has(id)) continue; // dedupe identical launch ops
|
||||||
|
seenLaunch.add(id);
|
||||||
|
out.launch.push({ by, regionKey, type: p.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(o.deorbit)) {
|
||||||
|
const seenDeorbit = new Set<string>();
|
||||||
|
for (const e of o.deorbit) {
|
||||||
|
const id = str(e, 120);
|
||||||
|
if (!id) continue; // empty / missing / non-string: drop
|
||||||
|
if (seenDeorbit.has(id)) continue; // dedupe
|
||||||
|
seenDeorbit.add(id);
|
||||||
|
out.deorbit.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) Remove the satellites whose ids are in `deorbit` from
|
||||||
|
* `next.satellites`. An absent id is a no-op.
|
||||||
|
*/
|
||||||
|
function deorbitSatellites(next: GameState, deorbit: string[]): void {
|
||||||
|
if (deorbit.length === 0) return;
|
||||||
|
const ids = new Set(deorbit);
|
||||||
|
next.satellites = (next.satellites ?? []).filter((s) => !ids.has(s.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) Launch this turn's satellites: resolve `by` (id first, then
|
||||||
|
* a case-insensitive unique name), require `regionKey` to be a valid key
|
||||||
|
* owned by `by`, and append a fresh `{ id, by, regionKey, type, launchedTurn,
|
||||||
|
* launchedDate }` per op. Unknown / ambiguous `by`, an unowned or invalid
|
||||||
|
* `regionKey`, or an unknown `type` (already filtered at validate time) drops
|
||||||
|
* the op. Never throws.
|
||||||
|
*/
|
||||||
|
function launchSatellites(next: GameState, ops: SpaceOps['launch'], ctx: AbilityCtx): void {
|
||||||
|
const validKeys = ctx.validRegionKeys;
|
||||||
|
const taken = new Set((next.satellites ?? []).map((s) => s.id));
|
||||||
|
for (const op of ops) {
|
||||||
|
const by = resolveNationRef(next, op.by);
|
||||||
|
if (!by) continue; // unresolvable launcher: drop
|
||||||
|
const key = op.regionKey;
|
||||||
|
if (validKeys && !validKeys.has(key)) continue; // not a valid region key: drop
|
||||||
|
const owner = next.nations[by];
|
||||||
|
if (!owner || !owner.territory.includes(key)) continue; // by does not own it: drop
|
||||||
|
next.satellites = (next.satellites ?? []).concat({
|
||||||
|
id: freshSatelliteId(taken),
|
||||||
|
by,
|
||||||
|
regionKey: key,
|
||||||
|
type: op.type,
|
||||||
|
launchedTurn: ctx.actingTurn,
|
||||||
|
launchedDate: next.date,
|
||||||
|
});
|
||||||
|
taken.add(next.satellites[next.satellites.length - 1].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) The per-turn satellite bonus, applied EVERY committed turn
|
||||||
|
* (not just to this turn's launches): each active satellite grants its owner
|
||||||
|
* +`SATELLITE_STABILITY_BONUS` stability (clamped to 0–100), mirrored to every
|
||||||
|
* region the owner holds (`regionStats`), then the owner's derived `stats` are
|
||||||
|
* re-derived (the same 0-region / no-entry guards as the economy / migration
|
||||||
|
* layers — a 0-region nation keeps its directly-mutated value). A nation with
|
||||||
|
* several satellites stacks the bonus (one `+1` per satellite). A claim
|
||||||
|
* whose nation no longer exists (removed by a merge / split this turn) is
|
||||||
|
* skipped. Never throws.
|
||||||
|
*/
|
||||||
|
export function applySatelliteBonuses(next: GameState, geoOf?: (key: string) => { area: number; population: number } | undefined): void {
|
||||||
|
const satellites = next.satellites ?? [];
|
||||||
|
if (satellites.length === 0) return;
|
||||||
|
|
||||||
|
// nation id -> number of satellites it owns (stacks the bonus).
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const s of satellites) {
|
||||||
|
counts.set(s.by, (counts.get(s.by) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyNation = (n: Nation, count: number): void => {
|
||||||
|
n.stats.stability = clampStat(n.stats.stability + count * SATELLITE_STABILITY_BONUS);
|
||||||
|
for (const key of n.territory) {
|
||||||
|
const rs = next.regionStats[key];
|
||||||
|
if (!rs) continue;
|
||||||
|
rs.stability = clampStat(rs.stability + count * SATELLITE_STABILITY_BONUS);
|
||||||
|
}
|
||||||
|
// Re-derive so the effect persists (the main recompute ran earlier this
|
||||||
|
// turn). A nation with no regionStats entry keeps its directly-mutated value.
|
||||||
|
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||||
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [id, count] of counts) {
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue; // nation removed this turn: bonus dropped (satellite kept)
|
||||||
|
applyNation(n, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) Apply the turn's space 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
|
||||||
|
* ownership it inspects is final for this turn.
|
||||||
|
*
|
||||||
|
* (1) DEORBIT first: remove satellites by id (absent id = no-op).
|
||||||
|
* (2) LAUNCH: for each launch op, resolve `by`, require `regionKey` to be a
|
||||||
|
* valid key owned by `by`, and append a fresh satellite stamped with
|
||||||
|
* `launchedTurn` / `launchedDate`.
|
||||||
|
* (3) PER-TURN BONUS: every active satellite grants its owner +1 stability
|
||||||
|
* (clamped, mirrored to its regions, re-derived) — one per satellite.
|
||||||
|
*
|
||||||
|
* Sole ownership is never touched; only `satellites` and stability change.
|
||||||
|
*/
|
||||||
|
export function applySpaceControl(next: GameState, ops: SpaceOps, ctx: AbilityCtx): void {
|
||||||
|
deorbitSatellites(next, ops?.deorbit ?? []);
|
||||||
|
launchSatellites(next, ops?.launch ?? [], ctx);
|
||||||
|
applySatelliteBonuses(next, ctx.geoOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const space: Ability<'space'> = {
|
||||||
|
field: 'space',
|
||||||
|
default: () => ({ launch: [], deorbit: [] }),
|
||||||
|
validate: (raw) => validateSpaceOps(raw),
|
||||||
|
apply: (state, data, ctx) => applySpaceControl(state, data, ctx),
|
||||||
|
schemaLine: () =>
|
||||||
|
' "space": {"launch":[{"by":"<nation id or name>","regionKey":"<region key>","type":"<recon|comms|navigation|weather>"}],"deorbit":["<satellite id>"]},',
|
||||||
|
constraint: () =>
|
||||||
|
'- Space program ("space") is the orbital layer: a "launch" op ({"by":"<nation>","regionKey":"<key>","type":"<recon|comms|navigation|weather>"}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op (["<satellite id>"]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message\'s "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).',
|
||||||
|
isMeaningful: (data) => data.launch.length > 0 || data.deorbit.length > 0,
|
||||||
|
stateFields: ['satellites'],
|
||||||
|
stub: () => ({ launch: [], deorbit: [] }),
|
||||||
|
// Space is DURABLE (like sea / wars / industry): the user payload ALWAYS
|
||||||
|
// carries the current satellites (even when empty) so the model knows what
|
||||||
|
// is in orbit and can deorbit it.
|
||||||
|
payload: (ctx) => ({ satellites: ctx.state.satellites ?? [] }),
|
||||||
|
};
|
||||||
@@ -36,7 +36,9 @@ export type AbilityField =
|
|||||||
| 'disasters'
|
| 'disasters'
|
||||||
| 'migration'
|
| 'migration'
|
||||||
| 'sea'
|
| 'sea'
|
||||||
| 'contested';
|
| 'contested'
|
||||||
|
| 'alliances'
|
||||||
|
| 'space';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything a single ability's `apply` may need from the surrounding turn.
|
* Everything a single ability's `apply` may need from the surrounding turn.
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ function makeState(id: string): GameState {
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-38
@@ -137,6 +137,10 @@ function baseState(): GameState {
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +578,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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' }]);
|
||||||
@@ -596,7 +600,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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' }]);
|
||||||
@@ -617,7 +621,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -636,7 +640,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
});
|
});
|
||||||
@@ -1093,7 +1097,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
applyTurnResult(state, result);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
expect(JSON.stringify(state)).toBe(snapshot);
|
||||||
@@ -1110,7 +1114,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.pending).toEqual([]);
|
expect(next.pending).toEqual([]);
|
||||||
@@ -1133,7 +1137,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -1159,7 +1163,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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');
|
||||||
@@ -1177,7 +1181,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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', () => {
|
||||||
@@ -1267,7 +1271,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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', () => {
|
||||||
@@ -1382,7 +1386,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1689,9 +1693,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [{ 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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)', () => {
|
||||||
@@ -1704,7 +1708,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1725,7 +1729,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1746,7 +1750,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1769,7 +1773,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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');
|
||||||
@@ -1802,7 +1806,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -1826,7 +1830,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
// no nation was renamed
|
// no nation was renamed
|
||||||
@@ -1853,7 +1857,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
// neither was renamed (ambiguous)
|
// neither was renamed (ambiguous)
|
||||||
@@ -1879,7 +1883,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(validated!.takeovers).toEqual([]);
|
expect(validated!.takeovers).toEqual([]);
|
||||||
const next = applyTurnResult(state, validated!);
|
const next = applyTurnResult(state, validated!);
|
||||||
@@ -1905,7 +1909,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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');
|
||||||
@@ -1929,7 +1933,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -1951,7 +1955,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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');
|
||||||
@@ -1985,7 +1989,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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);
|
||||||
@@ -2009,13 +2013,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -2042,7 +2046,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: [] }, contested: { open: [], resolve: [] }, markers: {
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -2089,7 +2093,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: [] }, contested: { open: [], resolve: [] }, markers: {
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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' },
|
||||||
@@ -2114,7 +2118,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -2130,14 +2134,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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||||
});
|
});
|
||||||
@@ -2207,7 +2211,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||||
});
|
});
|
||||||
const { state } = await runTurn({
|
const { state } = await runTurn({
|
||||||
llm: fakeLlm([result]),
|
llm: fakeLlm([result]),
|
||||||
@@ -2286,6 +2290,10 @@ function mkTurnState(
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2311,6 +2319,8 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
|||||||
migration: partial.migration ?? { ops: [] },
|
migration: partial.migration ?? { ops: [] },
|
||||||
sea: partial.sea ?? { control: [] },
|
sea: partial.sea ?? { control: [] },
|
||||||
contested: partial.contested ?? { open: [], resolve: [] },
|
contested: partial.contested ?? { open: [], resolve: [] },
|
||||||
|
alliances: partial.alliances ?? { form: [], break: [] },
|
||||||
|
space: partial.space ?? { launch: [], deorbit: [] },
|
||||||
markers: partial.markers ?? { add: [], remove: [] },
|
markers: partial.markers ?? { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3036,7 +3046,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
|
||||||
@@ -3424,7 +3434,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -3445,7 +3455,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -3466,7 +3476,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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
|
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -528,6 +528,8 @@ export function buildUserPrompt(
|
|||||||
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)
|
Object.assign(payload, ABILITIES[11].payload(upCtx)); // contested → contested (only when non-empty)
|
||||||
|
Object.assign(payload, ABILITIES[12].payload(upCtx)); // alliances → alliances (always emitted)
|
||||||
|
Object.assign(payload, ABILITIES[13].payload(upCtx)); // space → satellites (always emitted)
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,6 +672,8 @@ export function validateTurnResult(
|
|||||||
migration: { ops: [] },
|
migration: { ops: [] },
|
||||||
sea: { control: [] },
|
sea: { control: [] },
|
||||||
contested: { open: [], resolve: [] },
|
contested: { open: [], resolve: [] },
|
||||||
|
alliances: { form: [], break: [] },
|
||||||
|
space: { launch: [], deorbit: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,10 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
// 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
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ const state: GameState = {
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ function mkState(
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
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
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
// 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);
|
||||||
|
|||||||
@@ -830,6 +830,108 @@ export interface ContestedRegion {
|
|||||||
parties: string[];
|
parties: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Alliances / mutual-defense treaties (v0.45.0, #80) ----------------------
|
||||||
|
//
|
||||||
|
// A durable, symmetric pact between two distinct nations. The LLM proposes
|
||||||
|
// `form` / `break` ops each turn; the engine applies them directly
|
||||||
|
// (`applyTurnResult` → the alliances ability) and persists the active pacts in
|
||||||
|
// `GameState.alliances`. A pair holds at most one pact (re-forming is a
|
||||||
|
// no-op), and an active war between the pair overrides the `ally` relation —
|
||||||
|
// the pact still exists but the `war` relation stays in force until the war
|
||||||
|
// ends.
|
||||||
|
|
||||||
|
/** One active alliance: which two nations are bound, and when the pact was formed. */
|
||||||
|
export interface Alliance {
|
||||||
|
/** Stable id (`al1`, `al2`, …) assigned by the engine on formation. */
|
||||||
|
id: string;
|
||||||
|
/** The two party nation ids (sorted, distinct). */
|
||||||
|
parties: string[];
|
||||||
|
/** The turn number the pact was formed on. */
|
||||||
|
formedTurn: number;
|
||||||
|
/** The in-game date the pact took effect on (`YYYY-MM-DD`). */
|
||||||
|
formedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single "form a new alliance between a and b" op. */
|
||||||
|
export interface AllianceFormOp {
|
||||||
|
/** One party (a nation id or name, resolved via `resolveNationRef`). */
|
||||||
|
a: string;
|
||||||
|
/** The other party (a nation id or name, distinct from `a`). */
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single "break the alliance between a and b" op. */
|
||||||
|
export interface AllianceBreakOp {
|
||||||
|
/** One party (a nation id or name). */
|
||||||
|
a: string;
|
||||||
|
/** The other party (a nation id or name). */
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alliances block of a `TurnResult`. Required; default `{ form: [], break: [] }`. */
|
||||||
|
export interface AllianceOps {
|
||||||
|
/** New pacts to form this turn. */
|
||||||
|
form: AllianceFormOp[];
|
||||||
|
/** Existing pacts to end this turn. */
|
||||||
|
break: AllianceBreakOp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Space program / satellites (v0.45.0, #81) -------------------------------
|
||||||
|
//
|
||||||
|
// Durable per-turn space ops: the LLM launches satellites over regions it
|
||||||
|
// owns and deorbits active ones by id. Each active satellite grants its owner
|
||||||
|
// a small deterministic +1 stability per committed turn (mirrored to its
|
||||||
|
// regions, re-derived), stacked per satellite. Launches/deorbits never move
|
||||||
|
// borders — sole ownership is untouched.
|
||||||
|
|
||||||
|
/** The kind of satellite a nation can put into orbit. */
|
||||||
|
export type SatelliteType = 'recon' | 'comms' | 'navigation' | 'weather';
|
||||||
|
|
||||||
|
/** All satellite types, in canonical order (the LLM contract + validator). */
|
||||||
|
export const SATELLITE_TYPES: SatelliteType[] = ['recon', 'comms', 'navigation', 'weather'];
|
||||||
|
|
||||||
|
/** True when `t` is one of the four known satellite types. */
|
||||||
|
export function isSatelliteType(t: unknown): t is SatelliteType {
|
||||||
|
return typeof t === 'string' && SATELLITE_TYPES.includes(t as SatelliteType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single "launch a satellite" op: `by` puts a satellite of `type` over `regionKey` (a region it owns). */
|
||||||
|
export interface SatelliteLaunchOp {
|
||||||
|
/** The launching nation (a nation id or name, resolved via `resolveNationRef`). */
|
||||||
|
by: string;
|
||||||
|
/** The region key the satellite orbits over (must be a valid key owned by `by`). */
|
||||||
|
regionKey: string;
|
||||||
|
/** The satellite's type (one of `SATELLITE_TYPES`). */
|
||||||
|
type: SatelliteType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single "deorbit a satellite" op: the satellite's stable id. */
|
||||||
|
export type SatelliteDeorbitOp = string;
|
||||||
|
|
||||||
|
/** The space block of a `TurnResult`. Required; default `{ launch: [], deorbit: [] }`. */
|
||||||
|
export interface SpaceOps {
|
||||||
|
/** New satellites to launch this turn. */
|
||||||
|
launch: SatelliteLaunchOp[];
|
||||||
|
/** Satellite ids to deorbit (remove) this turn. */
|
||||||
|
deorbit: SatelliteDeorbitOp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One active satellite: who launched it, over which region, of what type, and when. */
|
||||||
|
export interface Satellite {
|
||||||
|
/** Stable id (`s1`, `s2`, …) assigned by the engine on launch. */
|
||||||
|
id: string;
|
||||||
|
/** The launching nation's id (resolved). */
|
||||||
|
by: string;
|
||||||
|
/** The region key the satellite orbits over. */
|
||||||
|
regionKey: string;
|
||||||
|
/** The satellite's type (one of `SATELLITE_TYPES`). */
|
||||||
|
type: SatelliteType;
|
||||||
|
/** The turn number the satellite was launched on. */
|
||||||
|
launchedTurn: number;
|
||||||
|
/** The in-game date the satellite was launched on (`YYYY-MM-DD`). */
|
||||||
|
launchedDate: 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;
|
||||||
@@ -1052,6 +1154,27 @@ export interface TurnResult {
|
|||||||
* default `{ open: [], resolve: [] }`.
|
* default `{ open: [], resolve: [] }`.
|
||||||
*/
|
*/
|
||||||
contested: ContestedOps;
|
contested: ContestedOps;
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #80) Alliances / mutual-defense treaties this turn: `form`
|
||||||
|
* declares a new pact between two nations (a symmetric alliance entry plus
|
||||||
|
* an `ally` relation, unless they are at war — the pact still forms but the
|
||||||
|
* `war` relation keeps overriding); `break` ends an existing pact and sets
|
||||||
|
* the pair back to `neutral` (unless an active war still binds them). A pair
|
||||||
|
* holds at most one pact (re-forming is a no-op). Required;
|
||||||
|
* default `{ form: [], break: [] }`.
|
||||||
|
*/
|
||||||
|
alliances: AllianceOps;
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) Space program / satellites this turn: `launch` puts a new
|
||||||
|
* satellite (one of the four `SatelliteType`s) into orbit over a region the
|
||||||
|
* acting nation owns, stamped with the turn and date; `deorbit` removes an
|
||||||
|
* active satellite by its stable id. Every satellite its owner holds grants
|
||||||
|
* the owner a small deterministic +1 stability per committed turn (mirrored
|
||||||
|
* to its regions, re-derived), so a nation with several satellites stacks
|
||||||
|
* the bonus. Launches/deorbits never move borders — sole ownership is
|
||||||
|
* untouched. Required; default `{ launch: [], deorbit: [] }`.
|
||||||
|
*/
|
||||||
|
space: SpaceOps;
|
||||||
/**
|
/**
|
||||||
* (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
|
||||||
|
|||||||
@@ -465,6 +465,10 @@ export function worldDraftToGameState(
|
|||||||
seaZones: [],
|
seaZones: [],
|
||||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||||
contested: [],
|
contested: [],
|
||||||
|
// (v0.45.0, #80) no alliances on a fresh state.
|
||||||
|
alliances: [],
|
||||||
|
// (v0.45.0, #81) no satellites on a fresh state.
|
||||||
|
satellites: [],
|
||||||
};
|
};
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -3,7 +3,12 @@
|
|||||||
// (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, ContestedRegion } from './protocol';
|
import type {
|
||||||
|
Alliance,
|
||||||
|
NationOp,
|
||||||
|
ContestedRegion,
|
||||||
|
Satellite,
|
||||||
|
} 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 {
|
||||||
@@ -486,6 +491,29 @@ export interface GameState {
|
|||||||
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
|
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
|
||||||
*/
|
*/
|
||||||
contested: ContestedRegion[];
|
contested: ContestedRegion[];
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #80) Active alliances / mutual-defense treaties (see {@link
|
||||||
|
* Alliance}): one entry per pact — which two nations are bound and when the
|
||||||
|
* pact was formed. Declared/ended by a turn's `alliances.form` /
|
||||||
|
* `alliances.break` ops (a `form` op also sets the pair's relation to
|
||||||
|
* `ally` unless an active war overrides it; a `break` op sets it back to
|
||||||
|
* `neutral` unless an active war still binds them). A pair holds at most one
|
||||||
|
* pact (re-forming is a no-op). Always `[]` on a fresh game; rows saved
|
||||||
|
* before v0.45 load with this defaulted to `[]` (see server/db.ts) —
|
||||||
|
* additive, no data wipe.
|
||||||
|
*/
|
||||||
|
alliances: Alliance[];
|
||||||
|
/**
|
||||||
|
* (v0.45.0, #81) Active satellites (see {@link Satellite}): one entry per
|
||||||
|
* satellite in orbit — which nation launched it, over which region, of what
|
||||||
|
* type, and when. Declared/deorbited by a turn's `space.launch` /
|
||||||
|
* `space.deorbit` ops (a launch requires `by` to own `regionKey`; each
|
||||||
|
* active satellite grants its owner +1 stability per committed turn,
|
||||||
|
* mirrored to its regions and re-derived). Always `[]` on a fresh game;
|
||||||
|
* rows saved before v0.45 load with this defaulted to `[]` (see
|
||||||
|
* server/db.ts) — additive, no data wipe.
|
||||||
|
*/
|
||||||
|
satellites: Satellite[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user