Files
History/server/abilities/industry.ts
T
kamilkrzywda 2d17c743f6 refactor(#74) P3: add the 8 ability modules + ABILITIES registry
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.
2026-10-16 07:23:11 +00:00

213 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
},
};