feat(#62): ideology / government-type change (v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
CI / build + unit + e2e (pull_request) Successful in 2m33s
Add a per-nation free-form ideology (form of government) field and a
TurnResult.ideology lever (coups / revolutions / reforms / constitutional
changes) with a deterministic -5 stability dip and political narration.
- shared/types.ts: Nation.ideology?: string (optional/additive)
- shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT
= -5, TurnResult.ideology (required, between leaders and markers),
nationForPrompt now carries ideology
- server/engine.ts: validateIdeologyOps (exported, never throws),
applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions +
re-derived -> political event), ideology block in applyTurnResult after
leaders / before markers, isMeaningful + validateTurnResult + system prompt
+ runTurn unclear fallback
- server/stub.ts: StubPayload gains ideology; deterministicTurn returns
ideology: { changes: [] } (never changes one, e2e unaffected)
- Tests: engine.test.ts (validate / isMeaningful / applyTurnResult /
buildUserPrompt), protocol.test.ts (constant + nationForPrompt),
db.test.ts (legacy load + round-trip); every TurnResult fixture updated
- package.json -> 0.39.0; CHANGELOG v0.39.0 entry
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
HYBRID_OP_TYPES,
|
||||
IDEOLOGY_STABILITY_EFFECT,
|
||||
isDateLike,
|
||||
isMarkerType,
|
||||
isNuclearCapable,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
type EconomyTradeOp,
|
||||
type HybridOp,
|
||||
type HybridOpType,
|
||||
type IdeologyChange,
|
||||
type IndustryOps,
|
||||
type LeaderCause,
|
||||
type LeaderChange,
|
||||
@@ -247,6 +249,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
|
||||
' "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>"}]},',
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
@@ -271,6 +274,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".',
|
||||
'- 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).',
|
||||
'- 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.',
|
||||
@@ -637,6 +641,7 @@ export function validateTurnResult(
|
||||
industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] },
|
||||
leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -770,6 +775,14 @@ export function validateTurnResult(
|
||||
// Applied by applyTurnResult after nuclear, before markers.
|
||||
result.leaders = { changes: validateLeaderOps(o.leaders) };
|
||||
|
||||
// (v0.39, #62) Ideology (form of government) changes: each names a target
|
||||
// nation (id or name) and a new government type. Refs stay raw here
|
||||
// (resolved against the real state at apply time, like wars / hybrid /
|
||||
// nuclear / leader ops); an unresolvable target or an empty label is
|
||||
// dropped at apply time, never thrown. Applied by applyTurnResult after
|
||||
// leaders, before markers.
|
||||
result.ideology = { changes: validateIdeologyOps(o.ideology) };
|
||||
|
||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||
// removals by stable id. Applied by applyTurnResult after the territory
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
@@ -1139,6 +1152,35 @@ export function validateLeaderOps(raw: unknown): LeaderChange[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.39, #62) Validate the raw `ideology` object of a turn into a safe
|
||||
* `IdeologyChange[]`. Defensive like the rest of the parser — never throws.
|
||||
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each
|
||||
* change needs a non-empty `target` (a nation ref, capped at 120 chars) and
|
||||
* a non-empty, non-whitespace `to` (the new form of government, capped at
|
||||
* 120 chars). Refs stay raw here (resolved against the real state at apply
|
||||
* time, like the wars / hybrid / nuclear / leader ops); an unresolvable
|
||||
* target is dropped at apply time, never thrown.
|
||||
*/
|
||||
export function validateIdeologyOps(raw: unknown): IdeologyChange[] {
|
||||
const out: IdeologyChange[] = [];
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: raw && typeof raw === 'object'
|
||||
? (raw as Record<string, unknown>).changes
|
||||
: undefined;
|
||||
if (!Array.isArray(list)) return out;
|
||||
for (const e of list) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const s = e as Record<string, unknown>;
|
||||
const target = str(s.target, 120);
|
||||
const to = str(s.to, 120);
|
||||
if (!target || !to) continue;
|
||||
out.push({ target, to });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
||||
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
||||
@@ -1502,6 +1544,66 @@ function applyLeaderChange(
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ideology / form of government (v0.39, #62) ------------------------------
|
||||
|
||||
/**
|
||||
* (v0.39, #62) Apply a single ideology change to `next` (in place). The
|
||||
* target nation is resolved by the shared resolver (id first, then a
|
||||
* case-insensitive unique name; an unknown or ambiguous ref is dropped,
|
||||
* never thrown). On a valid change the target's `ideology` is set to the new
|
||||
* government type (re-setting the same value is idempotent for the field but
|
||||
* still narrated) and a DETERMINISTIC stability dip
|
||||
* (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability`
|
||||
* (clamped 0–100) and mirrored to every region the nation owns
|
||||
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
|
||||
* same 0-region / no-entry guards as the hybrid/economy/industry/leader
|
||||
* effects, so a 0-region nation keeps its directly-mutated value). Finally a
|
||||
* narration event is appended: a `political` event for the government shift.
|
||||
* The change never moves territory (sole ownership is untouched).
|
||||
*/
|
||||
function applyIdeologyChange(
|
||||
next: GameState,
|
||||
op: IdeologyChange,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
actingTurn?: number,
|
||||
): void {
|
||||
const turn = actingTurn ?? next.turn;
|
||||
const id = resolveNationRef(next, op.target);
|
||||
if (id === null) return; // unknown / ambiguous target: drop, never throw
|
||||
const n = next.nations[id];
|
||||
|
||||
n.ideology = op.to;
|
||||
|
||||
const delta = IDEOLOGY_STABILITY_EFFECT;
|
||||
if (delta !== 0) {
|
||||
n.stats.stability = clampStat(n.stats.stability + delta);
|
||||
for (const key of n.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue;
|
||||
rs.stability = clampStat(rs.stability + delta);
|
||||
}
|
||||
// Re-derive the nation's aggregate stats (the main recompute ran earlier
|
||||
// this turn, so without this the dip would be washed out next turn).
|
||||
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: n.id,
|
||||
type: 'political',
|
||||
title: `${n.name}'s government shifted to ${op.to}`,
|
||||
text: `${n.name} has changed its form of government to ${op.to}.`,
|
||||
outcome: 'success',
|
||||
},
|
||||
turn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
|
||||
|
||||
/**
|
||||
@@ -2076,6 +2178,9 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
// (v0.38, #61) a leader assassinated / died / appointed this turn is a real
|
||||
// world change (a nation's leader and stability moved).
|
||||
result.leaders.changes.length > 0 ||
|
||||
// (v0.39, #62) an ideology (form of government) change this turn is a real
|
||||
// world change (a nation's government type and stability moved).
|
||||
result.ideology.changes.length > 0 ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
@@ -2510,6 +2615,18 @@ export function applyTurnResult(
|
||||
// appends a narration event. A leaderless assassination/death is dropped.
|
||||
for (const op of result.leaders.changes) applyLeaderChange(next, op, geoOf, actingTurn);
|
||||
|
||||
// --- Ideology / form of government (v0.39, #62) ---
|
||||
//
|
||||
// Placed AFTER the leaders block (both are per-nation "who is in charge /
|
||||
// how the state is run" levers) and BEFORE the markers block (markers
|
||||
// recompute their owner from the FINAL post-claim ownership; ideology only
|
||||
// touches stats, so ordering w.r.t. markers is immaterial, but keeping the
|
||||
// levers grouped is clearer). Each change sets the target's ideology,
|
||||
// applies a deterministic stability dip (−5), and appends a political
|
||||
// narration event. An unresolvable (unknown / ambiguous) target is
|
||||
// dropped, never thrown.
|
||||
for (const op of result.ideology.changes) applyIdeologyChange(next, op, geoOf, actingTurn);
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -3122,6 +3239,7 @@ export async function runTurn(opts: {
|
||||
industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] },
|
||||
leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user