feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
CI / build + unit + e2e (pull_request) Successful in 1m55s
CI / build + unit + e2e (pull_request) Successful in 1m55s
Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.
- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
DisastersOps, and `TurnResult.disasters` (between `ideology` and
`markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
schemaLine / constraint / isMeaningful / inert stub / empty payload; no
stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
dip table (population never touched). Target resolution mirrors hybrid
(a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
`from` field, so there is no self-target drop. Dips clamp to 0-100,
mirror to the target's regions (regionStats), and re-derive the target
nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
regenerated (one schema line + one constraint bullet each); the
user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
the Layout tree + a "how to add a new ability" bullet + a disasters
bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
`as unknown` casts into the top-level ./abilities import, and de-dup
`str` (now imported from ./abilities/shared).
Version bumped 0.40.0 -> 0.41.0.
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
// The DISASTERS & PANDEMICS ability (v0.41.0, #63) as a modular LLM-ability
|
||||
// (v0.40.0, #67). Natural and man-made catastrophes — earthquakes, floods,
|
||||
// volcanic eruptions, wildfires, famines, plagues, and epidemics — hit a
|
||||
// nation or a single region each turn. Unlike most other abilities this one
|
||||
// is **turn-transient**: it owns NO `GameState` field (no durable list, no
|
||||
// `db.ts` change) — exactly like the hybrid layer. Each event carries a
|
||||
// `type` and a `severity`, and the engine applies a DETERMINISTIC stat dip
|
||||
// chosen from the `(type, severity)` lookup table below (no hidden
|
||||
// randomness — the LLM chooses the type and severity, the table decides the
|
||||
// magnitude). The dip lands on the target nation's `stats` (clamped 0–100)
|
||||
// AND on the target's regions (`regionStats` — the single named region when
|
||||
// the target was a region key, otherwise every region the target owns), and
|
||||
// the target's derived `stats` are re-derived afterwards (the same 0-region /
|
||||
// no-entry guards as hybrid / economy / industry, so a 0-region nation keeps
|
||||
// its directly-mutated value and the dip persists to the next turn). The
|
||||
// target is a nation (resolved by the shared `resolveNationRef`) or a region
|
||||
// key (which hits its SOLE owner); a key with 0 or 2+ owners is untargetable.
|
||||
// There is no acting-nation (`from`) field on a disaster — a catastrophe is
|
||||
// not an attack, so the hybrid's self-target drop does not apply. Malformed
|
||||
// events and unresolvable targets are dropped, never thrown.
|
||||
|
||||
import { clampStat, deriveNationStats } from '../../shared/protocol';
|
||||
import type {
|
||||
DisasterEvent,
|
||||
DisasterSeverity,
|
||||
DisasterType,
|
||||
} from '../../shared/protocol';
|
||||
import type { GameState, Nation, NationStats, StatKey } from '../../shared/types';
|
||||
import { resolveNationRef, str } from './shared';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/** The seven disaster types a turn can declare (v0.41.0, #63). */
|
||||
export const DISASTER_TYPES: readonly DisasterType[] = [
|
||||
'earthquake',
|
||||
'flood',
|
||||
'volcanic',
|
||||
'wildfire',
|
||||
'famine',
|
||||
'plague',
|
||||
'epidemic',
|
||||
];
|
||||
|
||||
/** The three severity levels a disaster event can carry (v0.41.0, #63). */
|
||||
export const DISASTER_SEVERITIES: readonly DisasterSeverity[] = ['minor', 'moderate', 'severe'];
|
||||
|
||||
const VALID_DISASTER_TYPES = new Set<string>(DISASTER_TYPES);
|
||||
const VALID_SEVERITIES = new Set<string>(DISASTER_SEVERITIES);
|
||||
|
||||
/** True when `t` is one of the seven known disaster types (v0.41.0, #63). */
|
||||
export function isDisasterType(t: unknown): t is DisasterType {
|
||||
return typeof t === 'string' && VALID_DISASTER_TYPES.has(t);
|
||||
}
|
||||
|
||||
/** True when `s` is one of the three known disaster severities (v0.41.0, #63). */
|
||||
export function isDisasterSeverity(s: unknown): s is DisasterSeverity {
|
||||
return typeof s === 'string' && VALID_SEVERITIES.has(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) The DETERMINISTIC per-`(type, severity)` stat dip of a
|
||||
* disaster event. Each pair maps to a small set of signed (negative) stat
|
||||
* deltas: no hidden randomness — the LLM picks the type and severity, the
|
||||
* table decides the magnitude. Population is never touched (a disaster hurts
|
||||
* military / economy / stability, never the head-count). Every (type,
|
||||
* severity) combination dips at least one stat (none is purely
|
||||
* informational).
|
||||
*/
|
||||
export const DISASTER_EFFECTS: Record<DisasterType, Record<DisasterSeverity, Partial<Record<StatKey, number>>>> = {
|
||||
earthquake: {
|
||||
minor: { stability: -2, economy: -2 },
|
||||
moderate: { stability: -4, economy: -4 },
|
||||
severe: { stability: -7, economy: -7 },
|
||||
},
|
||||
flood: {
|
||||
minor: { economy: -2, stability: -1 },
|
||||
moderate: { economy: -3, stability: -2 },
|
||||
severe: { economy: -6, stability: -4 },
|
||||
},
|
||||
volcanic: {
|
||||
minor: { economy: -2, stability: -2 },
|
||||
moderate: { economy: -4, stability: -3 },
|
||||
severe: { economy: -7, stability: -6 },
|
||||
},
|
||||
wildfire: {
|
||||
minor: { economy: -2 },
|
||||
moderate: { economy: -4 },
|
||||
severe: { economy: -7 },
|
||||
},
|
||||
famine: {
|
||||
minor: { economy: -2, stability: -2 },
|
||||
moderate: { economy: -4, stability: -4 },
|
||||
severe: { economy: -7, stability: -7 },
|
||||
},
|
||||
plague: {
|
||||
minor: { stability: -3, military: -2 },
|
||||
moderate: { stability: -5, military: -3 },
|
||||
severe: { stability: -9, military: -6 },
|
||||
},
|
||||
epidemic: {
|
||||
minor: { stability: -2, military: -1 },
|
||||
moderate: { stability: -4, military: -2 },
|
||||
severe: { stability: -7, military: -4 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) Validate the raw `disasters` field of a turn into a safe
|
||||
* list of {@link DisasterEvent}s. Defensive like the rest of the parser — never throws.
|
||||
* The raw value may be the object `{ events: [...] }` (the TurnResult shape)
|
||||
* or a bare array (be lenient about either). Each event needs a known `type`
|
||||
* (one of {@link DISASTER_TYPES}), a known `severity` (one of
|
||||
* {@link DISASTER_SEVERITIES}), and a non-empty `target` (a nation id/name or
|
||||
* a region key, capped at 200 chars); a non-empty `detail` is capped at 300
|
||||
* chars. An event with an unknown / missing `type`, an unknown / missing
|
||||
* `severity`, or an empty `target` is dropped. Resolution of `target` to a
|
||||
* concrete nation (or a region's sole owner) happens at apply time
|
||||
* (`applyDisasterEvent`), not here. A missing / malformed top-level value
|
||||
* yields `{ events: [] }`.
|
||||
*/
|
||||
export function validateDisasterEvents(raw: unknown): DisasterEvent[] {
|
||||
let list: unknown[] | null = null;
|
||||
if (Array.isArray(raw)) {
|
||||
list = raw;
|
||||
} else if (raw && typeof raw === 'object') {
|
||||
const inner = (raw as Record<string, unknown>).events;
|
||||
if (Array.isArray(inner)) list = inner;
|
||||
}
|
||||
if (!list) return [];
|
||||
const out: DisasterEvent[] = [];
|
||||
for (const e of list) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
if (!isDisasterType(o.type)) continue; // unknown / missing type: drop
|
||||
if (!isDisasterSeverity(o.severity)) continue; // unknown / missing severity: drop
|
||||
const target = str(o.target, 200);
|
||||
if (!target) continue; // empty / missing target: drop
|
||||
const detail = str(o.detail, 300);
|
||||
out.push({
|
||||
type: o.type,
|
||||
target,
|
||||
severity: o.severity,
|
||||
...(detail ? { detail } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) Resolve a disaster event's `target` (a nation id/name OR a
|
||||
* region key) to the concrete nation it affects. A nation ref resolves via
|
||||
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
|
||||
* with zero or 2+ owners can't be a valid target). Returns the affected
|
||||
* nation plus the specific region key (when the target was a region) so the
|
||||
* dip can land on just that region's entry. `validKeys` (when supplied)
|
||||
* gates the region-key path; a ref that is neither a known nation nor a
|
||||
* valid region key returns `null`. Pure; never throws.
|
||||
*
|
||||
* NOTE: there is no acting-nation (`from`) field on a disaster — a
|
||||
* catastrophe is not an attack, so (unlike the hybrid layer) a self-target
|
||||
* drop does NOT apply: an earthquake can hit the nation it is declared
|
||||
* against.
|
||||
*/
|
||||
function resolveDisasterTarget(
|
||||
state: GameState,
|
||||
target: string,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
): { nation: Nation; regionKey?: string } | null {
|
||||
const id = resolveNationRef(state, target);
|
||||
if (id !== null) {
|
||||
const n = state.nations[id];
|
||||
return n ? { nation: n } : null;
|
||||
}
|
||||
const key = (target ?? '').trim();
|
||||
if (!key) return null;
|
||||
if (validKeys && !validKeys.has(key)) return null; // unknown region key
|
||||
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
|
||||
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
|
||||
return { nation: owners[0], regionKey: key };
|
||||
}
|
||||
|
||||
/** Apply each signed dip in `dips` to a stats record in place (clamped). */
|
||||
function applyDips(stats: NationStats, dips: Partial<Record<StatKey, number>>): void {
|
||||
(Object.keys(dips) as StatKey[]).forEach((k) => {
|
||||
const d = dips[k];
|
||||
if (typeof d === 'number') stats[k] = clampStat(stats[k] + d);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.41.0, #63) Apply ONE disaster event to the (cloned) `next` state,
|
||||
* mutating it in place and never throwing. Resolution order:
|
||||
* 1. `target` must resolve to a concrete nation (a nation ref, or a region
|
||||
* key's sole owner); an unresolvable target (unknown / ambiguous ref, a
|
||||
* region with 0 or 2+ owners, or an unknown region key) drops the event.
|
||||
* Then the deterministic `(type, severity)` dip from {@link DISASTER_EFFECTS}
|
||||
* is applied:
|
||||
* - the target nation's `stats` is dipped on each affected stat (clamped
|
||||
* 0–100);
|
||||
* - the dip is mirrored to the target's regions — the single named region
|
||||
* when the target was a region key, otherwise every region the target
|
||||
* owns (regions with no `regionStats` entry are skipped);
|
||||
* - the target nation's derived `stats` are re-derived afterwards (the
|
||||
* same 0-region / no-entry guards as the main recompute, so a 0-region
|
||||
* nation keeps its directly-mutated value and a dip persists).
|
||||
* A disaster never moves territory (sole ownership is untouched) and never
|
||||
* touches `population`.
|
||||
*/
|
||||
export function applyDisasterEvent(
|
||||
next: GameState,
|
||||
ev: DisasterEvent,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
): void {
|
||||
const target = resolveDisasterTarget(next, ev.target, validKeys);
|
||||
if (target === null) return; // unresolvable target: drop
|
||||
const dips = DISASTER_EFFECTS[ev.type][ev.severity];
|
||||
if (!dips) return; // defensive: unknown (type, severity) pair
|
||||
applyDips(target.nation.stats, dips);
|
||||
if (target.regionKey !== undefined) {
|
||||
const rs = next.regionStats[target.regionKey];
|
||||
if (rs) applyDips(rs, dips);
|
||||
} else {
|
||||
for (const key of target.nation.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue; // a key with no entry is skipped
|
||||
applyDips(rs, dips);
|
||||
}
|
||||
}
|
||||
// Re-derive the target nation's aggregate stats so the dip persists (the
|
||||
// main recompute ran earlier this turn; without this the dip would be
|
||||
// washed out next turn). Same 0-region / no-entry guards as hybrid /
|
||||
// economy: a nation with no region stats entry keeps its directly-mutated
|
||||
// `stats`.
|
||||
if (target.nation.territory.length > 0 && target.nation.territory.some((k) => next.regionStats[k])) {
|
||||
target.nation.stats = deriveNationStats(target.nation.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
|
||||
export const disasters: Ability<'disasters'> = {
|
||||
field: 'disasters',
|
||||
default: () => ({ events: [] }),
|
||||
// The low-level validator returns a bare list; the field's shape is the
|
||||
// `{ events }` object (mirrors the historical TurnResult shape).
|
||||
validate: (raw) => ({ events: validateDisasterEvents(raw) }),
|
||||
apply: (state, data, ctx) => {
|
||||
for (const ev of data.events) {
|
||||
applyDisasterEvent(state, ev, ctx.geoOf, ctx.validRegionKeys);
|
||||
}
|
||||
},
|
||||
schemaLine: () =>
|
||||
' "disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},',
|
||||
constraint: () =>
|
||||
'- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region\'s SOLE owner — a region with 0 or 2+ owners can\'t be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).',
|
||||
isMeaningful: (data) => data.events.length > 0,
|
||||
stub: () => ({ events: [] }),
|
||||
// The layer is turn-transient — it contributes nothing to the user payload
|
||||
// (no durable state to show the model), so the payload is always empty.
|
||||
payload: () => ({}),
|
||||
};
|
||||
Reference in New Issue
Block a user