feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
CI / build + unit + e2e (pull_request) Successful in 2m20s
CI / build + unit + e2e (pull_request) Successful in 2m20s
#80: TurnResult.alliances — form/break durable mutual-defense pacts (GameState.alliances; pair relation → ally/neutral, an active war overrides). #81: TurnResult.space — launch/deorbit satellites (GameState.satellites; +1 stability per active satellite per turn). Both appended last to the ABILITIES registry (entries 13 & 14, after contested); durable in SQLite via the stateFields loop, mirrored to regionStats, stats re-derived. System + user prompt goldens updated; 738 unit tests passing.
This commit is contained in:
@@ -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: [],
|
||||
seaZones: [],
|
||||
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: [],
|
||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||
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>"}]},
|
||||
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
|
||||
"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>"]}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
- 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).
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -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>"}]},
|
||||
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
|
||||
"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>"]}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
- 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).
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -215,5 +215,7 @@
|
||||
"Bravo"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"alliances": [],
|
||||
"satellites": []
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { disasters } from './disasters';
|
||||
import { migration } from './migration';
|
||||
import { sea } from './sea';
|
||||
import { contested } from './contested';
|
||||
import { alliances } from './alliances';
|
||||
import { space } from './space';
|
||||
import type { Ability, AbilityField } from './types';
|
||||
|
||||
/**
|
||||
@@ -49,6 +51,8 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
|
||||
migration,
|
||||
sea,
|
||||
contested,
|
||||
alliances,
|
||||
space,
|
||||
];
|
||||
|
||||
// ── type re-exports ────────────────────────────────────────────────────────────
|
||||
@@ -185,3 +189,23 @@ export {
|
||||
CONTESTED_STROKE,
|
||||
contestedTooltipLabel,
|
||||
} 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: [],
|
||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||
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: [],
|
||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||
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. ---
|
||||
@@ -254,6 +258,8 @@ function fullDefaultTurn(): TurnResult {
|
||||
migration: { ops: [] },
|
||||
sea: { control: [] },
|
||||
contested: { open: [], resolve: [] },
|
||||
alliances: { form: [], break: [] },
|
||||
space: { launch: [], deorbit: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
@@ -285,6 +291,10 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
|
||||
return { control: [{ zone: 'pacific', by: 'a1' }] };
|
||||
case 'contested':
|
||||
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',
|
||||
'sea',
|
||||
'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);
|
||||
expect(fields).toHaveLength(12);
|
||||
expect(new Set(fields).size).toBe(12);
|
||||
expect(fields).toHaveLength(14);
|
||||
expect(new Set(fields).size).toBe(14);
|
||||
const all = fullDefaultTurn();
|
||||
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', () => {
|
||||
const owned: string[] = [];
|
||||
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', () => {
|
||||
@@ -395,9 +407,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(13);
|
||||
expect(extended.map((a) => a.constraint())).toHaveLength(13);
|
||||
expect(extended[12].schemaLine()).toBe(' "custom": [],');
|
||||
expect(extended.map((a) => a.schemaLine())).toHaveLength(15);
|
||||
expect(extended.map((a) => a.constraint())).toHaveLength(15);
|
||||
expect(extended[14].schemaLine()).toBe(' "custom": [],');
|
||||
});
|
||||
|
||||
it('the engine still re-exports every name its callers import from it', () => {
|
||||
|
||||
@@ -87,6 +87,10 @@ function mkState(
|
||||
seaZones,
|
||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||
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'
|
||||
| 'migration'
|
||||
| 'sea'
|
||||
| 'contested';
|
||||
| 'contested'
|
||||
| 'alliances'
|
||||
| 'space';
|
||||
|
||||
/**
|
||||
* Everything a single ability's `apply` may need from the surrounding turn.
|
||||
|
||||
Reference in New Issue
Block a user