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>; evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore; } const INPUT_REQUIREMENT_KINDS = new Set([ 'destGeneral', 'destCity', 'destNation', 'diplomacy', 'arg', ]); const AVAILABILITY_PRIORITY: Record = { 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 => isRecord(value) ? (value as Record) : {}; const readMetaNumber = (meta: Record, 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, 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(); 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, 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, 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 => { 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(); 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 => { // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. 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), }; };