diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 9b3b407..12049a4 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -4,36 +4,18 @@ import type { ConstraintContext, General, GeneralActionDefinition, + GeneralTurnCommandSpec, Nation, + NationTurnCommandSpec, RequirementKey, StateView, + TurnCommandEnv, TriggerValue, } from '@sammo-ts/logic'; import { - AppointmentActionDefinition, - AssignmentActionDefinition, - BoostMoraleActionDefinition, - AwardActionDefinition, - CommerceInvestmentActionDefinition, - DeclarationActionDefinition, - DefenceUpgradeActionDefinition, - DispatchActionDefinition, evaluateConstraints, - FireAttackActionDefinition, - FarmingActionDefinition, - FoundingActionDefinition, - NationRestActionDefinition, - RecoveryActionDefinition, - ResidentsSelectionActionDefinition, - RecruitActionDefinition, - RestActionDefinition, - SecurityUpgradeActionDefinition, - TechResearchActionDefinition, - TalentScoutActionDefinition, - TrainingActionDefinition, - UprisingActionDefinition, - VolunteerRecruitActionDefinition, - WallRepairActionDefinition, + loadGeneralTurnCommandSpecs, + loadNationTurnCommandSpecs, } from '@sammo-ts/logic'; import type { @@ -42,6 +24,7 @@ import type { NationRow, WorldStateRow, } from '../context.js'; +import { loadTurnCommandProfile } from './turnCommandProfile.js'; type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown'; @@ -64,30 +47,7 @@ export interface TurnCommandTable { nation: TurnCommandGroup[]; } -interface CommandEnv { - develCost: number; - trainDelta: number; - atmosDelta: number; - maxTrainByCommand: number; - maxAtmosByCommand: number; - sabotageDefaultProb: number; - sabotageProbCoefByStat: number; - sabotageDefenceCoefByGeneralCount: number; - sabotageDamageMin: number; - sabotageDamageMax: number; - openingPartYear: number; - maxGeneral: number; - defaultNpcGold: number; - defaultNpcRice: number; - defaultCrewTypeId: number; - defaultSpecialDomestic: string | null; - defaultSpecialWar: string | null; - initialNationGenLimit: number; - maxTechLevel: number; - baseGold: number; - baseRice: number; - maxResourceActionAmount: number; -} +type CommandEnv = TurnCommandEnv; interface AvailabilityCore { possible: boolean; @@ -482,226 +442,47 @@ const pickAvailability = ( ? lhs : rhs; -const buildEntries = (env: CommandEnv): { - general: CommandEntry[]; - nation: CommandEntry[]; -} => { - const emptyModules: [] = []; +type TurnCommandSpec = GeneralTurnCommandSpec | NationTurnCommandSpec; - const generalEntries: CommandEntry[] = [ - { - category: '개인', - definition: new RestActionDefinition(), - reqArg: false, - args: {}, - }, - { - category: '개인', - definition: new RecoveryActionDefinition(), - reqArg: false, - args: {}, - }, - { - category: '전략', - definition: new UprisingActionDefinition(), - reqArg: false, - args: {}, - }, - { - category: '전략', - definition: new AppointmentActionDefinition(), - reqArg: true, - args: { destNationId: 0 }, - }, - { - category: '전략', - definition: new FoundingActionDefinition(), - reqArg: false, - args: {}, - }, - { - category: '군사', - definition: new TrainingActionDefinition({ - trainDelta: env.trainDelta, - maxTrainByCommand: env.maxTrainByCommand, - }), - reqArg: false, - args: {}, - }, - { - category: '군사', - definition: new BoostMoraleActionDefinition({ - atmosDelta: env.atmosDelta, - maxAtmosByCommand: env.maxAtmosByCommand, - }), - reqArg: false, - args: {}, - }, - { - category: '군사', - definition: new DispatchActionDefinition(), - reqArg: true, - args: { destCityId: 0 }, - }, - { - category: '내정', - definition: new ResidentsSelectionActionDefinition({ - develCost: env.develCost, - }), - reqArg: false, - args: {}, - }, - { - category: '내정', - definition: new FarmingActionDefinition({ - develCost: env.develCost, - }), - reqArg: false, - args: {}, - }, - { - category: '내정', - definition: new CommerceInvestmentActionDefinition(emptyModules, { - develCost: env.develCost, - }), - reqArg: false, - args: {}, - }, - { - category: '내정', - definition: new TechResearchActionDefinition({ - costGold: env.develCost, - maxTechLevel: env.maxTechLevel, - }), - reqArg: false, - args: {}, - }, - { - category: '내정', - definition: new SecurityUpgradeActionDefinition({ - develCost: env.develCost, - }), - reqArg: false, - args: {}, - }, - { - category: '내정', - definition: new DefenceUpgradeActionDefinition({ - develCost: env.develCost, - }), - reqArg: false, - args: {}, - }, - { - category: '내정', - definition: new WallRepairActionDefinition({ - develCost: env.develCost, - }), - reqArg: false, - args: {}, - }, - { - category: '내정', - definition: new RecruitActionDefinition(emptyModules, {}), - reqArg: true, - args: {}, - }, - { - category: '계략', - definition: new FireAttackActionDefinition(emptyModules, { - develCost: env.develCost, - sabotageDefaultProb: env.sabotageDefaultProb, - sabotageProbCoefByStat: env.sabotageProbCoefByStat, - sabotageDefenceCoefByGeneralCount: - env.sabotageDefenceCoefByGeneralCount, - sabotageDamageMin: env.sabotageDamageMin, - sabotageDamageMax: env.sabotageDamageMax, - }), - reqArg: true, - args: { destCityId: 0 }, - }, - { - category: '인사', - definition: new TalentScoutActionDefinition(emptyModules, { - develCost: env.develCost, - maxGeneral: env.maxGeneral, - defaultNpcGold: env.defaultNpcGold, - defaultNpcRice: env.defaultNpcRice, - defaultCrewTypeId: env.defaultCrewTypeId, - defaultSpecialDomestic: env.defaultSpecialDomestic, - defaultSpecialWar: env.defaultSpecialWar, - }), - reqArg: false, - args: {}, - }, - { - category: '전략', - definition: new VolunteerRecruitActionDefinition(emptyModules, { - openingPartYear: env.openingPartYear, - initialNationGenLimit: env.initialNationGenLimit, - defaultNpcGold: env.defaultNpcGold, - defaultNpcRice: env.defaultNpcRice, - defaultCrewTypeId: env.defaultCrewTypeId, - defaultSpecialDomestic: env.defaultSpecialDomestic, - defaultSpecialWar: env.defaultSpecialWar, - }), - reqArg: false, - args: {}, - }, - ]; +const buildEntries = ( + env: CommandEnv, + specs: TurnCommandSpec[] +): CommandEntry[] => { + const entries: CommandEntry[] = []; - const awardDefinition = new AwardActionDefinition({ - baseGold: env.baseGold, - baseRice: env.baseRice, - maxAmount: env.maxResourceActionAmount, - }); - const assignmentDefinition = new AssignmentActionDefinition({}); + for (const spec of specs) { + const definition = spec.createDefinition(env); + const entry: CommandEntry = { + category: spec.category, + definition, + reqArg: spec.reqArg, + args: spec.args, + }; - const nationEntries: CommandEntry[] = [ - { - category: '휴식', - definition: new NationRestActionDefinition(), - reqArg: false, - args: {}, - }, - { - category: '외교', - definition: new DeclarationActionDefinition(), - reqArg: true, - args: { destNationId: 0 }, - }, - { - category: '인사', - definition: assignmentDefinition, - reqArg: true, - args: { destGeneralId: 0, destCityId: 0 }, - }, - { - category: '인사', - definition: awardDefinition, - reqArg: true, - args: { isGold: true, amount: 1, destGeneralId: 0 }, - evaluate: (ctx, view) => { + if (spec.key === 'che_포상') { + entry.evaluate = (ctx, view) => { const gold = evaluateDefinition( - awardDefinition, + definition, ctx, view, true, { isGold: true, amount: 1, destGeneralId: 0 } ); const rice = evaluateDefinition( - awardDefinition, + definition, ctx, view, true, { isGold: false, amount: 1, destGeneralId: 0 } ); return pickAvailability(gold, rice); - }, - }, - ]; + }; + } - return { general: generalEntries, nation: nationEntries }; + entries.push(entry); + } + + return entries; }; const buildGroups = ( @@ -742,12 +523,12 @@ const buildGroups = ( })); }; -export const buildTurnCommandTable = (options: { +export const buildTurnCommandTable = async (options: { worldState: WorldStateRow; general: GeneralRow; city: CityRow | null; nation: NationRow | null; -}): TurnCommandTable => { +}): Promise => { // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. const general = mapGeneralRow(options.general); const city = options.city ? mapCityRow(options.city) : null; @@ -764,10 +545,16 @@ export const buildTurnCommandTable = (options: { }; const env = buildCommandEnv(options.worldState); - const entries = buildEntries(env); + 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(entries.general, ctx, view), - nation: buildGroups(entries.nation, ctx, view), + general: buildGroups(generalEntries, ctx, view), + nation: buildGroups(nationEntries, ctx, view), }; }; diff --git a/app/game-api/src/turns/turnCommandProfile.ts b/app/game-api/src/turns/turnCommandProfile.ts new file mode 100644 index 0000000..2228e55 --- /dev/null +++ b/app/game-api/src/turns/turnCommandProfile.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + DEFAULT_TURN_COMMAND_PROFILE, + parseTurnCommandProfile, + type TurnCommandProfile, +} from '@sammo-ts/logic'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +const DEFAULT_PROFILE_PATH = path.resolve( + REPO_ROOT, + 'resources', + 'turn-commands', + 'default.json' +); + +export interface TurnCommandProfileOptions { + filePath?: string; +} + +const readCommandProfile = async (filePath: string): Promise => { + const raw = await fs.readFile(filePath, 'utf8'); + return parseTurnCommandProfile(JSON.parse(raw) as unknown); +}; + +export const loadTurnCommandProfile = async ( + options?: TurnCommandProfileOptions +): Promise => { + const filePath = + options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH; + try { + return await readCommandProfile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return DEFAULT_TURN_COMMAND_PROFILE; + } + throw error; + } +}; diff --git a/app/game-engine/src/turn/reservedTurnActionContext.ts b/app/game-engine/src/turn/reservedTurnActionContext.ts new file mode 100644 index 0000000..e1f14c7 --- /dev/null +++ b/app/game-engine/src/turn/reservedTurnActionContext.ts @@ -0,0 +1,228 @@ +import type { + City, + General, + MapDefinition, + Nation, + ScenarioMeta, + UnitSetDefinition, +} from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldState } from './types.js'; + +interface WorldSummary { + totalGeneralCount: number; + totalNpcCount: number; + averageStats?: General['stats']; +} + +interface NationSummary { + averageStats?: General['stats']; + averageExperience?: number; + averageDedication?: number; +} + +export interface ActionRandomSource { + nextFloat(): number; + nextBool(probability: number): boolean; + nextInt(minInclusive: number, maxExclusive: number): number; +} + +export type ActionContextBase = { + general: TurnGeneral; + city?: City; + nation?: Nation | null; + rng: ActionRandomSource; +}; + +export type ActionResolveContext = ActionContextBase & Record; + +export interface ActionContextOptions { + world: TurnWorldState; + scenarioMeta?: ScenarioMeta; + map?: MapDefinition; + unitSet?: UnitSetDefinition; + worldRef: InMemoryTurnWorld | null; + actionArgs: Record; + createGeneralId: () => number; +} + +type ActionContextBuilder = ( + base: ActionContextBase, + options: ActionContextOptions +) => ActionResolveContext | null; + +const buildWorldSummary = (world: InMemoryTurnWorld | null): WorldSummary => { + if (!world) { + return { totalGeneralCount: 0, totalNpcCount: 0 }; + } + const generals = world.listGenerals(); + if (generals.length === 0) { + return { totalGeneralCount: 0, totalNpcCount: 0 }; + } + const total = generals.length; + const npcCount = generals.filter((general) => general.npcState > 0).length; + const statSum = generals.reduce( + (acc, general) => ({ + leadership: acc.leadership + general.stats.leadership, + strength: acc.strength + general.stats.strength, + intelligence: acc.intelligence + general.stats.intelligence, + }), + { leadership: 0, strength: 0, intelligence: 0 } + ); + return { + totalGeneralCount: total, + totalNpcCount: npcCount, + averageStats: { + leadership: statSum.leadership / total, + strength: statSum.strength / total, + intelligence: statSum.intelligence / total, + }, + }; +}; + +const buildNationSummary = ( + world: InMemoryTurnWorld | null, + nationId: number +): NationSummary => { + if (!world || nationId <= 0) { + return {}; + } + const generals = world.listGenerals().filter( + (general) => general.nationId === nationId + ); + if (generals.length === 0) { + return {}; + } + const total = generals.length; + const statSum = generals.reduce( + (acc, general) => ({ + leadership: acc.leadership + general.stats.leadership, + strength: acc.strength + general.stats.strength, + intelligence: acc.intelligence + general.stats.intelligence, + }), + { leadership: 0, strength: 0, intelligence: 0 } + ); + const expSum = generals.reduce((acc, general) => acc + general.experience, 0); + const dedSum = generals.reduce((acc, general) => acc + general.dedication, 0); + return { + averageStats: { + leadership: statSum.leadership / total, + strength: statSum.strength / total, + intelligence: statSum.intelligence / total, + }, + averageExperience: expSum / total, + averageDedication: dedSum / total, + }; +}; + +const buildAverageNationGeneralCount = (world: InMemoryTurnWorld | null): number => { + if (!world) { + return 0; + } + const generals = world.listGenerals(); + const nations = world.listNations(); + if (nations.length === 0) { + return generals.length; + } + return generals.length / nations.length; +}; + +const resolveStartYear = ( + world: TurnWorldState, + scenarioMeta?: ScenarioMeta +): number => { + if (typeof scenarioMeta?.startYear === 'number') { + return scenarioMeta.startYear; + } + return world.currentYear; +}; + +const resolveTurnTermMinutes = (world: TurnWorldState): number => + Math.max(1, Math.round(world.tickSeconds / 60)); + +// 커맨드별로 필요한 컨텍스트 확장 데이터를 구성한다. +const ACTION_CONTEXT_BUILDERS: Record = { + che_인재탐색: (base, options) => ({ + ...base, + currentYear: options.world.currentYear, + worldSummary: buildWorldSummary(options.worldRef), + createGeneralId: options.createGeneralId, + }), + che_의병모집: (base, options) => { + const nationSummary = buildNationSummary( + options.worldRef, + base.general.nationId + ); + return { + ...base, + currentYear: options.world.currentYear, + startYear: resolveStartYear(options.world, options.scenarioMeta), + averageNationGeneralCount: buildAverageNationGeneralCount( + options.worldRef + ), + nationAverageStats: nationSummary.averageStats, + nationAverageExperience: nationSummary.averageExperience, + nationAverageDedication: nationSummary.averageDedication, + createGeneralId: options.createGeneralId, + }; + }, + che_포상: (base, options) => { + const destGeneralId = options.actionArgs.destGeneralId; + if (typeof destGeneralId !== 'number') { + return null; + } + const destGeneral = options.worldRef?.getGeneralById(destGeneralId); + if (!destGeneral) { + return null; + } + return { + ...base, + destGeneral, + }; + }, + che_발령: (base, options) => { + const destGeneralId = options.actionArgs.destGeneralId; + const destCityId = options.actionArgs.destCityId; + if (typeof destGeneralId !== 'number' || typeof destCityId !== 'number') { + return null; + } + const destGeneral = options.worldRef?.getGeneralById(destGeneralId); + const destCity = options.worldRef?.getCityById(destCityId); + if (!destGeneral || !destCity) { + return null; + } + return { + ...base, + destGeneral, + destCity, + currentYear: options.world.currentYear, + currentMonth: options.world.currentMonth, + turnTermMinutes: resolveTurnTermMinutes(options.world), + generalTurnTime: base.general.turnTime, + destGeneralTurnTime: destGeneral.turnTime, + }; + }, + che_징병: (base, options) => { + if (!options.map || !options.unitSet) { + return null; + } + return { + ...base, + map: options.map, + unitSet: options.unitSet, + cities: options.worldRef?.listCities() ?? [], + currentYear: options.world.currentYear, + startYear: resolveStartYear(options.world, options.scenarioMeta), + }; + }, +}; + +export const buildActionContext = ( + key: string, + base: ActionContextBase, + options: ActionContextOptions +): ActionResolveContext | null => { + const builder = ACTION_CONTEXT_BUILDERS[key]; + return builder ? builder(base, options) : base; +}; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts new file mode 100644 index 0000000..0b6745f --- /dev/null +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -0,0 +1,186 @@ +import type { + GeneralActionDefinition, + GeneralTurnCommandKey, + NationTurnCommandKey, + ScenarioConfig, + TurnCommandEnv, + TurnCommandProfile, + UnitSetDefinition, +} from '@sammo-ts/logic'; +import { + loadGeneralTurnCommandSpecs, + loadNationTurnCommandSpecs, +} from '@sammo-ts/logic'; + +const DEFAULT_GENERAL_GOLD = 1000; +const DEFAULT_GENERAL_RICE = 1000; +const DEFAULT_CREW_TYPE_ID = 1100; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const asRecord = (value: unknown): Record => + isRecord(value) ? value : {}; + +const normalizeCode = (value: string | null | undefined): string | null => { + if (!value || value === 'None') { + return null; + } + return value; +}; + +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; +}; + +export const buildCommandEnv = ( + config: ScenarioConfig, + unitSet?: UnitSetDefinition +): TurnCommandEnv => { + const constValues = asRecord(config.const); + + return { + develCost: resolveNumber( + constValues, + ['develCost', 'develcost', 'develrate'], + 0 + ), + trainDelta: resolveNumber(constValues, ['trainDelta'], 0), + atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), + 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'], + unitSet?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID + ), + defaultSpecialDomestic: resolveOptionalString( + constValues, + ['defaultSpecialDomestic'] + ), + defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']), + initialNationGenLimit: resolveNumber( + constValues, + ['initialNationGenLimit'], + 0 + ), + maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), + baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), + baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), + maxResourceActionAmount: resolveNumber( + constValues, + ['maxResourceActionAmount'], + 0 + ), + }; +}; + +const ensureGeneralFallback = async ( + definitions: Map, + fallbackKey: GeneralTurnCommandKey, + env: TurnCommandEnv +): Promise => { + if (definitions.has(fallbackKey)) { + return; + } + const [spec] = await loadGeneralTurnCommandSpecs([fallbackKey]); + if (!spec) { + return; + } + definitions.set(fallbackKey, spec.createDefinition(env)); +}; + +export const buildReservedTurnDefinitions = async (options: { + env: TurnCommandEnv; + commandProfile: TurnCommandProfile; + defaultActionKey: GeneralTurnCommandKey & NationTurnCommandKey; +}): Promise<{ + general: Map; + nation: Map; +}> => { + const generalSpecs = await loadGeneralTurnCommandSpecs( + options.commandProfile.general + ); + const nationSpecs = await loadNationTurnCommandSpecs( + options.commandProfile.nation + ); + + const general = new Map( + generalSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)]) + ); + const nation = new Map( + nationSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)]) + ); + + await ensureGeneralFallback(general, options.defaultActionKey, options.env); + if (!nation.has(options.defaultActionKey)) { + const [spec] = await loadNationTurnCommandSpecs([ + options.defaultActionKey, + ]); + if (spec) { + nation.set(spec.key, spec.createDefinition(options.env)); + } + } + + return { general, nation }; +}; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index aa0d955..0d4d210 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1,6 +1,5 @@ import type { City, - General, GeneralActionDefinition, GeneralActionResolveContext, LogEntryDraft, @@ -10,34 +9,13 @@ import type { ScenarioDiplomacy, ScenarioMeta, Troop, + TurnCommandProfile, UnitSetDefinition, } from '@sammo-ts/logic'; import { - AppointmentActionDefinition, - AssignmentActionDefinition, - BoostMoraleActionDefinition, - AwardActionDefinition, - CommerceInvestmentActionDefinition, - DeclarationActionDefinition, - DefenceUpgradeActionDefinition, - DispatchActionDefinition, + DEFAULT_TURN_COMMAND_PROFILE, evaluateConstraints, - FireAttackActionDefinition, - FarmingActionDefinition, - FoundingActionDefinition, - NationRestActionDefinition, - RecoveryActionDefinition, - ResidentsSelectionActionDefinition, - RecruitActionDefinition, resolveGeneralAction, - RestActionDefinition, - SecurityUpgradeActionDefinition, - TechResearchActionDefinition, - TalentScoutActionDefinition, - TrainingActionDefinition, - UprisingActionDefinition, - VolunteerRecruitActionDefinition, - WallRepairActionDefinition, } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { LiteHashDRBG } from '@sammo-ts/common'; @@ -49,47 +27,13 @@ import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnGeneral, TurnWorldState } from './types.js'; import type { ReservedTurnEntry } from './reservedTurnStore.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import { + buildCommandEnv, + buildReservedTurnDefinitions, +} from './reservedTurnCommands.js'; +import type { ActionContextBase } from './reservedTurnActionContext.js'; +import { buildActionContext } from './reservedTurnActionContext.js'; -interface CommandEnv { - develCost: number; - trainDelta: number; - atmosDelta: number; - maxTrainByCommand: number; - maxAtmosByCommand: number; - sabotageDefaultProb: number; - sabotageProbCoefByStat: number; - sabotageDefenceCoefByGeneralCount: number; - sabotageDamageMin: number; - sabotageDamageMax: number; - openingPartYear: number; - maxGeneral: number; - defaultNpcGold: number; - defaultNpcRice: number; - defaultCrewTypeId: number; - defaultSpecialDomestic: string | null; - defaultSpecialWar: string | null; - initialNationGenLimit: number; - maxTechLevel: number; - baseGold: number; - baseRice: number; - maxResourceActionAmount: number; -} - -interface WorldSummary { - totalGeneralCount: number; - totalNpcCount: number; - averageStats?: General['stats']; -} - -interface NationSummary { - averageStats?: General['stats']; - averageExperience?: number; - averageDedication?: number; -} - -const DEFAULT_GENERAL_GOLD = 1000; -const DEFAULT_GENERAL_RICE = 1000; -const DEFAULT_CREW_TYPE_ID = 1100; const DEFAULT_ACTION = '휴식'; const isRecord = (value: unknown): value is Record => @@ -98,134 +42,6 @@ const isRecord = (value: unknown): value is Record => const asRecord = (value: unknown): Record => isRecord(value) ? value : {}; -const normalizeCode = (value: string | null | undefined): string | null => { - if (!value || value === 'None') { - return null; - } - return value; -}; - -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 = ( - config: ScenarioConfig, - unitSet?: UnitSetDefinition -): CommandEnv => { - const constValues = asRecord(config.const); - - return { - develCost: resolveNumber( - constValues, - ['develCost', 'develcost', 'develrate'], - 0 - ), - trainDelta: resolveNumber(constValues, ['trainDelta'], 0), - atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), - 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'], - unitSet?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID - ), - defaultSpecialDomestic: resolveOptionalString( - constValues, - ['defaultSpecialDomestic'] - ), - defaultSpecialWar: resolveOptionalString( - constValues, - ['defaultSpecialWar'] - ), - initialNationGenLimit: resolveNumber( - constValues, - ['initialNationGenLimit'], - 0 - ), - maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), - baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), - baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), - maxResourceActionAmount: resolveNumber( - constValues, - ['maxResourceActionAmount'], - 0 - ), - }; -}; - const resolveConstraintEnv = ( world: TurnWorldState, scenarioMeta?: ScenarioMeta @@ -259,93 +75,6 @@ const buildDiplomacyMap = ( return map; }; -const buildWorldSummary = ( - world: InMemoryTurnWorld | null -): WorldSummary => { - if (!world) { - return { totalGeneralCount: 0, totalNpcCount: 0 }; - } - const generals = world.listGenerals(); - if (generals.length === 0) { - return { totalGeneralCount: 0, totalNpcCount: 0 }; - } - const total = generals.length; - const npcCount = generals.filter((general) => general.npcState > 0).length; - const statSum = generals.reduce( - (acc, general) => ({ - leadership: acc.leadership + general.stats.leadership, - strength: acc.strength + general.stats.strength, - intelligence: acc.intelligence + general.stats.intelligence, - }), - { leadership: 0, strength: 0, intelligence: 0 } - ); - return { - totalGeneralCount: total, - totalNpcCount: npcCount, - averageStats: { - leadership: statSum.leadership / total, - strength: statSum.strength / total, - intelligence: statSum.intelligence / total, - }, - }; -}; - -const buildNationSummary = ( - world: InMemoryTurnWorld | null, - nationId: number -): NationSummary => { - if (!world || nationId <= 0) { - return {}; - } - const generals = world.listGenerals().filter( - (general) => general.nationId === nationId - ); - if (generals.length === 0) { - return {}; - } - const total = generals.length; - const statSum = generals.reduce( - (acc, general) => ({ - leadership: acc.leadership + general.stats.leadership, - strength: acc.strength + general.stats.strength, - intelligence: acc.intelligence + general.stats.intelligence, - }), - { leadership: 0, strength: 0, intelligence: 0 } - ); - const expSum = generals.reduce((acc, general) => acc + general.experience, 0); - const dedSum = generals.reduce((acc, general) => acc + general.dedication, 0); - return { - averageStats: { - leadership: statSum.leadership / total, - strength: statSum.strength / total, - intelligence: statSum.intelligence / total, - }, - averageExperience: expSum / total, - averageDedication: dedSum / total, - }; -}; - -const buildAverageNationGeneralCount = (world: InMemoryTurnWorld | null): number => { - if (!world) { - return 0; - } - const generals = world.listGenerals(); - const nations = world.listNations(); - if (nations.length === 0) { - return generals.length; - } - return generals.length / nations.length; -}; - -const resolveStartYear = ( - world: TurnWorldState, - scenarioMeta?: ScenarioMeta -): number => { - if (typeof scenarioMeta?.startYear === 'number') { - return scenarioMeta.startYear; - } - return world.currentYear; -}; const buildSeedBase = (world: TurnWorldState): string => { const meta = asRecord(world.meta); @@ -445,196 +174,12 @@ class WorldStateView implements StateView { } } -const buildGeneralDefinitions = ( - env: CommandEnv -): Map => { - const definitions = new Map(); - definitions.set('che_거병', new UprisingActionDefinition()); - definitions.set('che_임관', new AppointmentActionDefinition()); - definitions.set('che_건국', new FoundingActionDefinition()); - definitions.set( - 'che_훈련', - new TrainingActionDefinition({ - trainDelta: env.trainDelta, - maxTrainByCommand: env.maxTrainByCommand, - }) - ); - definitions.set( - 'che_사기진작', - new BoostMoraleActionDefinition({ - atmosDelta: env.atmosDelta, - maxAtmosByCommand: env.maxAtmosByCommand, - }) - ); - definitions.set('che_요양', new RecoveryActionDefinition()); - definitions.set('che_출병', new DispatchActionDefinition()); - definitions.set( - 'che_주민선정', - new ResidentsSelectionActionDefinition({ develCost: env.develCost }) - ); - definitions.set( - 'che_농지개간', - new FarmingActionDefinition({ develCost: env.develCost }) - ); - definitions.set( - 'che_상업투자', - new CommerceInvestmentActionDefinition([], env) - ); - definitions.set( - 'che_기술연구', - new TechResearchActionDefinition({ - costGold: env.develCost, - maxTechLevel: env.maxTechLevel, - }) - ); - definitions.set( - 'che_치안강화', - new SecurityUpgradeActionDefinition({ develCost: env.develCost }) - ); - definitions.set( - 'che_수비강화', - new DefenceUpgradeActionDefinition({ develCost: env.develCost }) - ); - definitions.set( - 'che_성벽보수', - new WallRepairActionDefinition({ develCost: env.develCost }) - ); - definitions.set('che_화계', new FireAttackActionDefinition([], env)); - definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env)); - definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env)); - definitions.set('che_징병', new RecruitActionDefinition([], {})); - definitions.set('휴식', new RestActionDefinition()); - return definitions; -}; - -const buildNationDefinitions = ( - env: CommandEnv -): Map => { - const definitions = new Map(); - const maxAmount = - env.maxResourceActionAmount > 0 - ? env.maxResourceActionAmount - : Math.max(env.baseGold, env.baseRice, 1000); - definitions.set('휴식', new NationRestActionDefinition()); - definitions.set('che_선전포고', new DeclarationActionDefinition()); - definitions.set( - 'che_포상', - new AwardActionDefinition({ - baseGold: env.baseGold, - baseRice: env.baseRice, - maxAmount, - }) - ); - definitions.set('che_발령', new AssignmentActionDefinition({})); - return definitions; -}; + const extractArgsRecord = (value: unknown): Record => isRecord(value) ? value : {}; -const resolveTurnTermMinutes = (world: TurnWorldState): number => - Math.max(1, Math.round(world.tickSeconds / 60)); - -type ActionContextBase = { - general: TurnGeneral; - city?: City; - nation?: Nation | null; - rng: DeterministicRandom; -}; - -type ActionResolveContext = ActionContextBase & Record; - -const buildActionContext = ( - key: string, - base: ActionContextBase, - options: { - world: TurnWorldState; - scenarioMeta?: ScenarioMeta; - map?: MapDefinition; - unitSet?: UnitSetDefinition; - worldRef: InMemoryTurnWorld | null; - actionArgs: Record; - createGeneralId: () => number; - } -): ActionResolveContext | null => { - switch (key) { - case 'che_인재탐색': - return { - ...base, - currentYear: options.world.currentYear, - worldSummary: buildWorldSummary(options.worldRef), - createGeneralId: options.createGeneralId, - }; - case 'che_의병모집': { - const nationSummary = buildNationSummary( - options.worldRef, - (base.general as TurnGeneral).nationId - ); - return { - ...base, - currentYear: options.world.currentYear, - startYear: resolveStartYear(options.world, options.scenarioMeta), - averageNationGeneralCount: buildAverageNationGeneralCount( - options.worldRef - ), - nationAverageStats: nationSummary.averageStats, - nationAverageExperience: nationSummary.averageExperience, - nationAverageDedication: nationSummary.averageDedication, - createGeneralId: options.createGeneralId, - }; - } - case 'che_포상': { - const destGeneralId = options.actionArgs.destGeneralId; - if (typeof destGeneralId !== 'number') { - return null; - } - const destGeneral = options.worldRef?.getGeneralById(destGeneralId); - if (!destGeneral) { - return null; - } - return { - ...base, - destGeneral, - }; - } - case 'che_발령': { - const destGeneralId = options.actionArgs.destGeneralId; - const destCityId = options.actionArgs.destCityId; - if (typeof destGeneralId !== 'number' || typeof destCityId !== 'number') { - return null; - } - const destGeneral = options.worldRef?.getGeneralById(destGeneralId); - const destCity = options.worldRef?.getCityById(destCityId); - if (!destGeneral || !destCity) { - return null; - } - return { - ...base, - destGeneral, - destCity, - currentYear: options.world.currentYear, - currentMonth: options.world.currentMonth, - turnTermMinutes: resolveTurnTermMinutes(options.world), - generalTurnTime: (base.general as TurnGeneral).turnTime, - destGeneralTurnTime: destGeneral.turnTime, - }; - } - case 'che_징병': - if (!options.map || !options.unitSet) { - return null; - } - return { - ...base, - map: options.map, - unitSet: options.unitSet, - cities: options.worldRef?.listCities() ?? [], - currentYear: options.world.currentYear, - startYear: resolveStartYear(options.world, options.scenarioMeta), - }; - default: - return base; - } -}; + const buildConstraintContext = ( general: TurnGeneral, @@ -664,7 +209,7 @@ const resolveDefinition = ( fallback: GeneralActionDefinition ): GeneralActionDefinition => definitions.get(actionKey) ?? fallback; -export const createReservedTurnHandler = (options: { +export const createReservedTurnHandler = async (options: { reservedTurns: InMemoryReservedTurnStore; scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; @@ -672,10 +217,17 @@ export const createReservedTurnHandler = (options: { map?: MapDefinition; unitSet?: UnitSetDefinition; getWorld: () => InMemoryTurnWorld | null; -}): GeneralTurnHandler => { + commandProfile?: TurnCommandProfile; +}): Promise => { const env = buildCommandEnv(options.scenarioConfig, options.unitSet); - const generalDefinitions = buildGeneralDefinitions(env); - const nationDefinitions = buildNationDefinitions(env); + const commandProfile = + options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE; + const { general: generalDefinitions, nation: nationDefinitions } = + await buildReservedTurnDefinitions({ + env, + commandProfile, + defaultActionKey: DEFAULT_ACTION, + }); const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!; const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!; const diplomacyMap = buildDiplomacyMap(options.diplomacy); diff --git a/app/game-engine/src/turn/turnCommandProfile.ts b/app/game-engine/src/turn/turnCommandProfile.ts new file mode 100644 index 0000000..2228e55 --- /dev/null +++ b/app/game-engine/src/turn/turnCommandProfile.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + DEFAULT_TURN_COMMAND_PROFILE, + parseTurnCommandProfile, + type TurnCommandProfile, +} from '@sammo-ts/logic'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +const DEFAULT_PROFILE_PATH = path.resolve( + REPO_ROOT, + 'resources', + 'turn-commands', + 'default.json' +); + +export interface TurnCommandProfileOptions { + filePath?: string; +} + +const readCommandProfile = async (filePath: string): Promise => { + const raw = await fs.readFile(filePath, 'utf8'); + return parseTurnCommandProfile(JSON.parse(raw) as unknown); +}; + +export const loadTurnCommandProfile = async ( + options?: TurnCommandProfileOptions +): Promise => { + const filePath = + options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH; + try { + return await readCommandProfile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return DEFAULT_TURN_COMMAND_PROFILE; + } + throw error; + } +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index b63c73e..57a34b1 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,4 +1,4 @@ -import type { TurnSchedule } from '@sammo-ts/logic'; +import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic'; import { SystemClock } from '../lifecycle/clock.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; @@ -23,6 +23,7 @@ import { InMemoryTurnStateStore } from './inMemoryStateStore.js'; import { createGatewayProfileGate } from './gatewayProfileGate.js'; import { createReservedTurnHandler } from './reservedTurnHandler.js'; import { createReservedTurnStore } from './reservedTurnStore.js'; +import { loadTurnCommandProfile } from './turnCommandProfile.js'; import { loadTurnWorldFromDatabase } from './worldLoader.js'; export interface TurnDaemonRuntimeOptions { @@ -39,6 +40,8 @@ export interface TurnDaemonRuntimeOptions { calendarHandler?: TurnCalendarHandler; enableDatabaseFlush?: boolean; pauseGateIntervalMs?: number; + commandProfile?: TurnCommandProfile; + commandProfilePath?: string; } export interface TurnDaemonRuntime { @@ -81,12 +84,19 @@ export const createTurnDaemonRuntime = async ( : await createReservedTurnStore({ databaseUrl: options.databaseUrl, }); + const commandProfile = + options.commandProfile ?? + (options.commandProfilePath + ? await loadTurnCommandProfile({ + filePath: options.commandProfilePath, + }) + : await loadTurnCommandProfile()); let worldRef: InMemoryTurnWorld | null = null; const worldOptions: InMemoryTurnWorldOptions = { schedule, generalTurnHandler: options.generalTurnHandler ?? - createReservedTurnHandler({ + (await createReservedTurnHandler({ reservedTurns: reservedTurnStoreHandle!.store, scenarioConfig: snapshot.scenarioConfig, scenarioMeta: snapshot.scenarioMeta, @@ -94,7 +104,8 @@ export const createTurnDaemonRuntime = async ( map: snapshot.map, unitSet: snapshot.unitSet, getWorld: () => worldRef, - }), + commandProfile, + })), calendarHandler: options.calendarHandler, }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); diff --git a/packages/logic/src/actions/index.ts b/packages/logic/src/actions/index.ts index 0fef650..e55e171 100644 --- a/packages/logic/src/actions/index.ts +++ b/packages/logic/src/actions/index.ts @@ -1,5 +1,7 @@ export * from './definition.js'; export * from './engine.js'; +export * from './turn/commandEnv.js'; +export * from './turn/commandProfile.js'; export * from './turn/general/index.js'; export * from './turn/nation/index.js'; export * from './instant/general/index.js'; diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts new file mode 100644 index 0000000..e99ee6b --- /dev/null +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -0,0 +1,24 @@ +export interface TurnCommandEnv { + develCost: number; + trainDelta: number; + atmosDelta: number; + maxTrainByCommand: number; + maxAtmosByCommand: number; + sabotageDefaultProb: number; + sabotageProbCoefByStat: number; + sabotageDefenceCoefByGeneralCount: number; + sabotageDamageMin: number; + sabotageDamageMax: number; + openingPartYear: number; + maxGeneral: number; + defaultNpcGold: number; + defaultNpcRice: number; + defaultCrewTypeId: number; + defaultSpecialDomestic: string | null; + defaultSpecialWar: string | null; + initialNationGenLimit: number; + maxTechLevel: number; + baseGold: number; + baseRice: number; + maxResourceActionAmount: number; +} diff --git a/packages/logic/src/actions/turn/commandProfile.ts b/packages/logic/src/actions/turn/commandProfile.ts new file mode 100644 index 0000000..6e59e3b --- /dev/null +++ b/packages/logic/src/actions/turn/commandProfile.ts @@ -0,0 +1,78 @@ +import { + GENERAL_TURN_COMMAND_KEYS, + isGeneralTurnCommandKey, + type GeneralTurnCommandKey, +} from './general/index.js'; +import { + NATION_TURN_COMMAND_KEYS, + isNationTurnCommandKey, + type NationTurnCommandKey, +} from './nation/index.js'; + +export interface TurnCommandProfile { + general: GeneralTurnCommandKey[]; + nation: NationTurnCommandKey[]; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const asStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value)) { + return null; + } + const list = value.filter((entry): entry is string => typeof entry === 'string'); + return list.length > 0 ? list : null; +}; + +const parseKeyList = < + T extends string +>(options: { + raw: unknown; + defaults: T[]; + isKey: (value: string) => value is T; + label: string; +}): T[] => { + const rawList = asStringArray(options.raw); + if (!rawList) { + return options.defaults; + } + const parsed: T[] = []; + for (const value of rawList) { + if (!options.isKey(value)) { + throw new Error( + `Unknown ${options.label} command key: ${value}` + ); + } + parsed.push(value); + } + return parsed; +}; + +export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = { + general: [...GENERAL_TURN_COMMAND_KEYS], + nation: [...NATION_TURN_COMMAND_KEYS], +}; + +export const parseTurnCommandProfile = ( + raw: unknown, + fallback: TurnCommandProfile = DEFAULT_TURN_COMMAND_PROFILE +): TurnCommandProfile => { + if (!isRecord(raw)) { + return fallback; + } + return { + general: parseKeyList({ + raw: raw.general, + defaults: fallback.general, + isKey: isGeneralTurnCommandKey, + label: 'general', + }), + nation: parseKeyList({ + raw: raw.nation, + defaults: fallback.nation, + isKey: isNationTurnCommandKey, + label: 'nation', + }), + }; +}; diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts index 0b67065..bd64de1 100644 --- a/packages/logic/src/actions/turn/general/che_거병.ts +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -8,6 +8,8 @@ import type { } from '../../engine.js'; import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface UprisingArgs {} @@ -53,3 +55,11 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_거병', + category: '전략', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index 97e39e4..ac62300 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -8,6 +8,8 @@ import type { } from '../../engine.js'; import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface FoundingArgs {} @@ -53,3 +55,11 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_건국', + category: '전략', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_기술연구.ts b/packages/logic/src/actions/turn/general/che_기술연구.ts index c109d49..bdd969f 100644 --- a/packages/logic/src/actions/turn/general/che_기술연구.ts +++ b/packages/logic/src/actions/turn/general/che_기술연구.ts @@ -26,6 +26,8 @@ import { createNationPatchEffect, } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface TechResearchArgs {} @@ -127,3 +129,15 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_기술연구', + category: '내정', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ + costGold: env.develCost, + maxTechLevel: env.maxTechLevel, + }), +}; diff --git a/packages/logic/src/actions/turn/general/che_농지개간.ts b/packages/logic/src/actions/turn/general/che_농지개간.ts index 11a4a01..5b50927 100644 --- a/packages/logic/src/actions/turn/general/che_농지개간.ts +++ b/packages/logic/src/actions/turn/general/che_농지개간.ts @@ -1,5 +1,7 @@ import type { GeneralTriggerState } from '../../../domain/entities.js'; import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -18,3 +20,12 @@ export class ActionDefinition< ); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_농지개간', + category: '내정', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ develCost: env.develCost }), +}; diff --git a/packages/logic/src/actions/turn/general/che_사기진작.ts b/packages/logic/src/actions/turn/general/che_사기진작.ts index 5654cef..47a2316 100644 --- a/packages/logic/src/actions/turn/general/che_사기진작.ts +++ b/packages/logic/src/actions/turn/general/che_사기진작.ts @@ -15,6 +15,8 @@ import type { } from '../../engine.js'; import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface BoostMoraleArgs {} @@ -89,3 +91,15 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_사기진작', + category: '군사', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ + atmosDelta: env.atmosDelta, + maxAtmosByCommand: env.maxAtmosByCommand, + }), +}; diff --git a/packages/logic/src/actions/turn/general/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts index 2e9a4d6..7d4db38 100644 --- a/packages/logic/src/actions/turn/general/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -37,6 +37,8 @@ import { createGeneralPatchEffect, createLogEffect, } from '../../engine.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export type DomesticCriticalPick = 'fail' | 'normal' | 'success'; @@ -437,3 +439,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_상업투자', + category: '내정', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env), +}; diff --git a/packages/logic/src/actions/turn/general/che_성벽보수.ts b/packages/logic/src/actions/turn/general/che_성벽보수.ts index 5eb7e43..ef7e6c2 100644 --- a/packages/logic/src/actions/turn/general/che_성벽보수.ts +++ b/packages/logic/src/actions/turn/general/che_성벽보수.ts @@ -1,5 +1,7 @@ import type { GeneralTriggerState } from '../../../domain/entities.js'; import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -18,3 +20,12 @@ export class ActionDefinition< ); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_성벽보수', + category: '내정', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ develCost: env.develCost }), +}; diff --git a/packages/logic/src/actions/turn/general/che_수비강화.ts b/packages/logic/src/actions/turn/general/che_수비강화.ts index 6d976ac..070c33f 100644 --- a/packages/logic/src/actions/turn/general/che_수비강화.ts +++ b/packages/logic/src/actions/turn/general/che_수비강화.ts @@ -1,5 +1,7 @@ import type { GeneralTriggerState } from '../../../domain/entities.js'; import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -18,3 +20,12 @@ export class ActionDefinition< ); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_수비강화', + category: '내정', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ develCost: env.develCost }), +}; diff --git a/packages/logic/src/actions/turn/general/che_요양.ts b/packages/logic/src/actions/turn/general/che_요양.ts index e92b87e..13f2cad 100644 --- a/packages/logic/src/actions/turn/general/che_요양.ts +++ b/packages/logic/src/actions/turn/general/che_요양.ts @@ -15,6 +15,8 @@ import type { } from '../../engine.js'; import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface RecoveryArgs {} @@ -77,3 +79,11 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_요양', + category: '개인', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_의병모집.ts b/packages/logic/src/actions/turn/general/che_의병모집.ts index 8a84681..d78eb3b 100644 --- a/packages/logic/src/actions/turn/general/che_의병모집.ts +++ b/packages/logic/src/actions/turn/general/che_의병모집.ts @@ -34,6 +34,8 @@ import { import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; import { buildRecruitmentGeneral } from './recruitment.js'; import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface VolunteerRecruitArgs {} @@ -431,3 +433,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_의병모집', + category: '전략', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env), +}; diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 9703b8b..f336efa 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -32,6 +32,8 @@ import { import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; import { buildRecruitmentGeneral } from './recruitment.js'; import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface TalentScoutArgs {} @@ -468,3 +470,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_인재탐색', + category: '인사', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env), +}; diff --git a/packages/logic/src/actions/turn/general/che_임관.ts b/packages/logic/src/actions/turn/general/che_임관.ts index ffe9258..c460683 100644 --- a/packages/logic/src/actions/turn/general/che_임관.ts +++ b/packages/logic/src/actions/turn/general/che_임관.ts @@ -11,6 +11,8 @@ import type { } from '../../engine.js'; import { createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface AppointmentArgs { destNationId: number; @@ -63,3 +65,11 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_임관', + category: '전략', + reqArg: true, + args: { destNationId: 0 }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts index c10b732..09c6726 100644 --- a/packages/logic/src/actions/turn/general/che_주민선정.ts +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -1,5 +1,7 @@ import type { GeneralTriggerState } from '../../../domain/entities.js'; import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -18,3 +20,12 @@ export class ActionDefinition< ); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_주민선정', + category: '내정', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ develCost: env.develCost }), +}; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 41a200f..219a817 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -36,6 +36,8 @@ import { createLogEffect, } from '../../engine.js'; import type { MapDefinition, UnitSetDefinition } from '../../../world/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; import { type CrewTypeAvailabilityContext, findCrewTypeById, @@ -600,3 +602,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_징병', + category: '내정', + reqArg: true, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition([], {}), +}; diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 9617159..8c66d71 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -14,6 +14,8 @@ import type { } from '../../engine.js'; import { createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface DispatchArgs { destCityId: number; @@ -69,3 +71,11 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_출병', + category: '군사', + reqArg: true, + args: { destCityId: 0 }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_치안강화.ts b/packages/logic/src/actions/turn/general/che_치안강화.ts index 9f2975c..1a3cabe 100644 --- a/packages/logic/src/actions/turn/general/che_치안강화.ts +++ b/packages/logic/src/actions/turn/general/che_치안강화.ts @@ -1,5 +1,7 @@ import type { GeneralTriggerState } from '../../../domain/entities.js'; import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -18,3 +20,12 @@ export class ActionDefinition< ); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_치안강화', + category: '내정', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ develCost: env.develCost }), +}; diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 047a993..c1271b2 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -36,6 +36,8 @@ import { createLogEffect, } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface FireAttackArgs { destCityId: number; @@ -503,3 +505,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_화계', + category: '계략', + reqArg: true, + args: { destCityId: 0 }, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition([], env), +}; diff --git a/packages/logic/src/actions/turn/general/che_훈련.ts b/packages/logic/src/actions/turn/general/che_훈련.ts index b0f12e2..460f10e 100644 --- a/packages/logic/src/actions/turn/general/che_훈련.ts +++ b/packages/logic/src/actions/turn/general/che_훈련.ts @@ -15,6 +15,8 @@ import type { } from '../../engine.js'; import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface TrainingArgs {} @@ -89,3 +91,15 @@ export class ActionDefinition< }; } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_훈련', + category: '군사', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ + trainDelta: env.trainDelta, + maxTrainByCommand: env.maxTrainByCommand, + }), +}; diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 51116dd..90174ff 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -1,3 +1,25 @@ +import type { GeneralActionDefinition } from '../../definition.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type * as UprisingModule from './che_거병.js'; +import type * as AppointmentModule from './che_임관.js'; +import type * as FoundingModule from './che_건국.js'; +import type * as TrainingModule from './che_훈련.js'; +import type * as BoostMoraleModule from './che_사기진작.js'; +import type * as RecoveryModule from './che_요양.js'; +import type * as DispatchModule from './che_출병.js'; +import type * as ResidentsSelectionModule from './che_주민선정.js'; +import type * as FarmingModule from './che_농지개간.js'; +import type * as CommerceInvestmentModule from './che_상업투자.js'; +import type * as TechResearchModule from './che_기술연구.js'; +import type * as SecurityUpgradeModule from './che_치안강화.js'; +import type * as DefenceUpgradeModule from './che_수비강화.js'; +import type * as WallRepairModule from './che_성벽보수.js'; +import type * as FireAttackModule from './che_화계.js'; +import type * as TalentScoutModule from './che_인재탐색.js'; +import type * as VolunteerRecruitModule from './che_의병모집.js'; +import type * as RecruitModule from './che_징병.js'; +import type * as RestModule from './휴식.js'; + export type GeneralTurnCommandKey = | 'che_거병' | 'che_임관' @@ -19,25 +41,40 @@ export type GeneralTurnCommandKey = | 'che_징병' | '휴식'; -import type * as UprisingModule from './che_거병.js'; -import type * as AppointmentModule from './che_임관.js'; -import type * as FoundingModule from './che_건국.js'; -import type * as TrainingModule from './che_훈련.js'; -import type * as BoostMoraleModule from './che_사기진작.js'; -import type * as RecoveryModule from './che_요양.js'; -import type * as DispatchModule from './che_출병.js'; -import type * as ResidentsSelectionModule from './che_주민선정.js'; -import type * as FarmingModule from './che_농지개간.js'; -import type * as CommerceInvestmentModule from './che_상업투자.js'; -import type * as TechResearchModule from './che_기술연구.js'; -import type * as SecurityUpgradeModule from './che_치안강화.js'; -import type * as DefenceUpgradeModule from './che_수비강화.js'; -import type * as WallRepairModule from './che_성벽보수.js'; -import type * as FireAttackModule from './che_화계.js'; -import type * as TalentScoutModule from './che_인재탐색.js'; -import type * as VolunteerRecruitModule from './che_의병모집.js'; -import type * as RecruitModule from './che_징병.js'; -import type * as RestModule from './휴식.js'; +export const GENERAL_TURN_COMMAND_KEYS: GeneralTurnCommandKey[] = [ + 'che_거병', + 'che_임관', + 'che_건국', + 'che_훈련', + 'che_사기진작', + 'che_요양', + 'che_출병', + 'che_주민선정', + 'che_농지개간', + 'che_상업투자', + 'che_기술연구', + 'che_치안강화', + 'che_수비강화', + 'che_성벽보수', + 'che_화계', + 'che_인재탐색', + 'che_의병모집', + 'che_징병', + '휴식', +]; + +export const isGeneralTurnCommandKey = ( + value: string +): value is GeneralTurnCommandKey => + (GENERAL_TURN_COMMAND_KEYS as string[]).includes(value); + +export interface GeneralTurnCommandSpec { + key: GeneralTurnCommandKey; + category: string; + reqArg: boolean; + args: Record; + createDefinition(env: TurnCommandEnv): GeneralActionDefinition; +} export type GeneralTurnCommandModule = | typeof UprisingModule @@ -106,6 +143,26 @@ export class GeneralTurnCommandLoader { } } +export const loadGeneralTurnCommandSpecs = async ( + keys: GeneralTurnCommandKey[], + loader: GeneralTurnCommandLoader = new GeneralTurnCommandLoader() +): Promise => { + const specs: GeneralTurnCommandSpec[] = []; + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + continue; + } + seen.add(key); + const module = await loader.load(key); + if (!('commandSpec' in module)) { + throw new Error(`Missing commandSpec for general command: ${key}`); + } + specs.push(module.commandSpec); + } + return specs; +}; + export { ActionDefinition as UprisingActionDefinition, } from './che_거병.js'; diff --git a/packages/logic/src/actions/turn/general/휴식.ts b/packages/logic/src/actions/turn/general/휴식.ts index 77ee379..1e7b35d 100644 --- a/packages/logic/src/actions/turn/general/휴식.ts +++ b/packages/logic/src/actions/turn/general/휴식.ts @@ -12,6 +12,8 @@ import type { } from '../../engine.js'; import { createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; export interface RestArgs {} @@ -66,3 +68,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: GeneralTurnCommandSpec = { + key: '휴식', + category: '개인', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/nation/che_발령.ts b/packages/logic/src/actions/turn/nation/che_발령.ts index 6f38587..d6a4d16 100644 --- a/packages/logic/src/actions/turn/nation/che_발령.ts +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -28,6 +28,8 @@ import type { import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; export interface AssignmentArgs { destGeneralId: number; @@ -206,3 +208,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_발령', + category: '인사', + reqArg: true, + args: { destGeneralId: 0, destCityId: 0 }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition({}), +}; diff --git a/packages/logic/src/actions/turn/nation/che_선전포고.ts b/packages/logic/src/actions/turn/nation/che_선전포고.ts index 4dd79ae..00ec674 100644 --- a/packages/logic/src/actions/turn/nation/che_선전포고.ts +++ b/packages/logic/src/actions/turn/nation/che_선전포고.ts @@ -14,6 +14,8 @@ import type { } from '../../engine.js'; import { createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; export interface DeclareWarArgs { destNationId: number; @@ -72,3 +74,11 @@ export class ActionDefinition< }; } } + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_선전포고', + category: '외교', + reqArg: true, + args: { destNationId: 0 }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/nation/che_포상.ts b/packages/logic/src/actions/turn/nation/che_포상.ts index 2855f0b..b3cb389 100644 --- a/packages/logic/src/actions/turn/nation/che_포상.ts +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -32,6 +32,8 @@ import { createNationPatchEffect, } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; export interface AwardArgs { @@ -266,3 +268,21 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_포상', + category: '인사', + reqArg: true, + args: { isGold: true, amount: 1, destGeneralId: 0 }, + createDefinition: (env: TurnCommandEnv) => { + const maxAmount = + env.maxResourceActionAmount > 0 + ? env.maxResourceActionAmount + : Math.max(env.baseGold, env.baseRice, 1000); + return new ActionDefinition({ + baseGold: env.baseGold, + baseRice: env.baseRice, + maxAmount, + }); + }, +}; diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index 89c3e91..4249e77 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -1,13 +1,35 @@ +import type { GeneralActionDefinition } from '../../definition.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type * as NationRestModule from './휴식.js'; +import type * as AwardModule from './che_포상.js'; +import type * as AssignmentModule from './che_발령.js'; +import type * as DeclarationModule from './che_선전포고.js'; + export type NationTurnCommandKey = | '휴식' | 'che_포상' | 'che_발령' | 'che_선전포고'; -import type * as NationRestModule from './휴식.js'; -import type * as AwardModule from './che_포상.js'; -import type * as AssignmentModule from './che_발령.js'; -import type * as DeclarationModule from './che_선전포고.js'; +export const NATION_TURN_COMMAND_KEYS: NationTurnCommandKey[] = [ + '휴식', + 'che_포상', + 'che_발령', + 'che_선전포고', +]; + +export const isNationTurnCommandKey = ( + value: string +): value is NationTurnCommandKey => + (NATION_TURN_COMMAND_KEYS as string[]).includes(value); + +export interface NationTurnCommandSpec { + key: NationTurnCommandKey; + category: string; + reqArg: boolean; + args: Record; + createDefinition(env: TurnCommandEnv): GeneralActionDefinition; +} export type NationTurnCommandModule = | typeof NationRestModule @@ -46,6 +68,26 @@ export class NationTurnCommandLoader { } } +export const loadNationTurnCommandSpecs = async ( + keys: NationTurnCommandKey[], + loader: NationTurnCommandLoader = new NationTurnCommandLoader() +): Promise => { + const specs: NationTurnCommandSpec[] = []; + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + continue; + } + seen.add(key); + const module = await loader.load(key); + if (!('commandSpec' in module)) { + throw new Error(`Missing commandSpec for nation command: ${key}`); + } + specs.push(module.commandSpec); + } + return specs; +}; + export { ActionDefinition as NationRestActionDefinition, ActionResolver as NationRestActionResolver, diff --git a/packages/logic/src/actions/turn/nation/휴식.ts b/packages/logic/src/actions/turn/nation/휴식.ts index 3f09ed6..b87101b 100644 --- a/packages/logic/src/actions/turn/nation/휴식.ts +++ b/packages/logic/src/actions/turn/nation/휴식.ts @@ -10,6 +10,8 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '../../engine.js'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; export interface NationRestArgs {} @@ -56,3 +58,11 @@ export class ActionDefinition< return this.resolver.resolve(context, args); } } + +export const commandSpec: NationTurnCommandSpec = { + key: '휴식', + category: '휴식', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json new file mode 100644 index 0000000..c9cc037 --- /dev/null +++ b/resources/turn-commands/default.json @@ -0,0 +1,29 @@ +{ + "general": [ + "che_거병", + "che_임관", + "che_건국", + "che_훈련", + "che_사기진작", + "che_요양", + "che_출병", + "che_주민선정", + "che_농지개간", + "che_상업투자", + "che_기술연구", + "che_치안강화", + "che_수비강화", + "che_성벽보수", + "che_화계", + "che_인재탐색", + "che_의병모집", + "che_징병", + "휴식" + ], + "nation": [ + "휴식", + "che_포상", + "che_발령", + "che_선전포고" + ] +}