v0.28.0 (#46): per-region stats — area×population-weighted-median Nation.stats (PR 1 of 2)
CI / build + unit + e2e (pull_request) Successful in 2m34s
CI / build + unit + e2e (pull_request) Successful in 2m34s
This commit is contained in:
+69
-7
@@ -19,6 +19,7 @@ import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
addDays,
|
||||
clampStat,
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
isDateLike,
|
||||
isMarkerType,
|
||||
@@ -592,15 +593,26 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
* deterministic offset; a spec whose key has no known centroid is dropped.
|
||||
* Without it no marker can be placed (all adds are dropped), which keeps the
|
||||
* engine pure and lets tests inject their own data.
|
||||
*
|
||||
* `geoOf` (v0.28, #46) resolves a region key to its area + population
|
||||
* (from public/region-geo.json via server/regions.ts), used to weight the
|
||||
* end-of-turn recompute of each nation's derived stats as the
|
||||
* area×population-weighted median of its regions' stats. Without it the
|
||||
* recompute degrades to an unweighted median (every region weight 1) — still
|
||||
* region-consistent, just unweighted. The engine stays pure either way.
|
||||
*/
|
||||
export function applyTurnResult(
|
||||
state: GameState,
|
||||
result: TurnResult,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
|
||||
// field starts empty (the turn then re-inits claimed regions as it goes).
|
||||
next.regionStats = next.regionStats ?? {};
|
||||
const actingTurn = state.turn; // this action resolves the current turn
|
||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||
@@ -609,11 +621,6 @@ export function applyTurnResult(
|
||||
for (const ev of result.events) {
|
||||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
||||
}
|
||||
for (const c of result.stateChanges) {
|
||||
const n = next.nations[c.nation];
|
||||
if (!n) continue;
|
||||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||||
}
|
||||
for (const r of result.relations) {
|
||||
const a = next.nations[r.a];
|
||||
const b = next.nations[r.b];
|
||||
@@ -663,6 +670,10 @@ export function applyTurnResult(
|
||||
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
||||
unowned.add(key);
|
||||
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
|
||||
// (v0.28, #46) a released region leaves no-man's land ownerless — its
|
||||
// per-region stats leave the table with it (no man's land carries none;
|
||||
// a later re-claim re-inits the entry from the gainer, see below).
|
||||
delete next.regionStats[key];
|
||||
}
|
||||
|
||||
// (2) Claims against the post-release ownership snapshot.
|
||||
@@ -709,10 +720,53 @@ export function applyTurnResult(
|
||||
}
|
||||
next.nations[gainer].territory.push(key);
|
||||
unowned.delete(key); // re-claimed: the key leaves no man's land
|
||||
// (v0.28, #46) a transferred region keeps its own per-region stats (the
|
||||
// conquest is what makes a nation's regions diverge); only a re-claimed
|
||||
// region that has no entry left (it was released earlier this turn) is
|
||||
// re-inited as a copy of the gainer's current stats, so the recompute
|
||||
// below never sees a hole in the table.
|
||||
if (!next.regionStats[key]) next.regionStats[key] = { ...next.nations[gainer].stats };
|
||||
}
|
||||
|
||||
next.unowned = [...unowned];
|
||||
|
||||
// --- Per-region stats (v0.28, #46): nation-level stateChanges re-applied to
|
||||
// the regions, then every nation's stats recomputed as the derived
|
||||
// area×population-weighted median of its regions ---
|
||||
//
|
||||
// (a) A nation-level stat change (`stateChanges`) is re-interpreted as
|
||||
// applying to EVERY region the nation owns: each owned region's copy of
|
||||
// that stat gets the same `set`/`add` (clamped to 0–100), and the
|
||||
// nation-level stat is updated too (it is kept as-is for nations the
|
||||
// recompute below does not touch — see (b) — and overwritten for the
|
||||
// rest, where the regions are the source of truth).
|
||||
//
|
||||
// (b) RECOMPUTE: every nation's `stats` is re-derived as the weighted
|
||||
// median of its owned regions' stats (via deriveNationStats, weighted
|
||||
// by area×population when `geoOf` is available, unweighted otherwise).
|
||||
// Two guards keep degenerate states sane: a nation with NO regions
|
||||
// keeps its stats untouched (it has no regions to aggregate), and a
|
||||
// nation none of whose regions has a stats entry keeps its stats
|
||||
// too (deriveNationStats would return zeros, which would be wrong).
|
||||
// This recompute is what makes a conquered region's foreign stats
|
||||
// blend into the conqueror's aggregate and keeps the derived stats in
|
||||
// lockstep with the per-region table across the turn's changes.
|
||||
for (const c of result.stateChanges) {
|
||||
const n = next.nations[c.nation];
|
||||
if (!n) continue;
|
||||
for (const key of n.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue; // a key with no entry is skipped (recompute does the same)
|
||||
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||||
}
|
||||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||||
}
|
||||
for (const n of Object.values(next.nations)) {
|
||||
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
|
||||
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -1115,8 +1169,16 @@ export async function runTurn(opts: {
|
||||
* the existing markers' owners still get recomputed.
|
||||
*/
|
||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
|
||||
/**
|
||||
* (v0.28, #46) Region-key -> area/population resolver (from
|
||||
* public/region-geo.json). Present, the end-of-turn recompute of each
|
||||
* nation's derived stats weights each owned region by area×population;
|
||||
* absent, the recompute degrades to an unweighted (uniform) median —
|
||||
* still region-consistent, just unweighted.
|
||||
*/
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined;
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf } = opts;
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf } = opts;
|
||||
const chatWithTools = llm.chatWithTools;
|
||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||
const system = buildSystemPrompt(preset, useTools);
|
||||
@@ -1181,7 +1243,7 @@ export async function runTurn(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf), result };
|
||||
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf), result };
|
||||
}
|
||||
|
||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user