Each per-feature LLM ability is now a self-contained module in server/abilities/ with its own Ability<K> const (schema line, constraint bullet, validator, applier, isMeaningful, stub default, user-payload field). The ordered ABILITIES registry (index.ts) assembles them in the canonical order: takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology. All function bodies are moved VERBATIM from engine.ts. The engine is untouched in this commit — P4 will rewire it to use the registry.
213 lines
10 KiB
TypeScript
213 lines
10 KiB
TypeScript
// The INDUSTRY ability (v0.36, #59) as a modular LLM-ability (v0.40.0, #67).
|
||
// The per-region economy-boost lever: each project is a building in one
|
||
// region (`{ regionKey, type }`, type one of oil / iron / food / industry).
|
||
// `GameState.industry` is a PER-REGION SET — a region key that is not a valid
|
||
// manifest key is dropped; a region that already hosts a project is untouched
|
||
// (the first declared type wins — re-declaring is a no-op). Every committed
|
||
// turn each active project grants its region's SOLE owner a small,
|
||
// deterministic economy boost (`INDUSTRY_ECONOMY_BONUS`, plus one extra point
|
||
// when the region's `industry` resource value is ≥ 0.5 — from the synthesized
|
||
// resource manifest; without it just the base) — clamped to 0–100, mirrored
|
||
// to every region the owner holds, and re-derived like the economy/hybrid
|
||
// effects. A project on a region with 0 or 2+ owners yields no boost but is
|
||
// kept. Sole ownership is untouched (the layer moves stats, never territory).
|
||
// All bodies are moved VERBATIM from the pre-refactor engine.ts.
|
||
|
||
import { clampStat, deriveNationStats } from '../../shared/protocol';
|
||
import type { GameState, IndustryType, RegionResources } from '../../shared/types';
|
||
import { INDUSTRY_TYPES } from '../../shared/types';
|
||
import type { IndustryOps } from '../../shared/protocol';
|
||
import { str } from './shared';
|
||
import type { Ability } from './types';
|
||
|
||
const VALID_INDUSTRY_TYPES = new Set<string>(INDUSTRY_TYPES);
|
||
|
||
/** True when `t` is one of the four known industry project types (v0.36, #59). */
|
||
export function isIndustryType(t: unknown): t is IndustryType {
|
||
return typeof t === 'string' && VALID_INDUSTRY_TYPES.has(t);
|
||
}
|
||
|
||
/**
|
||
* (v0.36, #59) Validate the raw `industry` object of a turn into a safe
|
||
* {@link IndustryOps}. Defensive like the rest of the parser — never throws.
|
||
* `projects`: each op needs a non-empty `regionKey` (capped at 120 chars) and a
|
||
* known project {@link IndustryType} (one of INDUSTRY_TYPES); ops with a
|
||
* missing/malformed field are dropped. The region key is kept raw here (like
|
||
* the hybrid / economy refs) — whether it is a *valid* region key, and whether
|
||
* its region ends the turn with a sole owner, is checked at apply time against
|
||
* the manifest and the final ownership (a project on a region with no sole
|
||
* owner yields no boost but is kept). A missing / malformed top-level object
|
||
* yields `{ projects: [] }`.
|
||
*/
|
||
export function validateIndustryOps(raw: unknown): IndustryOps {
|
||
const out: IndustryOps = { projects: [] };
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||
const o = raw as Record<string, unknown>;
|
||
if (Array.isArray(o.projects)) {
|
||
for (const e of o.projects) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const p = e as Record<string, unknown>;
|
||
const regionKey = str(p.regionKey, 120);
|
||
if (!regionKey || !isIndustryType(p.type)) continue;
|
||
out.projects.push({ regionKey, type: p.type });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* (v0.36, #59) The BASE per-turn economy boost one industry project grants its
|
||
* region's sole owner. A project's actual boost is this base plus one extra
|
||
* point when the region's `industry` resource value is at least 0.5 (a region
|
||
* with a strong industry profile yields a bigger return). The base value is
|
||
* deliberately the same magnitude as {@link TRADE_ECONOMY_BONUS} /
|
||
* {@link EMBARGO_ECONOMY_PENALTY}.
|
||
*/
|
||
const INDUSTRY_ECONOMY_BONUS = 1;
|
||
|
||
/**
|
||
* (v0.36, #59) The per-turn economy boost for ONE industry project sitting in
|
||
* `regionKey`: {@link INDUSTRY_ECONOMY_BONUS} plus one extra point when the
|
||
* region's `industry` resource value (from `resourceOf`) is at least 0.5.
|
||
* Without the resource manifest (or for a region with no profile) the boost is
|
||
* the base value. Pure + deterministic.
|
||
*/
|
||
function industryBoost(
|
||
regionKey: string,
|
||
resourceOf?: (key: string) => RegionResources | undefined,
|
||
): number {
|
||
const r = resourceOf?.(regionKey);
|
||
return INDUSTRY_ECONOMY_BONUS + (r && r.industry >= 0.5 ? 1 : 0);
|
||
}
|
||
|
||
/**
|
||
* (v0.36, #59) Apply the turn's industry ops to `GameState.industry` as a
|
||
* PER-REGION SET: each op names a region key + a project type. A region key
|
||
* that is not a valid manifest key (`validKeys`, when supplied) is dropped; a
|
||
* region that already hosts a project is untouched (the first declared type
|
||
* wins — re-declaring on the same region is a no-op, the type is never
|
||
* replaced). Otherwise the project is appended. Mutates `next` in place.
|
||
* Nothing here throws.
|
||
*/
|
||
function applyIndustryOps(
|
||
next: GameState,
|
||
ops: IndustryOps,
|
||
validKeys?: ReadonlySet<string>,
|
||
): void {
|
||
if (ops.projects.length === 0) return;
|
||
const has = (key: string) => next.industry.some((p) => p.regionKey === key);
|
||
for (const p of ops.projects) {
|
||
if (validKeys && !validKeys.has(p.regionKey)) continue; // unknown region key: drop
|
||
if (has(p.regionKey)) continue; // a region hosts at most one project
|
||
next.industry.push({ regionKey: p.regionKey, type: p.type });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* (v0.36, #59) The per-turn economic effect of the industry layer, applied
|
||
* EVERY committed turn (not just to this turn's adds): each active project
|
||
* grants its region's SOLE owner a small, deterministic economy boost (see
|
||
* {@link industryBoost}) — clamped to 0–100, mirrored to every region the
|
||
* owner holds (`regionStats`), and each affected nation's derived stats are
|
||
* re-derived afterwards (the same 0-region / no-entry guards as
|
||
* `applyEconomyEffects`). A project whose region ends the turn with NO owner,
|
||
* or 2+ owners (released to no man's land / corrupt), yields no boost but is
|
||
* kept in the list. Sole ownership is untouched. No-op when there are no
|
||
* active projects. Never throws.
|
||
*/
|
||
export function applyIndustryEffects(
|
||
next: GameState,
|
||
resourceOf?: (key: string) => RegionResources | undefined,
|
||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||
): void {
|
||
if (next.industry.length === 0) return;
|
||
|
||
// Map each region key to its SOLE owner (a key with 0 or 2+ owners maps to
|
||
// nothing and yields no boost).
|
||
const ownerOf = new Map<string, string>();
|
||
{
|
||
const owners = new Map<string, string[]>();
|
||
for (const n of Object.values(next.nations)) {
|
||
for (const key of n.territory) {
|
||
const list = owners.get(key);
|
||
if (list) list.push(n.id);
|
||
else owners.set(key, [n.id]);
|
||
}
|
||
}
|
||
for (const [key, list] of owners) {
|
||
if (list.length === 1) ownerOf.set(key, list[0]);
|
||
}
|
||
}
|
||
|
||
// nation id -> accumulated economy boost (a nation owning several project
|
||
// regions gets each of their boosts).
|
||
const delta = new Map<string, number>();
|
||
for (const p of next.industry) {
|
||
const owner = ownerOf.get(p.regionKey);
|
||
if (!owner) continue; // 0 or 2+ owners: no boost (the project is kept)
|
||
const n = next.nations[owner];
|
||
if (!n) continue; // a nation removed this turn (a merge): the boost is dropped
|
||
delta.set(owner, (delta.get(owner) ?? 0) + industryBoost(p.regionKey, resourceOf));
|
||
}
|
||
|
||
const affected = new Set<string>();
|
||
for (const [id, d] of delta) {
|
||
const n = next.nations[id];
|
||
if (!n) continue;
|
||
n.stats.economy = clampStat(n.stats.economy + d);
|
||
for (const key of n.territory) {
|
||
const rs = next.regionStats[key];
|
||
if (rs) rs.economy = clampStat(rs.economy + d);
|
||
}
|
||
affected.add(id);
|
||
}
|
||
|
||
if (affected.size > 0) {
|
||
for (const id of affected) {
|
||
const n = next.nations[id];
|
||
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export const industry: Ability<'industry'> = {
|
||
field: 'industry',
|
||
default: () => ({ projects: [] }),
|
||
validate: (raw) => validateIndustryOps(raw),
|
||
apply: (state, data, ctx) => {
|
||
// Placed after the economy block (both move stats, and the industry boost
|
||
// should compose with the trade/embargo effects of the same turn) and
|
||
// before markers / nationOps. Order:
|
||
// (1) APPLY OPS: this turn's `industry.projects` are added to
|
||
// `GameState.industry` as a per-region SET — a region key that is not
|
||
// a valid manifest key is dropped, and a region that already hosts a
|
||
// project is untouched (the first declared type wins; re-declaring is
|
||
// a no-op).
|
||
// (2) PER-TURN EFFECT: the FULL active project list (not just this
|
||
// turn's adds) each grants their region's SOLE owner a small,
|
||
// deterministic economy boost scaled by the region's `industry`
|
||
// resource value — clamped, mirrored to every region the owner holds,
|
||
// and re-derived like the economy/hybrid effects. A project whose
|
||
// region ends the turn with no owner (or 2+ owners) yields no boost
|
||
// but is KEPT (the region may be re-claimed later). Sole ownership is
|
||
// untouched (the layer moves stats, never territory).
|
||
applyIndustryOps(state, data, ctx.validRegionKeys);
|
||
applyIndustryEffects(state, ctx.resourceOf, ctx.geoOf);
|
||
},
|
||
schemaLine: () =>
|
||
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
|
||
constraint: () =>
|
||
'- 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".',
|
||
isMeaningful: (data) => data.projects.length > 0,
|
||
stateFields: ['industry'],
|
||
stub: () => ({ projects: [] }),
|
||
payload: (ctx) => {
|
||
const out: Record<string, unknown> = {};
|
||
if (ctx.nationResources !== undefined) out.resources = ctx.nationResources;
|
||
if (ctx.industryProjects !== undefined) out.industry = ctx.industryProjects;
|
||
return out;
|
||
},
|
||
};
|