Files
core2026/app/game-api/src/turns/commandTable.ts
T

502 lines
17 KiB
TypeScript

import type {
City,
Constraint,
ConstraintContext,
General,
GeneralItemSlots,
GeneralActionDefinition,
GeneralTurnCommandSpec,
Nation,
NationTurnCommandSpec,
RequirementKey,
StateView,
TurnCommandEnv,
TriggerValue,
} from '@sammo-ts/logic';
import { evaluateConstraints, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs } from '@sammo-ts/logic';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { asRecord, isRecord } from '@sammo-ts/common';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
import { loadTurnCommandProfile } from './turnCommandProfile.js';
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
export interface TurnCommandAvailability {
key: string;
name: string;
reqArg: boolean;
possible: boolean;
status: AvailabilityStatus;
reason?: string;
}
export interface TurnCommandGroup {
category: string;
values: TurnCommandAvailability[];
}
export interface TurnCommandTable {
general: TurnCommandGroup[];
nation: TurnCommandGroup[];
}
type CommandEnv = TurnCommandEnv;
interface AvailabilityCore {
possible: boolean;
status: AvailabilityStatus;
reason?: string;
}
interface CommandEntry {
category: string;
definition: GeneralActionDefinition;
reqArg: boolean;
availabilityArgs: Readonly<Record<string, unknown>>;
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
}
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
'destGeneral',
'destCity',
'destNation',
'diplomacy',
'arg',
]);
const AVAILABILITY_PRIORITY: Record<AvailabilityStatus, number> = {
available: 3,
needsInput: 2,
unknown: 1,
blocked: 0,
};
const DEFAULT_GENERAL_GOLD = 1000;
const DEFAULT_GENERAL_RICE = 1000;
const DEFAULT_CREW_TYPE_ID = 1100;
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
const readMetaNumber = (meta: Record<string, TriggerValue>, key: string): number | null => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
};
const ensureGeneralMeta = (meta: Record<string, TriggerValue>, generalId: number): General['meta'] => {
const killturn = readMetaNumber(meta, 'killturn');
if (killturn === null) {
throw new Error(`general.meta.killturn is required (generalId=${generalId}).`);
}
return { ...meta, killturn } as General['meta'];
};
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
class MemoryStateView implements StateView {
private readonly store = new Map<string, unknown>();
has(req: RequirementKey): boolean {
return this.store.has(this.getKey(req));
}
get(req: RequirementKey): unknown | null {
return this.store.get(this.getKey(req)) ?? null;
}
set(req: RequirementKey, value: unknown): void {
this.store.set(this.getKey(req), value);
}
private getKey(req: RequirementKey): string {
switch (req.kind) {
case 'general':
return `general:${req.id}`;
case 'generalList':
return 'general:list';
case 'city':
return `city:${req.id}`;
case 'nation':
return `nation:${req.id}`;
case 'destGeneral':
return `destGeneral:${req.id}`;
case 'destCity':
return `destCity:${req.id}`;
case 'destNation':
return `destNation:${req.id}`;
case 'diplomacy':
return `diplomacy:${req.srcNationId}:${req.destNationId}`;
case 'diplomacyList':
return 'diplomacy:list';
case 'arg':
return `arg:${req.key}`;
case 'env':
return `env:${req.key}`;
default:
return 'unknown';
}
}
}
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const resolveOptionalString = (source: Record<string, unknown>, keys: string[]): string | null => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string') {
return normalizeCode(value);
}
}
return null;
};
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
const config = asRecord(worldState.config);
const constValues = asRecord(config.const);
return {
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], 0),
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], 0),
sabotageProbCoefByStat: resolveNumber(constValues, ['sabotageProbCoefByStat'], 0),
sabotageDefenceCoefByGeneralCount: resolveNumber(constValues, ['sabotageDefenceCoefByGeneralCount'], 0),
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0),
sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], 0),
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0),
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
defaultCrewTypeId: resolveNumber(constValues, ['defaultCrewTypeId'], DEFAULT_CREW_TYPE_ID),
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01),
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
};
};
const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown> => {
const config = asRecord(worldState.config);
const constValues = asRecord(config.const);
const meta = asRecord(worldState.meta);
const scenarioMeta = asRecord(meta.scenarioMeta);
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined;
return {
currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth,
year: worldState.currentYear,
month: worldState.currentMonth,
startYear,
relYear,
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
};
};
const mapGeneralRow = (row: GeneralRow): General => {
const legacySlots: GeneralItemSlots = {
horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode),
book: normalizeCode(row.bookCode),
item: normalizeCode(row.itemCode),
};
const meta = ensureGeneralMeta(asTriggerRecord(row.meta), row.id);
const itemInventory = readItemInventoryFromMeta(meta, legacySlots);
return {
id: row.id,
name: row.name,
nationId: row.nationId,
cityId: row.cityId,
troopId: row.troopId,
stats: {
leadership: row.leadership,
strength: row.strength,
intelligence: row.intel,
},
experience: row.experience,
dedication: row.dedication,
officerLevel: row.officerLevel,
role: {
personality: normalizeCode(row.personalCode),
specialDomestic: normalizeCode(row.specialCode),
specialWar: normalizeCode(row.special2Code),
items: projectItemSlots(itemInventory),
},
injury: row.injury,
gold: row.gold,
rice: row.rice,
crew: row.crew,
crewTypeId: row.crewTypeId,
train: row.train,
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
itemInventory,
meta,
};
};
const mapCityRow = (row: CityRow): City => {
const meta = asTriggerRecord(row.meta);
const state = typeof meta.state === 'number' && Number.isFinite(meta.state) ? Math.floor(meta.state) : 0;
return {
id: row.id,
name: row.name,
nationId: row.nationId,
level: row.level,
state,
population: row.population,
populationMax: row.populationMax,
agriculture: row.agriculture,
agricultureMax: row.agricultureMax,
commerce: row.commerce,
commerceMax: row.commerceMax,
security: row.security,
securityMax: row.securityMax,
supplyState: row.supplyState,
frontState: row.frontState,
defence: row.defence,
defenceMax: row.defenceMax,
wall: row.wall,
wallMax: row.wallMax,
meta: {
...meta,
trust: row.trust,
...(row.trade === null ? {} : { trade: row.trade }),
region: row.region,
},
};
};
const mapNationRow = (row: NationRow): Nation => ({
id: row.id,
name: row.name,
color: row.color,
capitalCityId: row.capitalCityId,
chiefGeneralId: null,
gold: row.gold,
rice: row.rice,
power: 0,
level: row.level,
typeCode: row.typeCode,
meta: {
...asTriggerRecord(row.meta),
tech: row.tech,
},
});
const buildStateView = (
general: General,
city: City | null,
nation: Nation | null,
generalList: General[] | null
): StateView => {
const view = new MemoryStateView();
view.set({ kind: 'general', id: general.id }, general);
if (city) {
view.set({ kind: 'city', id: city.id }, city);
}
if (nation) {
view.set({ kind: 'nation', id: nation.id }, nation);
}
if (generalList) {
view.set({ kind: 'generalList' }, generalList);
}
return view;
};
const evaluateAvailability = (
constraints: Constraint[],
ctx: ConstraintContext,
view: StateView,
reqArg: boolean
): AvailabilityCore => {
const result = evaluateConstraints(constraints, ctx, view);
if (result.kind === 'allow') {
return { possible: true, status: 'available' };
}
if (result.kind === 'deny') {
return {
possible: false,
status: 'blocked',
reason: result.reason,
};
}
const missingKinds = new Set(result.missing.map((req: RequirementKey) => req.kind));
const inputOnlyMissing =
missingKinds.size === 0 ? reqArg : Array.from(missingKinds).every((kind) => INPUT_REQUIREMENT_KINDS.has(kind));
if (inputOnlyMissing) {
return {
possible: true,
status: 'needsInput',
reason: '대상 선택 필요',
};
}
return {
possible: false,
status: 'unknown',
reason: '정보 부족',
};
};
const evaluateDefinition = (
definition: GeneralActionDefinition,
ctx: ConstraintContext,
view: StateView,
reqArg: boolean,
args: unknown
): AvailabilityCore => {
const constraints =
ctx.mode === 'precheck' && definition.buildMinConstraints
? definition.buildMinConstraints(ctx, args)
: definition.buildConstraints(ctx, args);
return evaluateAvailability(constraints, ctx, view, reqArg);
};
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
type TurnCommandSpec = GeneralTurnCommandSpec | NationTurnCommandSpec;
const buildEntries = (env: CommandEnv, specs: TurnCommandSpec[]): CommandEntry[] => {
const entries: CommandEntry[] = [];
for (const spec of specs) {
const definition = spec.createDefinition(env);
const entry: CommandEntry = {
category: spec.category,
definition,
reqArg: spec.reqArg,
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
};
if (spec.key === 'che_포상') {
entry.evaluate = (ctx, view) => {
const gold = evaluateDefinition(definition, ctx, view, true, {
isGold: true,
amount: 1,
destGeneralId: 0,
});
const rice = evaluateDefinition(definition, ctx, view, true, {
isGold: false,
amount: 1,
destGeneralId: 0,
});
return pickAvailability(gold, rice);
};
}
entries.push(entry);
}
return entries;
};
const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: StateView): TurnCommandGroup[] => {
const groups = new Map<string, TurnCommandAvailability[]>();
for (const entry of entries) {
const availability = entry.evaluate
? entry.evaluate(ctx, view)
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
const value: TurnCommandAvailability = {
key: entry.definition.key,
name: entry.definition.name,
reqArg: entry.reqArg,
...availability,
};
const list = groups.get(entry.category);
if (list) {
list.push(value);
} else {
groups.set(entry.category, [value]);
}
}
return Array.from(groups.entries()).map(([category, values]) => ({
category,
values,
}));
};
export const buildTurnCommandTable = async (options: {
worldState: WorldStateRow;
general: GeneralRow;
city: CityRow | null;
nation: NationRow | null;
nationGenerals: GeneralRow[] | null;
}): Promise<TurnCommandTable> => {
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
const general = mapGeneralRow(options.general);
const city = options.city ? mapCityRow(options.city) : null;
const nation = options.nation ? mapNationRow(options.nation) : null;
const generalList = options.nationGenerals ? options.nationGenerals.map(mapGeneralRow) : null;
const view = buildStateView(general, city, nation, generalList);
const ctx: ConstraintContext = {
actorId: general.id,
cityId: city?.id,
nationId: nation?.id,
args: {},
env: buildConstraintEnv(options.worldState),
mode: 'precheck',
};
const env = buildCommandEnv(options.worldState);
const commandProfile = await loadTurnCommandProfile();
const [generalSpecs, nationSpecs] = await Promise.all([
loadGeneralTurnCommandSpecs(commandProfile.general),
loadNationTurnCommandSpecs(commandProfile.nation),
]);
const generalEntries = buildEntries(env, generalSpecs);
const nationEntries = buildEntries(env, nationSpecs);
return {
general: buildGroups(generalEntries, ctx, view),
nation: buildGroups(nationEntries, ctx, view),
};
};