feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
CI / build + unit + e2e (pull_request) Successful in 1m55s

Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.

- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
  DisastersOps, and `TurnResult.disasters` (between `ideology` and
  `markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
  schemaLine / constraint / isMeaningful / inert stub / empty payload; no
  stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
  dip table (population never touched). Target resolution mirrors hybrid
  (a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
  `from` field, so there is no self-target drop. Dips clamp to 0-100,
  mirror to the target's regions (regionStats), and re-derive the target
  nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
  regenerated (one schema line + one constraint bullet each); the
  user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
  updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
  the Layout tree + a "how to add a new ability" bullet + a disasters
  bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
  `as unknown` casts into the top-level ./abilities import, and de-dup
  `str` (now imported from ./abilities/shared).

Version bumped 0.40.0 -> 0.41.0.
This commit is contained in:
2026-10-16 09:15:55 +00:00
parent df52261835
commit c90e9ae753
14 changed files with 984 additions and 116 deletions
+445
View File
@@ -0,0 +1,445 @@
// Unit tests for the DISASTERS & PANDEMICS ability (v0.41.0, #63) — the
// modular, turn-transient LLM-ability that applies a deterministic
// (type, severity) stat dip to a nation or a single region. 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 disaster, so the apply path
// is exercised here, not by the e2e suite.
import { describe, expect, it } from 'vitest';
import type { AbilityCtx } from './types';
import {
DISASTER_EFFECTS,
DISASTER_SEVERITIES,
DISASTER_TYPES,
applyDisasterEvent,
disasters,
isDisasterSeverity,
isDisasterType,
validateDisasterEvents,
} from './disasters';
import type { DisasterEvent, DisastersOps } from '../../shared/protocol';
import type { GameState, Nation, NationStats, RegionStats } from '../../shared/types';
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
/** Build a NationStats from the four canonical indices (military, economy, stability, population). */
function NS(military: number, economy: number, stability: number, population: number): NationStats {
return { military, economy, stability, population };
}
/** Build a RegionStats from the four canonical indices. */
function RS(military: number, economy: number, stability: number, population: number): RegionStats {
return { military, economy, stability, population };
}
/** A minimal nation: the given id/name/stats/region-key territory, neutral relations. */
function mkNation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
return { id, name, type: 'major', stats, territory, relations: {} };
}
/**
* A minimal GameState carrying the given nations (keyed by id) and per-region
* stats. Every other required field is at its fresh-game baseline, so a test
* can focus purely on the disaster dip.
*/
function mkState(
nations: Record<string, Nation>,
regionStats: Record<string, RegionStats>,
): GameState {
return {
id: 'g1',
presetId: 'test',
presetTitle: 'Test',
playerNationId: Object.keys(nations)[0] ?? 'uk',
turn: 1,
date: '1939-09-01',
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
regionStats,
wars: [],
trades: [],
embargoes: [],
industry: [],
};
}
/** A minimal AbilityCtx for the ability's `apply` (no manifests loaded). */
function mkCtx(state: GameState, validRegionKeys?: ReadonlySet<string>): AbilityCtx {
return {
previous: state,
actingTurn: state.turn,
...(validRegionKeys ? { validRegionKeys } : {}),
};
}
// ---------------------------------------------------------------------------
// Type / severity constants + predicates
// ---------------------------------------------------------------------------
describe('DISASTER_TYPES / DISASTER_SEVERITIES', () => {
it('exposes exactly the seven types and three severities', () => {
expect([...DISASTER_TYPES]).toEqual([
'earthquake',
'flood',
'volcanic',
'wildfire',
'famine',
'plague',
'epidemic',
]);
expect([...DISASTER_SEVERITIES]).toEqual(['minor', 'moderate', 'severe']);
});
it('every (type, severity) pair dips at least one stat, never population', () => {
for (const t of DISASTER_TYPES) {
for (const s of DISASTER_SEVERITIES) {
const dips = DISASTER_EFFECTS[t][s];
expect(dips).toBeDefined();
const keys = Object.keys(dips);
expect(keys.length).toBeGreaterThan(0);
expect(keys).not.toContain('population');
for (const k of keys) expect(dips[k as keyof typeof dips]).toBeLessThan(0);
}
}
});
});
describe('isDisasterType / isDisasterSeverity', () => {
it('accepts each known type and rejects unknown / non-strings', () => {
for (const t of DISASTER_TYPES) expect(isDisasterType(t)).toBe(true);
expect(isDisasterType('tsunami')).toBe(false);
expect(isDisasterType('Earthquake')).toBe(false); // case-sensitive
expect(isDisasterType('')).toBe(false);
expect(isDisasterType(42)).toBe(false);
expect(isDisasterType(null)).toBe(false);
expect(isDisasterType(undefined)).toBe(false);
});
it('accepts each known severity and rejects unknown / non-strings', () => {
for (const s of DISASTER_SEVERITIES) expect(isDisasterSeverity(s)).toBe(true);
expect(isDisasterSeverity('extreme')).toBe(false);
expect(isDisasterSeverity('Minor')).toBe(false); // case-sensitive
expect(isDisasterSeverity('')).toBe(false);
expect(isDisasterSeverity(3)).toBe(false);
expect(isDisasterSeverity(undefined)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// validateDisasterEvents
// ---------------------------------------------------------------------------
describe('validateDisasterEvents', () => {
const valid: DisasterEvent = { type: 'earthquake', target: 'uk', severity: 'severe' };
it('accepts the { events: [...] } shape', () => {
expect(validateDisasterEvents({ events: [valid] })).toEqual([valid]);
});
it('accepts a bare array (lenient about the wrapper)', () => {
expect(validateDisasterEvents([valid])).toEqual([valid]);
});
it('drops an event with an unknown type, keeping the valid one', () => {
const out = validateDisasterEvents({
events: [
{ type: 'tsunami', target: 'uk', severity: 'minor' },
valid,
],
});
expect(out).toEqual([valid]);
});
it('drops an event with a missing type', () => {
const out = validateDisasterEvents({
events: [{ target: 'uk', severity: 'minor' }, valid],
});
expect(out).toEqual([valid]);
});
it('drops an event with an unknown severity, keeping the valid one', () => {
const out = validateDisasterEvents({
events: [
{ type: 'plague', target: 'uk', severity: 'extreme' },
valid,
],
});
expect(out).toEqual([valid]);
});
it('drops an event with a missing severity', () => {
const out = validateDisasterEvents({
events: [{ type: 'plague', target: 'uk' }, valid],
});
expect(out).toEqual([valid]);
});
it('drops an event with an empty / whitespace target', () => {
const out = validateDisasterEvents({
events: [
{ type: 'plague', target: ' ', severity: 'minor' },
{ type: 'plague', target: '', severity: 'minor' },
valid,
],
});
expect(out).toEqual([valid]);
});
it('caps a non-empty detail at 300 chars and keeps a short one', () => {
const long = 'x'.repeat(500);
const out = validateDisasterEvents({
events: [
{ type: 'plague', target: 'uk', severity: 'minor', detail: long },
{ type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' },
],
});
expect(out).toEqual([
{ type: 'plague', target: 'uk', severity: 'minor', detail: 'x'.repeat(300) },
{ type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' },
]);
});
it('omits the detail key entirely when detail is empty / missing', () => {
const out = validateDisasterEvents({
events: [
{ type: 'flood', target: 'uk', severity: 'minor', detail: '' },
{ type: 'flood', target: 'de', severity: 'minor' },
],
});
expect(out).toEqual([
{ type: 'flood', target: 'uk', severity: 'minor' },
{ type: 'flood', target: 'de', severity: 'minor' },
]);
expect(out[0]).not.toHaveProperty('detail');
});
it('drops non-object event entries', () => {
const out = validateDisasterEvents({ events: ['not-an-object', 7, null, valid] });
expect(out).toEqual([valid]);
});
it('yields { events: [] } (here, []) for a missing / malformed top-level value', () => {
expect(validateDisasterEvents(undefined)).toEqual([]);
expect(validateDisasterEvents(null)).toEqual([]);
expect(validateDisasterEvents('nope')).toEqual([]);
expect(validateDisasterEvents(5)).toEqual([]);
expect(validateDisasterEvents({})).toEqual([]);
expect(validateDisasterEvents({ events: 'not-an-array' })).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// applyDisasterEvent — the deterministic dip
// ---------------------------------------------------------------------------
describe('applyDisasterEvent', () => {
it('a nation target: dips the nation AND every region it owns, then re-derives (identical values pass through)', () => {
// uk owns three identical regions; all stats identical → the re-derived
// aggregate equals the dipped values exactly.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2', 'g3']) },
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50), g3: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' });
// earthquake moderate = stability -4, economy -4 (military / population untouched)
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
for (const k of ['g1', 'g2', 'g3']) expect(state.regionStats[k]).toEqual(RS(50, 46, 46, 50));
});
it('a region target: dips ONLY the named region (the sibling region is untouched)', () => {
// uk owns g1 and g2 (identical). Target the region key g1 → only g1 dips.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) },
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'wildfire', target: 'g1', severity: 'severe' });
// wildfire severe = economy -7 only
expect(state.regionStats.g1).toEqual(RS(50, 43, 50, 50));
expect(state.regionStats.g2).toEqual(RS(50, 50, 50, 50)); // untouched
// re-derive (equal weights, min-biased median) → the nation takes the dipped value
expect(state.nations.uk.stats).toEqual(NS(50, 43, 50, 50));
});
it('a region target resolved by a validKeys-gated key hits its sole owner', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(
state,
{ type: 'famine', target: 'g1', severity: 'moderate' },
undefined,
new Set(['g1']),
);
// famine moderate = economy -4, stability -4
expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50));
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
});
it('clamps every dip at 0 (never negative)', () => {
// low stability + military; a severe plague (stability -9, military -6) floors both.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(3, 50, 3, 50), ['g1']) },
{ g1: RS(3, 50, 3, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'uk', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(0, 50, 0, 50));
expect(state.regionStats.g1).toEqual(RS(0, 50, 0, 50));
});
it('a 0-owner region is untargetable (the event is dropped, nothing dips)', () => {
// 'x1' is owned by nobody; no validKeys supplied, so the owners check drops it.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'x1', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched
expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50));
});
it('a region with 2+ owners is untargetable (the event is dropped, neither dips)', () => {
// corrupt double-ownership of 'y1'
const state = mkState(
{
uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['y1']),
de: mkNation('de', 'DE', NS(60, 60, 60, 60), ['y1']),
},
{ y1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'y1', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched
expect(state.nations.de.stats).toEqual(NS(60, 60, 60, 60)); // untouched
});
it('an unknown region key (gated by validKeys) is dropped', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
// 'zzz' is not in the manifest → dropped before the owners check.
applyDisasterEvent(state, { type: 'plague', target: 'zzz', severity: 'severe' }, undefined, new Set(['g1']));
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50));
});
it('an unknown nation reference is dropped (no throw, no dip)', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'ghost', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50));
expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50));
});
it('an ambiguous nation reference (two share the name) is dropped', () => {
const state = mkState(
{
a: mkNation('a', 'Same', NS(50, 50, 50, 50), ['g1']),
b: mkNation('b', 'Same', NS(70, 70, 70, 70), ['d1']),
},
{ g1: RS(50, 50, 50, 50), d1: RS(70, 70, 70, 70) },
);
applyDisasterEvent(state, { type: 'plague', target: 'Same', severity: 'severe' });
expect(state.nations.a.stats).toEqual(NS(50, 50, 50, 50)); // untouched
expect(state.nations.b.stats).toEqual(NS(70, 70, 70, 70)); // untouched
});
it('resolves a nation by (case-insensitive) name, not just id', () => {
const state = mkState(
{ uk: mkNation('uk', 'United Kingdom', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'epidemic', target: 'united kingdom', severity: 'moderate' });
// epidemic moderate = stability -4, military -2
expect(state.nations.uk.stats).toEqual(NS(48, 50, 46, 50));
expect(state.regionStats.g1).toEqual(RS(48, 50, 46, 50));
});
it('a 0-region nation keeps its directly-dipped stats (no re-derive to zero)', () => {
const iso = mkNation('iso', 'Iso', NS(50, 50, 50, 50), []);
const state = mkState({ iso }, {});
applyDisasterEvent(state, { type: 'epidemic', target: 'iso', severity: 'moderate' });
// epidemic moderate = stability -4, military -2 — the direct dip persists
expect(state.nations.iso.stats).toEqual(NS(48, 50, 46, 50));
expect(state.regionStats).toEqual({});
});
it('a region with no regionStats entry is skipped (the nation still dips + re-derives from its other regions)', () => {
// uk owns g1 (has stats) and g2 (no entry). Target the nation → the dip
// mirrors to g1 only (g2 has no entry to dip); re-derive uses g1 alone.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' });
expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50));
expect(state.regionStats).not.toHaveProperty('g2'); // never created
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
});
});
// ---------------------------------------------------------------------------
// The Ability object
// ---------------------------------------------------------------------------
describe('disasters (Ability)', () => {
it('owns the "disasters" field and declares NO durable state fields (turn-transient)', () => {
expect(disasters.field).toBe('disasters');
expect(disasters.stateFields).toBeUndefined();
});
it('default() and stub() are fresh, empty { events: [] } values', () => {
expect(disasters.default()).toEqual({ events: [] });
expect(disasters.stub()).toEqual({ events: [] });
// fresh on every call (never a shared mutable reference)
expect(disasters.default()).not.toBe(disasters.default());
expect(disasters.default().events).not.toBe(disasters.default().events);
expect(disasters.stub()).not.toBe(disasters.stub());
});
it('isMeaningful is true iff there is at least one event', () => {
expect(disasters.isMeaningful({ events: [] })).toBe(false);
expect(disasters.isMeaningful({ events: [{ type: 'plague', target: 'uk', severity: 'minor' }] })).toBe(
true,
);
});
it('payload() is always empty (the layer is turn-transient)', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
expect(disasters.payload({ state })).toEqual({});
});
it('validate() wraps the low-level validator into the { events } field shape', () => {
const valid = { type: 'flood', target: 'uk', severity: 'minor' } as DisasterEvent;
expect(disasters.validate({ events: [valid] })).toEqual({ events: [valid] });
expect(disasters.validate(undefined)).toEqual({ events: [] });
expect(disasters.validate([valid])).toEqual({ events: [valid] }); // bare array leniency
});
it('apply() wires ctx.geoOf + ctx.validRegionKeys through to the dip', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
const ctx = mkCtx(state, new Set(['g1']));
const data: DisastersOps = { events: [{ type: 'volcanic', target: 'g1', severity: 'moderate' }] };
disasters.apply(state, data, ctx);
// volcanic moderate = economy -4, stability -3
expect(state.regionStats.g1).toEqual(RS(50, 46, 47, 50));
expect(state.nations.uk.stats).toEqual(NS(50, 46, 47, 50));
});
});
+259
View File
@@ -0,0 +1,259 @@
// The DISASTERS & PANDEMICS ability (v0.41.0, #63) as a modular LLM-ability
// (v0.40.0, #67). Natural and man-made catastrophes — earthquakes, floods,
// volcanic eruptions, wildfires, famines, plagues, and epidemics — hit a
// nation or a single region each turn. Unlike most other abilities this one
// is **turn-transient**: it owns NO `GameState` field (no durable list, no
// `db.ts` change) — exactly like the hybrid layer. Each event carries a
// `type` and a `severity`, and the engine applies a DETERMINISTIC stat dip
// chosen from the `(type, severity)` lookup table below (no hidden
// randomness — the LLM chooses the type and severity, the table decides the
// magnitude). The dip lands on the target nation's `stats` (clamped 0–100)
// AND on the target's regions (`regionStats` — the single named region when
// the target was a region key, otherwise every region the target owns), and
// the target's derived `stats` are re-derived afterwards (the same 0-region /
// no-entry guards as hybrid / economy / industry, so a 0-region nation keeps
// its directly-mutated value and the dip persists to the next turn). The
// target is a nation (resolved by the shared `resolveNationRef`) or a region
// key (which hits its SOLE owner); a key with 0 or 2+ owners is untargetable.
// There is no acting-nation (`from`) field on a disaster — a catastrophe is
// not an attack, so the hybrid's self-target drop does not apply. Malformed
// events and unresolvable targets are dropped, never thrown.
import { clampStat, deriveNationStats } from '../../shared/protocol';
import type {
DisasterEvent,
DisasterSeverity,
DisasterType,
} from '../../shared/protocol';
import type { GameState, Nation, NationStats, StatKey } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/** The seven disaster types a turn can declare (v0.41.0, #63). */
export const DISASTER_TYPES: readonly DisasterType[] = [
'earthquake',
'flood',
'volcanic',
'wildfire',
'famine',
'plague',
'epidemic',
];
/** The three severity levels a disaster event can carry (v0.41.0, #63). */
export const DISASTER_SEVERITIES: readonly DisasterSeverity[] = ['minor', 'moderate', 'severe'];
const VALID_DISASTER_TYPES = new Set<string>(DISASTER_TYPES);
const VALID_SEVERITIES = new Set<string>(DISASTER_SEVERITIES);
/** True when `t` is one of the seven known disaster types (v0.41.0, #63). */
export function isDisasterType(t: unknown): t is DisasterType {
return typeof t === 'string' && VALID_DISASTER_TYPES.has(t);
}
/** True when `s` is one of the three known disaster severities (v0.41.0, #63). */
export function isDisasterSeverity(s: unknown): s is DisasterSeverity {
return typeof s === 'string' && VALID_SEVERITIES.has(s);
}
/**
* (v0.41.0, #63) The DETERMINISTIC per-`(type, severity)` stat dip of a
* disaster event. Each pair maps to a small set of signed (negative) stat
* deltas: no hidden randomness — the LLM picks the type and severity, the
* table decides the magnitude. Population is never touched (a disaster hurts
* military / economy / stability, never the head-count). Every (type,
* severity) combination dips at least one stat (none is purely
* informational).
*/
export const DISASTER_EFFECTS: Record<DisasterType, Record<DisasterSeverity, Partial<Record<StatKey, number>>>> = {
earthquake: {
minor: { stability: -2, economy: -2 },
moderate: { stability: -4, economy: -4 },
severe: { stability: -7, economy: -7 },
},
flood: {
minor: { economy: -2, stability: -1 },
moderate: { economy: -3, stability: -2 },
severe: { economy: -6, stability: -4 },
},
volcanic: {
minor: { economy: -2, stability: -2 },
moderate: { economy: -4, stability: -3 },
severe: { economy: -7, stability: -6 },
},
wildfire: {
minor: { economy: -2 },
moderate: { economy: -4 },
severe: { economy: -7 },
},
famine: {
minor: { economy: -2, stability: -2 },
moderate: { economy: -4, stability: -4 },
severe: { economy: -7, stability: -7 },
},
plague: {
minor: { stability: -3, military: -2 },
moderate: { stability: -5, military: -3 },
severe: { stability: -9, military: -6 },
},
epidemic: {
minor: { stability: -2, military: -1 },
moderate: { stability: -4, military: -2 },
severe: { stability: -7, military: -4 },
},
};
/**
* (v0.41.0, #63) Validate the raw `disasters` field of a turn into a safe
* list of {@link DisasterEvent}s. Defensive like the rest of the parser — never throws.
* The raw value may be the object `{ events: [...] }` (the TurnResult shape)
* or a bare array (be lenient about either). Each event needs a known `type`
* (one of {@link DISASTER_TYPES}), a known `severity` (one of
* {@link DISASTER_SEVERITIES}), and a non-empty `target` (a nation id/name or
* a region key, capped at 200 chars); a non-empty `detail` is capped at 300
* chars. An event with an unknown / missing `type`, an unknown / missing
* `severity`, or an empty `target` is dropped. Resolution of `target` to a
* concrete nation (or a region's sole owner) happens at apply time
* (`applyDisasterEvent`), not here. A missing / malformed top-level value
* yields `{ events: [] }`.
*/
export function validateDisasterEvents(raw: unknown): DisasterEvent[] {
let list: unknown[] | null = null;
if (Array.isArray(raw)) {
list = raw;
} else if (raw && typeof raw === 'object') {
const inner = (raw as Record<string, unknown>).events;
if (Array.isArray(inner)) list = inner;
}
if (!list) return [];
const out: DisasterEvent[] = [];
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
if (!isDisasterType(o.type)) continue; // unknown / missing type: drop
if (!isDisasterSeverity(o.severity)) continue; // unknown / missing severity: drop
const target = str(o.target, 200);
if (!target) continue; // empty / missing target: drop
const detail = str(o.detail, 300);
out.push({
type: o.type,
target,
severity: o.severity,
...(detail ? { detail } : {}),
});
}
return out;
}
/**
* (v0.41.0, #63) Resolve a disaster event's `target` (a nation id/name OR a
* region key) to the concrete nation it affects. A nation ref resolves via
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
* with zero or 2+ owners can't be a valid target). Returns the affected
* nation plus the specific region key (when the target was a region) so the
* dip can land on just that region's entry. `validKeys` (when supplied)
* gates the region-key path; a ref that is neither a known nation nor a
* valid region key returns `null`. Pure; never throws.
*
* NOTE: there is no acting-nation (`from`) field on a disaster — a
* catastrophe is not an attack, so (unlike the hybrid layer) a self-target
* drop does NOT apply: an earthquake can hit the nation it is declared
* against.
*/
function resolveDisasterTarget(
state: GameState,
target: string,
validKeys?: ReadonlySet<string>,
): { nation: Nation; regionKey?: string } | null {
const id = resolveNationRef(state, target);
if (id !== null) {
const n = state.nations[id];
return n ? { nation: n } : null;
}
const key = (target ?? '').trim();
if (!key) return null;
if (validKeys && !validKeys.has(key)) return null; // unknown region key
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
return { nation: owners[0], regionKey: key };
}
/** Apply each signed dip in `dips` to a stats record in place (clamped). */
function applyDips(stats: NationStats, dips: Partial<Record<StatKey, number>>): void {
(Object.keys(dips) as StatKey[]).forEach((k) => {
const d = dips[k];
if (typeof d === 'number') stats[k] = clampStat(stats[k] + d);
});
}
/**
* (v0.41.0, #63) Apply ONE disaster event to the (cloned) `next` state,
* mutating it in place and never throwing. Resolution order:
* 1. `target` must resolve to a concrete nation (a nation ref, or a region
* key's sole owner); an unresolvable target (unknown / ambiguous ref, a
* region with 0 or 2+ owners, or an unknown region key) drops the event.
* Then the deterministic `(type, severity)` dip from {@link DISASTER_EFFECTS}
* is applied:
* - the target nation's `stats` is dipped on each affected stat (clamped
* 0–100);
* - the dip is mirrored to the target's regions — the single named region
* when the target was a region key, otherwise every region the target
* owns (regions with no `regionStats` entry are skipped);
* - the target nation's derived `stats` are re-derived afterwards (the
* same 0-region / no-entry guards as the main recompute, so a 0-region
* nation keeps its directly-mutated value and a dip persists).
* A disaster never moves territory (sole ownership is untouched) and never
* touches `population`.
*/
export function applyDisasterEvent(
next: GameState,
ev: DisasterEvent,
geoOf?: (key: string) => { area: number; population: number } | undefined,
validKeys?: ReadonlySet<string>,
): void {
const target = resolveDisasterTarget(next, ev.target, validKeys);
if (target === null) return; // unresolvable target: drop
const dips = DISASTER_EFFECTS[ev.type][ev.severity];
if (!dips) return; // defensive: unknown (type, severity) pair
applyDips(target.nation.stats, dips);
if (target.regionKey !== undefined) {
const rs = next.regionStats[target.regionKey];
if (rs) applyDips(rs, dips);
} else {
for (const key of target.nation.territory) {
const rs = next.regionStats[key];
if (!rs) continue; // a key with no entry is skipped
applyDips(rs, dips);
}
}
// Re-derive the target nation's aggregate stats so the dip persists (the
// main recompute ran earlier this turn; without this the dip would be
// washed out next turn). Same 0-region / no-entry guards as hybrid /
// economy: a nation with no region stats entry keeps its directly-mutated
// `stats`.
if (target.nation.territory.length > 0 && target.nation.territory.some((k) => next.regionStats[k])) {
target.nation.stats = deriveNationStats(target.nation.territory, next.regionStats, geoOf);
}
}
export const disasters: Ability<'disasters'> = {
field: 'disasters',
default: () => ({ events: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ events }` object (mirrors the historical TurnResult shape).
validate: (raw) => ({ events: validateDisasterEvents(raw) }),
apply: (state, data, ctx) => {
for (const ev of data.events) {
applyDisasterEvent(state, ev, ctx.geoOf, ctx.validRegionKeys);
}
},
schemaLine: () =>
' "disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},',
constraint: () =>
'- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region\'s SOLE owner — a region with 0 or 2+ owners can\'t be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).',
isMeaningful: (data) => data.events.length > 0,
stub: () => ({ events: [] }),
// The layer is turn-transient — it contributes nothing to the user payload
// (no durable state to show the model), so the payload is always empty.
payload: () => ({}),
};
@@ -39,6 +39,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
"nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -64,6 +65,7 @@ HARD CONSTRAINTS
- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none).
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
- 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 every stat value between 0 and 100.
+2
View File
@@ -39,6 +39,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that
"nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -64,6 +65,7 @@ HARD CONSTRAINTS
- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none).
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
- 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 every stat value between 0 and 100.
+17 -3
View File
@@ -1,8 +1,8 @@
// The ordered LLM-ability registry.
//
// `ABILITIES` is the single source of truth for the 8 per-feature LLM abilities.
// `ABILITIES` is the single source of truth for the 9 per-feature LLM abilities.
// The engine iterates it in this order:
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters
// for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability
// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt,
// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check,
@@ -22,10 +22,11 @@ import { industry } from './industry';
import { nuclear } from './nuclear';
import { leaders } from './leaders';
import { ideology } from './ideology';
import { disasters } from './disasters';
import type { Ability, AbilityField } from './types';
/**
* The 8 per-feature LLM abilities in their canonical apply order.
* The 9 per-feature LLM abilities in their canonical apply order.
* The order matters: it determines the order of the schema lines in the
* system prompt, the order of the validate calls, the order of the apply
* steps, and the order of the payload fields in the user prompt.
@@ -39,6 +40,7 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
nuclear,
leaders,
ideology,
disasters,
];
// ── type re-exports ────────────────────────────────────────────────────────────
@@ -125,3 +127,15 @@ export {
export {
validateIdeologyOps,
} from './ideology';
// ── disasters ────────────────────────────────────────────────────────────────
export {
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
} from './disasters';
+35 -13
View File
@@ -3,13 +3,16 @@
// These tests pin the refactor's two load-bearing guarantees:
//
// 1. GOLDEN PROMPTS — the system prompt (both tool variants) and the user
// prompt the refactored engine builds are BYTE-IDENTICAL to the committed
// golden fixtures in ./goldens/ (captured in P1 from the pre-refactor
// engine). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here.
// prompt the engine builds are BYTE-IDENTICAL to the committed golden
// fixtures in ./goldens/ (captured in P1 from the pre-refactor engine, and
// re-baselined when a feature INTENTIONALLY changes the prompt — e.g. the
// system goldens were regenerated in v0.41.0 when the disasters ability
// was added). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here. The USER prompt golden is unchanged
// by the disasters feature (its payload is always empty).
//
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 8 abilities; each ability's default() equals the canonical
// list of the 9 abilities; each ability's default() equals the canonical
// TurnResult default for its field, is a FRESH value (never a shared
// reference), its default is not "meaningful" while a populated value is,
// and the registry composes (a hypothetical extra ability can be appended
@@ -26,13 +29,19 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
activeWarPairs,
applyDisasterEvent,
applyIndustryEffects,
atWar,
buildSystemPrompt,
buildUserPrompt,
DISASTER_EFFECTS,
DISASTER_SEVERITIES,
DISASTER_TYPES,
freshNationId,
freshWarId,
HYBRID_STAT_EFFECT,
isDisasterSeverity,
isDisasterType,
isHybridOpType,
isIndustryType,
NEW_NATION_STATS,
@@ -40,6 +49,7 @@ import {
resolveRegionKey,
shiftRelationTowardHostile,
syncWarRelations,
validateDisasterEvents,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
@@ -206,6 +216,7 @@ function fullDefaultTurn(): TurnResult {
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
markers: { add: [], remove: [] },
};
}
@@ -229,11 +240,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
return { changes: [{ target: 'a1', name: 'New Alpha Leader', cause: 'appointed' }] };
case 'ideology':
return { changes: [{ target: 'a1', to: 'democracy' }] };
case 'disasters':
return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 8-ability order (takeovers → ideology)', () => {
it('is the canonical 9-ability order (takeovers → disasters)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
@@ -243,13 +256,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'nuclear',
'leaders',
'ideology',
'disasters',
]);
});
it('lists 8 distinct fields, each a key of TurnResult', () => {
it('lists 9 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(8);
expect(new Set(fields).size).toBe(8);
expect(fields).toHaveLength(9);
expect(new Set(fields).size).toBe(9);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
@@ -314,7 +328,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
// The registry is a plain ordered array the engine iterates uniformly. A
// structural (field-name-agnostic) loop over the abilities — schema line,
// constraint bullet — must keep working when a 9th ability is appended.
// constraint bullet — must keep working when a 10th ability is appended.
// This documents the extension point without widening the closed
// AbilityField union.
interface Loopable {
@@ -333,9 +347,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'custom',
]);
// every uniform loop runs over all N abilities, the appended one included:
expect(extended.map((a) => a.schemaLine())).toHaveLength(9);
expect(extended.map((a) => a.constraint())).toHaveLength(9);
expect(extended[8].schemaLine()).toBe(' "custom": [],');
expect(extended.map((a) => a.schemaLine())).toHaveLength(10);
expect(extended.map((a) => a.constraint())).toHaveLength(10);
expect(extended[9].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
@@ -366,6 +380,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
validateTakeovers,
validateWarOps,
applyIndustryEffects,
// disasters (v0.41.0, #63)
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();
+6 -5
View File
@@ -17,11 +17,11 @@ import type {
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
/**
* The 8 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* The 9 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* field each one owns. This is the closed universe the ordered `ABILITIES`
* registry (./index.ts) is built from: a turn's world changes are exactly the
* core engine effects (events / stats / territory / no-man's-land / nation-ops
* / markers) plus, in order, these eight abilities.
* / markers) plus, in order, these nine abilities.
*/
export type AbilityField =
| 'takeovers'
@@ -31,7 +31,8 @@ export type AbilityField =
| 'industry'
| 'nuclear'
| 'leaders'
| 'ideology';
| 'ideology'
| 'disasters';
/**
* Everything a single ability's `apply` may need from the surrounding turn.
@@ -64,7 +65,7 @@ export interface AbilityCtx {
readonly centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
/**
* The `getNeighbours` tool executor (verify land borders). Available to an
* ability that needs it; none of the current eight use it (it drives the
* ability that needs it; none of the current nine use it (it drives the
* core territory/conquest logic), but the type is here so a future one can.
*/
readonly getNeighbours?: (regionKeys: string[]) => string[];
@@ -151,7 +152,7 @@ export interface Ability<K extends AbilityField = AbilityField> {
* The `GameState` fields this ability owns and that must be defaulted on a
* legacy SQLite row (db.ts migration, only-if-undefined). `undefined` for
* abilities that add no durable state (takeovers, hybrid, nuclear, leaders,
* ideology).
* ideology, disasters).
*/
readonly stateFields?: readonly string[];
+39 -56
View File
@@ -570,7 +570,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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: [] }, 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: [] },markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -592,7 +592,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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: [] }, 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: [] },markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -613,7 +613,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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: [] }, 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: [] },markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -632,7 +632,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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: [] }, 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: [] },markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1089,7 +1089,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1106,7 +1106,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1129,7 +1129,7 @@ describe('applyTurnResult', () => {
// 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: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
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
@@ -1155,7 +1155,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1173,7 +1173,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1263,7 +1263,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1378,7 +1378,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
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: [] }, 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: [] },markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1685,9 +1685,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
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: [] }, 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: [] }, 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: [] }, markers: { add: [], remove: [] } })).toBe(false);
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: [] },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: [] },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: [] },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)', () => {
@@ -1700,7 +1700,7 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
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: [] }, 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: [] },markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1721,8 +1721,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
});
@@ -1743,8 +1742,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1767,8 +1765,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1801,8 +1798,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.playerNationId).toBe('uk'); // player still leads it
@@ -1826,8 +1822,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// no nation was renamed
@@ -1854,8 +1849,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// neither was renamed (ambiguous)
@@ -1881,8 +1875,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
const next = applyTurnResult(state, validated!);
@@ -1908,8 +1901,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.name).toBe('The Commonwealth');
@@ -1933,8 +1925,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.version).toBe(2); // exactly one bump for the whole turn
@@ -1956,8 +1947,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1991,8 +1981,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
@@ -2016,13 +2005,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
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: [] }, 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: [] },markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
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: [] }, 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: [] },markers: { add, remove: [] },
}, undefined, centroidOf);
expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
@@ -2049,8 +2038,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: {
ideology: { changes: [] }, disasters: { events: [] },markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
@@ -2097,8 +2085,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: {
ideology: { changes: [] }, disasters: { events: [] },markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
@@ -2123,7 +2110,7 @@ describe('markers (point placement, v0.27 #44)', () => {
];
const next = applyTurnResult(state, {
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: [] }, 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: [] },markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
@@ -2139,14 +2126,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
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: [] }, 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: [] },markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
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: [] }, 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: [] },markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2216,8 +2203,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
llm: fakeLlm([result]),
@@ -2313,6 +2299,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
leaders: partial.leaders ?? { changes: [] },
ideology: partial.ideology ?? { changes: [] },
disasters: partial.disasters ?? { events: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3038,8 +3025,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
// the valid one survives, the empty-name one is dropped
@@ -3427,8 +3413,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3449,8 +3434,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3471,8 +3455,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
}),
).toBe(false);
});
+23 -36
View File
@@ -57,8 +57,9 @@ import {
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
str,
} from './abilities/shared';
import { ABILITIES } from './abilities';
import { ABILITIES, type AbilityCtx, type UserPromptCtx } from './abilities';
// Re-exported so the existing callers (server/index.ts, the unit tests) keep
// importing these shared primitives from the engine unchanged.
export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey };
@@ -86,6 +87,13 @@ export {
validateTakeovers,
validateWarOps,
applyIndustryEffects,
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
} from './abilities';
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
@@ -458,7 +466,7 @@ export function buildUserPrompt(
// (v0.40.0, #67) Build the UserPromptCtx once and merge each ability's
// payload() at its exact position (the key order is load-bearing for the
// golden test).
const upCtx = {
const upCtx: UserPromptCtx = {
state,
markers,
wars,
@@ -468,14 +476,13 @@ export function buildUserPrompt(
nationResources,
industryProjects,
};
const upCtxAny = upCtx as unknown as import('./abilities').UserPromptCtx;
Object.assign(payload, ABILITIES[0].payload(upCtxAny)); // takeovers → takeoverRisks
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
Object.assign(payload, ABILITIES[1].payload(upCtxAny)); // wars → wars
Object.assign(payload, ABILITIES[2].payload(upCtxAny)); // hybrid → relations
Object.assign(payload, ABILITIES[3].payload(upCtxAny)); // economy → trades, embargoes
Object.assign(payload, ABILITIES[4].payload(upCtxAny)); // industry → resources, industry
Object.assign(payload, ABILITIES[1].payload(upCtx)); // wars → wars
Object.assign(payload, ABILITIES[2].payload(upCtx)); // hybrid → relations
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
return JSON.stringify(payload, null, 2);
}
@@ -534,13 +541,6 @@ export function parseTurnResult(
return validateTurnResult(obj, regionKeyResolver);
}
function str(s: unknown, max = 0): string | null {
if (typeof s !== 'string') return null;
const t = s.trim();
if (!t) return null;
return max ? t.slice(0, max) : t;
}
/**
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
@@ -621,6 +621,7 @@ export function validateTurnResult(
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
markers: { add: [], remove: [] },
};
@@ -711,7 +712,7 @@ export function validateTurnResult(
// list for the player to confirm/reject.
result.nationOps = validateNationOps(o.nationOps);
// The 8 per-feature LLM abilities (v0.40.0, #67): each one's validate call
// The 9 per-feature LLM abilities (v0.40.0, #67): each one's validate call
// is owned by its Ability module in the ABILITIES registry. The field's raw
// value is passed through a.validate() which returns the validated field shape.
for (const a of ABILITIES) {
@@ -824,19 +825,6 @@ export function validateRegionChanges(
return out;
}
/**
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
* {@link Takeover} list — the non-military regime-change lever (a coup or
* revolution that renames a nation to its new regime name). Defensive like
* the rest of the parser — never throws. Each entry needs a non-empty
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
* to a concrete nation id happens at apply time (`resolveNationRef`), not
* here. Entries with either field missing or whitespace-only are dropped.
*/
// (v0.40.0, #67) The 8 per-feature ability validators, appliers, and helpers
// have been moved to server/abilities/ — re-exported below for the tests.
export function isMeaningful(result: TurnResult): boolean {
return (
result.events.length > 0 ||
@@ -845,7 +833,7 @@ export function isMeaningful(result: TurnResult): boolean {
result.regionChanges.length > 0 ||
result.unowned.length > 0 ||
result.nationOps.length > 0 ||
// (v0.40.0, #67) any of the 8 per-feature abilities having a non-empty
// (v0.40.0, #67) any of the 9 per-feature abilities having a non-empty
// payload is a real world change (each Ability's isMeaningful predicate
// is the per-ability half of the old 18-sub-field check).
ABILITIES.some((a) => a.isMeaningful(result[a.field])) ||
@@ -1112,12 +1100,12 @@ export function applyTurnResult(
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
// (v0.40.0, #67) The 8 per-feature LLM abilities, applied in ABILITIES
// (v0.40.0, #67) The 9 per-feature LLM abilities, applied in ABILITIES
// order (takeovers → wars → hybrid → economy → industry → nuclear →
// leaders → ideology). Each Ability's apply method handles its own
// field's validated data against the post-territory/post-recompute
// leaders → ideology → disasters). Each Ability's apply method handles its
// own field's validated data against the post-territory/post-recompute
// `next` state. The AbilityCtx carries the shared resolvers.
const abilityCtx = {
const abilityCtx: AbilityCtx = {
previous: state,
actingTurn,
validRegionKeys: validKeys,
@@ -1126,9 +1114,8 @@ export function applyTurnResult(
resourceOf,
centroidOf,
};
const abilityCtxAny = abilityCtx as unknown as import('./abilities').AbilityCtx;
for (const a of ABILITIES) {
a.apply(next, result[a.field], abilityCtxAny);
a.apply(next, result[a.field], abilityCtx);
}
// --- Map markers (v0.27, #44) ---
+2 -2
View File
@@ -252,13 +252,13 @@ export function deterministicTurn(p: StubPayload): TurnResult {
nationOps: [], // the stub never proposes nation ops (see doc comment)
};
// The 8 ability fields, filled by the registry in its canonical order. The
// The 9 ability fields, filled by the registry in its canonical order. The
// stub is ability-inert: every ability contributes its own no-op default
// (each ability's `stub()` — see the per-ability doc comments above), so the
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
// historical key order pinned by the stub's deterministic output
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
// ideology), which must stay byte-identical.
// ideology → disasters), which must stay byte-identical.
const abilities = {} as Record<AbilityField, unknown>;
for (const a of ABILITIES) abilities[a.field] = a.stub();