import type { City, Constraint, ConstraintContext, ConstraintResult, General, GeneralItemSlots, GeneralActionDefinition, GeneralTurnCommandSpec, GeneralTurnCommandKey, MapDefinition, Nation, NationTurnCommandKey, NationTurnCommandSpec, RequirementKey, StateView, TurnCommandEnv, TriggerValue, UnitSetDefinition, } from '@sammo-ts/logic'; import { evaluateConstraints } from '@sammo-ts/logic'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js'; import { asRecord, isRecord } from '@sammo-ts/common'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js'; import { buildTurnCommandInputFields, loadTurnCommandSpecs, type TurnCommandInputField, type TurnCommandInputOptions, type TurnCommandRecruitmentInfo, } from './commandInput.js'; type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown'; export interface TurnCommandAvailability { key: string; name: string; reqArg: boolean; possible: boolean; status: AvailabilityStatus; reason?: string; inputFields: TurnCommandInputField[]; } export interface TurnCommandGroup { category: string; values: TurnCommandAvailability[]; } export interface TurnCommandTable { general: TurnCommandGroup[]; nation: TurnCommandGroup[]; inputOptions: TurnCommandInputOptions; } type CommandEnv = TurnCommandEnv; interface AvailabilityCore { possible: boolean; status: AvailabilityStatus; reason?: string; } interface CommandEntry { category: string; definition: GeneralActionDefinition; reqArg: boolean; availabilityArgs: Readonly>; inputFields: TurnCommandInputField[]; evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore; } const REF_GENERAL_COMMAND_GROUPS = [ { category: '개인', commands: [ '휴식', 'che_요양', 'che_단련', 'che_숙련전환', 'che_견문', 'che_은퇴', 'che_장비매매', 'che_군량매매', 'che_내정특기초기화', 'che_전투특기초기화', ], }, { category: '내정', commands: [ 'che_농지개간', 'che_상업투자', 'che_기술연구', 'che_수비강화', 'che_성벽보수', 'che_치안강화', 'che_정착장려', 'che_주민선정', ], }, { category: '군사', commands: [ 'che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제', 'che_첩보', ], }, { category: '인사', commands: [ 'che_이동', 'che_강행', 'che_인재탐색', 'che_등용', 'che_귀환', 'che_임관', 'che_랜덤임관', 'che_장수대상임관', ], }, { category: '계략', commands: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], }, { category: '국가', commands: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], }, ] as const satisfies ReadonlyArray<{ category: string; commands: ReadonlyArray; }>; const REF_GENERAL_CATEGORY_ORDER = new Map( REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const) ); const REF_GENERAL_COMMAND_POSITION = new Map( REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) => commands.map((command, index) => [command, { category, index }] as const) ) ); const REF_NATION_COMMAND_GROUPS = [ { category: '휴식', commands: ['휴식'], }, { category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'], }, { category: '외교', commands: ['che_물자원조', 'che_불가침제의', 'che_선전포고', 'che_종전제의', 'che_불가침파기제의'], }, { category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'], }, { category: '전략', commands: [ 'che_필사즉생', 'che_백성동원', 'che_수몰', 'che_허보', 'che_의병모집', 'che_이호경식', 'che_급습', 'che_피장파장', ], }, { category: '기타', commands: ['che_국기변경', 'che_국호변경'], }, ] as const satisfies ReadonlyArray<{ category: string; commands: ReadonlyArray; }>; const REF_NATION_CATEGORY_ORDER = new Map( REF_NATION_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const) ); const REF_NATION_COMMAND_POSITION = new Map( REF_NATION_COMMAND_GROUPS.flatMap(({ category, commands }) => commands.map((command, index) => [command, { category, index }] as const) ) ); 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'], 12), 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; const joinModeRaw = config.join_mode ?? config.joinMode; const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full'; return { currentYear: worldState.currentYear, currentMonth: worldState.currentMonth, year: worldState.currentYear, month: worldState.currentMonth, startYear, relYear, join_mode: joinMode, openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0), }; }; const mapGeneralRow = (row: GeneralRow, missingKillturnFallback?: number): General => { const legacySlots: GeneralItemSlots = { horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), book: normalizeCode(row.bookCode), item: normalizeCode(row.itemCode), }; const rawMeta = asTriggerRecord(row.meta); const meta = readMetaNumber(rawMeta, 'killturn') === null && missingKillturnFallback !== undefined ? ({ ...rawMeta, killturn: missingKillturnFallback } as General['meta']) : ensureGeneralMeta(rawMeta, 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, }, }); export const buildRecruitmentCommandInfo = (options: { worldState: WorldStateRow; general: GeneralRow; city: CityRow | null; nation: NationRow | null; cities: CityRow[]; map: MapDefinition; unitSet: UnitSetDefinition; generalActionModules?: ReadonlyArray; }): TurnCommandRecruitmentInfo => { const general = mapGeneralRow(options.general); const city = options.city ? mapCityRow(options.city) : undefined; const nation = options.nation ? mapNationRow(options.nation) : null; const cities = options.cities.map(mapCityRow); const context = city ? { general, city, nation } : { general, nation }; const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], {}); const tech = options.nation?.tech ?? 0; const techAbility = getTechAbility(tech); const constraintEnv = buildConstraintEnv(options.worldState); const startYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined; const availabilityContext = { general, nation, map: options.map, cities, currentYear: options.worldState.currentYear, ...(startYear === undefined ? {} : { startYear }), }; const crewTypes = options.unitSet.crewTypes ?? []; const armTypes = Object.entries(options.unitSet.armTypes ?? {}) .map(([armType, armName]) => ({ armType: Number(armType), armName })) .filter((entry) => Number.isFinite(entry.armType)) .sort((left, right) => left.armType - right.armType); const groups = armTypes.map(({ armType, armName }) => ({ armType, armName, values: crewTypes .filter((crewType) => crewType.armType === armType) .map((crewType) => { const displayCost = command.getDisplayUnitCost(context, crewType); const requiredTech = crewType.requirements.find((requirement) => requirement.type === 'ReqTech'); return { id: crewType.id, armType, name: crewType.name, available: isCrewTypeAvailable(options.unitSet, crewType.id, availabilityContext), special: requiredTech?.type === 'ReqTech' && typeof requiredTech.tech === 'number' && requiredTech.tech > 0, attack: crewType.attack + techAbility, defence: crewType.defence + techAbility, speed: crewType.speed, avoid: crewType.avoid, baseCost: displayCost.gold, baseRice: displayCost.rice, info: [...crewType.info], }; }), })); const currentCrewTypeName = crewTypes.find((crewType) => crewType.id === general.crewTypeId)?.name ?? '-'; return { techLevel: getTechLevel(tech), leadership: command.resolveLeadership(context), fullLeadership: command.resolveFullLeadership(context), currentCrewTypeId: general.crewTypeId, currentCrewTypeName, crew: general.crew, gold: general.gold, groups, }; }; const buildStateView = ( general: General, city: City | null, nation: Nation | null, generalList: General[] | null, env: Record ): StateView => { const view = new MemoryStateView(); view.set({ kind: 'general', id: general.id }, general); for (const [key, value] of Object.entries(env)) { if (value !== undefined) { view.set({ kind: 'env', key }, value); } } 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 : {}, inputFields: buildTurnCommandInputFields(spec), }; 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, inputFields: entry.inputFields, ...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, })); }; const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] => entries .map((entry, profileIndex) => { const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey); return { entry: refPosition ? { ...entry, category: refPosition.category } : entry, categoryIndex: REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER, commandIndex: refPosition?.index ?? profileIndex, profileIndex, }; }) .sort( (left, right) => left.categoryIndex - right.categoryIndex || left.commandIndex - right.commandIndex || left.profileIndex - right.profileIndex ) .map(({ entry }) => entry); const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[] => entries .map((entry, profileIndex) => { const refPosition = REF_NATION_COMMAND_POSITION.get(entry.definition.key as NationTurnCommandKey); return { entry: refPosition ? { ...entry, category: refPosition.category } : entry, categoryIndex: REF_NATION_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER, commandIndex: refPosition?.index ?? profileIndex, profileIndex, }; }) .sort( (left, right) => left.categoryIndex - right.categoryIndex || left.commandIndex - right.commandIndex || left.profileIndex - right.profileIndex ) .map(({ entry }) => entry); export const buildTurnCommandTable = async (options: { worldState: WorldStateRow; general: GeneralRow; city: CityRow | null; nation: NationRow | null; nationGenerals: GeneralRow[] | null; inputOptions?: TurnCommandInputOptions; }): 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 constraintEnv = buildConstraintEnv(options.worldState); const view = buildStateView(general, city, nation, generalList, constraintEnv); const ctx: ConstraintContext = { actorId: general.id, cityId: city?.id, nationId: nation?.id, args: {}, env: constraintEnv, mode: 'precheck', }; const env = buildCommandEnv(options.worldState); const { general: generalSpecs, nation: nationSpecs } = await loadTurnCommandSpecs(); const generalEntries = buildEntries(env, generalSpecs); const nationEntries = buildEntries(env, nationSpecs); return { general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view), nation: buildGroups(projectRefNationCommandGroups(nationEntries), ctx, view), inputOptions: options.inputOptions ?? { cities: [], nations: [], nationTargets: {}, generals: [], generalTargets: {}, crewTypes: [], armTypes: [], nationTypes: [], colors: [], items: {}, recruitment: null, amountPresets: {}, }, }; }; export const evaluateReservedTurnPermission = async (options: { worldState: WorldStateRow; general: GeneralRow; scope: 'general' | 'nation'; action: string; args: Record; }): Promise => { const specs = await loadTurnCommandSpecs(); const spec = specs[options.scope].find((entry) => entry.key === options.action); if (!spec) { throw new Error(`Unknown ${options.scope} turn command: ${options.action}`); } const definition = spec.createDefinition(buildCommandEnv(options.worldState)); if (!definition.buildPermissionConstraints) { return { kind: 'allow' }; } const general = mapGeneralRow(options.general, 0); const env = buildConstraintEnv(options.worldState); const ctx: ConstraintContext = { actorId: general.id, cityId: general.cityId > 0 ? general.cityId : undefined, nationId: general.nationId > 0 ? general.nationId : undefined, args: options.args, env, mode: 'full', }; const view = buildStateView(general, null, null, null, env); return evaluateConstraints(definition.buildPermissionConstraints(ctx, options.args), ctx, view); };