feat: nation-op split/merge — apply on confirm (playerPart, fresh ids, relations union) (P4b, #16)
This commit is contained in:
+204
-24
@@ -7,6 +7,8 @@ import type {
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
Nation,
|
||||
NationStats,
|
||||
Preset,
|
||||
RelationStatus,
|
||||
RoundItem,
|
||||
@@ -134,7 +136,9 @@ export function playerFrontier(
|
||||
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
||||
* available) the contract gains the contiguity rules for territory transfers
|
||||
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
||||
* may verify borders with the tool before answering.
|
||||
* may verify borders with the tool before answering. The contract also
|
||||
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
|
||||
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
|
||||
*/
|
||||
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
||||
const rules = preset.simulationRules
|
||||
@@ -178,14 +182,21 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
||||
' "unowned": ["<region key>"],',
|
||||
' "nationOps": [{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"}]',
|
||||
' "nationOps": [',
|
||||
' {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},',
|
||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||
' ],',
|
||||
'}',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and region keys that exist in the provided state.',
|
||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||||
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
|
||||
'- To rename a nation, add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} to "nationOps". This is a PROPOSAL: it only takes effect if the player confirms it, so use it only when the narrative justifies it (a revolution, reunification, regime change) — at most 1-2 per turn — and narrate it in "events".',
|
||||
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
|
||||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||
'- 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.',
|
||||
@@ -700,39 +711,208 @@ export function resolveNationRef(state: GameState, ref: string): string | null {
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused nation id for a newly created nation (split
|
||||
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||||
* each id it creates to `taken` as it goes, so ids stay unique within an
|
||||
* op and are never reused from a removed nation.
|
||||
*/
|
||||
export function freshNationId(taken: ReadonlySet<string>): string {
|
||||
for (let i = 1; ; i++) {
|
||||
const id = `n${i}`;
|
||||
if (!taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline stats a freshly created nation starts with (split parts,
|
||||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||
* a copy of the parent's stats (a small rump state is not the parent's
|
||||
* military power).
|
||||
*/
|
||||
export const NEW_NATION_STATS: NationStats = {
|
||||
military: 50,
|
||||
economy: 50,
|
||||
stability: 50,
|
||||
population: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
|
||||
* the removed nations' ids from every remaining nation's `relations` map so
|
||||
* no key dangles. (The client already degrades gracefully on a missing
|
||||
* target, but consistent data is cleaner.)
|
||||
*/
|
||||
function dropRelationsTo(state: GameState, removedIds: ReadonlySet<string>): void {
|
||||
for (const n of Object.values(state.nations)) {
|
||||
for (const id of removedIds) {
|
||||
if (id in n.relations) delete n.relations[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one NationOp to a state, MUTATING the passed object in place (the
|
||||
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
|
||||
* (the route-level functions do that once per decision). Rename changes only
|
||||
* `Nation.name` — the id, its territory, its relations (keyed by id), and the
|
||||
* player pointer all stay valid, so nothing downstream breaks; a
|
||||
* player-supplied `edits.name` overrides the proposed `to` (an empty edit
|
||||
* falls back to the proposal's own name). An unresolvable or ambiguous
|
||||
* `nationRef` is a silent no-op. Split and merge are declared for P4b and are
|
||||
* a no-op until then — the op stays pending-free once the player resolves it,
|
||||
* but the world does not change yet.
|
||||
* (the route-level functions do that once per decision). It never throws:
|
||||
* any unresolvable reference or malformed op is a silent no-op (the op is
|
||||
* still removed by confirmNationOp — the world simply doesn't change).
|
||||
*
|
||||
* - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
|
||||
* territory, its relations (keyed by id), and the player pointer all stay
|
||||
* valid, so nothing downstream breaks; a player-supplied `edits.name`
|
||||
* overrides the proposed `to` (an empty edit falls back to the proposal's
|
||||
* own name).
|
||||
*
|
||||
* - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must
|
||||
* be at least two named parts, else no-op. Parts are processed in order;
|
||||
* each receives the region keys of its `territory` that (a) the parent
|
||||
* owns and (b) no earlier part claimed — the first part to name a key
|
||||
* wins. Keys of the parent's territory that no part claimed go to part 0
|
||||
* (in the parent's `territory` order), so the parent's territory is fully
|
||||
* partitioned — no key is lost or ever held twice. Each part gets a
|
||||
* FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a
|
||||
* copy of the parent's), plus a copy of the parent's relations; the
|
||||
* parent is then removed. If the parent was the player's nation,
|
||||
* `playerNationId` moves to the chosen part (`edits.playerPart`, clamped
|
||||
* into range, default 0); `edits.name` renames that same chosen part.
|
||||
*
|
||||
* - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must
|
||||
* resolve, at least two inputs must resolve to DISTINCT nations, and the
|
||||
* survivor must be one of them — else no-op. The survivor keeps its id,
|
||||
* keeps its own stats, gains the union of all inputs' territories
|
||||
* (survivor's keys first, then each other input's unclaimed keys in its
|
||||
* own order), and gains a union of the inputs' relations (on a third
|
||||
* party the survivor's own view wins, else the first input's). The other
|
||||
* inputs are removed (their ids dropped from everyone else's relations
|
||||
* maps), and if the player led a removed input, `playerNationId` moves to
|
||||
* the survivor. The merged name is `edits.name` (if non-empty), else
|
||||
* `op.into`, else the survivor's current name.
|
||||
*/
|
||||
export function applyNationOp(state: GameState, op: NationOp, edits?: { name?: string }): void {
|
||||
if (op.kind !== 'rename') return; // split/merge: declared for P4b, not applied yet
|
||||
const id = resolveNationRef(state, op.nationRef);
|
||||
if (id === null) return; // invalid/ambiguous ref: no-op
|
||||
const n = state.nations[id];
|
||||
if (!n) return;
|
||||
const name = (edits?.name ?? '').trim() || op.to.trim();
|
||||
if (name) n.name = name;
|
||||
export function applyNationOp(
|
||||
state: GameState,
|
||||
op: NationOp,
|
||||
edits?: { name?: string; playerPart?: number },
|
||||
): void {
|
||||
if (op.kind === 'rename') {
|
||||
const id = resolveNationRef(state, op.nationRef);
|
||||
if (id === null) return; // invalid/ambiguous ref: no-op
|
||||
const n = state.nations[id];
|
||||
if (!n) return;
|
||||
const name = (edits?.name ?? '').trim() || op.to.trim();
|
||||
if (name) n.name = name;
|
||||
return;
|
||||
}
|
||||
|
||||
if (op.kind === 'split') {
|
||||
const id = resolveNationRef(state, op.nationRef);
|
||||
if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op
|
||||
const parent = state.nations[id];
|
||||
if (!parent) return;
|
||||
const taken = new Set(Object.keys(state.nations));
|
||||
const created: string[] = [];
|
||||
const claimed = new Set<string>();
|
||||
for (const part of op.parts) {
|
||||
const newId = freshNationId(taken);
|
||||
taken.add(newId);
|
||||
const keys = (part.territory ?? [])
|
||||
.filter((k) => parent.territory.includes(k) && !claimed.has(k));
|
||||
for (const k of keys) claimed.add(k);
|
||||
const partNation: Nation = {
|
||||
id: newId,
|
||||
type: parent.type, // a part keeps the parent's form of state
|
||||
name: part.name,
|
||||
stats: { ...NEW_NATION_STATS },
|
||||
territory: keys,
|
||||
relations: { ...parent.relations },
|
||||
};
|
||||
state.nations[newId] = partNation;
|
||||
created.push(newId);
|
||||
}
|
||||
// Keys the parent owned but no part claimed go to part 0 (in the
|
||||
// parent's territory order) — nothing is lost or duplicated.
|
||||
for (const k of parent.territory) {
|
||||
if (!claimed.has(k)) state.nations[created[0]].territory.push(k);
|
||||
}
|
||||
// The player chooses which part is its successor (edits.playerPart,
|
||||
// clamped into range; default 0) — only relevant when the parent was
|
||||
// the player's nation; the same part also gets a player-edited name.
|
||||
const partIdx =
|
||||
Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1);
|
||||
if (state.playerNationId === id) state.playerNationId = created[partIdx];
|
||||
const editedName = (edits?.name ?? '').trim();
|
||||
if (editedName) state.nations[created[partIdx]].name = editedName;
|
||||
delete state.nations[id];
|
||||
dropRelationsTo(state, new Set([id]));
|
||||
return;
|
||||
}
|
||||
|
||||
// merge
|
||||
const resolved: string[] = [];
|
||||
for (const ref of op.nationRefs) {
|
||||
const rid = resolveNationRef(state, ref);
|
||||
if (rid === null) return; // any unresolvable input: no-op
|
||||
resolved.push(rid);
|
||||
}
|
||||
const inputIds = new Set(resolved);
|
||||
const survivorId = resolveNationRef(state, op.survivingRef);
|
||||
if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return;
|
||||
const survivor = state.nations[survivorId];
|
||||
if (!survivor) return;
|
||||
// Territory: the union of all inputs — the survivor's keys first (stable
|
||||
// order), then each other input's unclaimed keys in its own order.
|
||||
const territory = [...survivor.territory];
|
||||
const seen = new Set(territory);
|
||||
for (const rid of resolved) {
|
||||
for (const k of state.nations[rid].territory) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
territory.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Relations: the union of all inputs' relations; the first input to name a
|
||||
// third party sets it, then the survivor's own view overwrites per key.
|
||||
const relations: Record<string, RelationStatus> = {};
|
||||
for (const rid of resolved) {
|
||||
for (const [other, status] of Object.entries(state.nations[rid].relations)) {
|
||||
if (other === survivorId) continue; // never relate to oneself
|
||||
if (!(other in relations)) relations[other] = status;
|
||||
}
|
||||
}
|
||||
for (const [other, status] of Object.entries(survivor.relations)) {
|
||||
if (other !== survivorId) relations[other] = status;
|
||||
}
|
||||
const removed = new Set<string>();
|
||||
for (const rid of inputIds) {
|
||||
if (rid === survivorId) continue;
|
||||
delete state.nations[rid];
|
||||
removed.add(rid);
|
||||
}
|
||||
if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) {
|
||||
state.playerNationId = survivorId;
|
||||
}
|
||||
const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name;
|
||||
if (name) survivor.name = name;
|
||||
survivor.territory = territory;
|
||||
survivor.relations = relations;
|
||||
dropRelationsTo(state, removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a pending nation-op proposal by index: apply it (a rename takes
|
||||
* effect, optionally with a player-edited name; split/merge are a no-op until
|
||||
* P4b) and remove it from `pendingNationOps`, bumping `version`. Returns
|
||||
* `null` for an invalid index, so the route answers 400 without mutating.
|
||||
* Reused by the /nation-op/confirm route.
|
||||
* effect, optionally with a player-edited name; a split partitions the
|
||||
* nation's territory into the new parts, the player picking which part is
|
||||
* its successor via `edits.playerPart`; a merge folds the inputs into the
|
||||
* survivor, optionally renamed via `edits.name`) and remove it from
|
||||
* `pendingNationOps`, bumping `version`. Returns `null` for an invalid
|
||||
* index, so the route answers 400 without mutating. Reused by the
|
||||
* /nation-op/confirm route.
|
||||
*/
|
||||
export function confirmNationOp(
|
||||
state: GameState,
|
||||
index: number,
|
||||
edits?: { name?: string },
|
||||
edits?: { name?: string; playerPart?: number },
|
||||
): GameState | null {
|
||||
if (!validPendingOpIndex(state, index)) return null;
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
|
||||
Reference in New Issue
Block a user