feat: Implement general and nation turn commands with command specifications

- Added command specifications for various general turn commands including talent scouting, appointments, and military actions.
- Introduced command specifications for nation turn commands such as assignments and declarations of war.
- Created a command environment interface to encapsulate command-related parameters.
- Developed a command profile loader to manage default and custom command profiles.
- Established a reserved turn action context to facilitate command execution with necessary context data.
- Updated the default command profile JSON to include new commands and their configurations.
This commit is contained in:
2026-01-01 15:17:30 +00:00
parent 49942cea5e
commit bfbcb4472e
36 changed files with 1089 additions and 750 deletions
+42 -255
View File
@@ -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<TurnCommandTable> => {
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
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),
};
};
@@ -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<TurnCommandProfile> => {
const raw = await fs.readFile(filePath, 'utf8');
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
};
export const loadTurnCommandProfile = async (
options?: TurnCommandProfileOptions
): Promise<TurnCommandProfile> => {
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;
}
};
@@ -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<string, unknown>;
export interface ActionContextOptions {
world: TurnWorldState;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
worldRef: InMemoryTurnWorld | null;
actionArgs: Record<string, unknown>;
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<string, ActionContextBuilder> = {
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;
};
@@ -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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
const resolveNumber = (
source: Record<string, unknown>,
keys: string[],
fallback: number
): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const resolveOptionalString = (
source: Record<string, unknown>,
keys: string[]
): string | null => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string') {
return normalizeCode(value);
}
}
return null;
};
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<string, GeneralActionDefinition>,
fallbackKey: GeneralTurnCommandKey,
env: TurnCommandEnv
): Promise<void> => {
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<string, GeneralActionDefinition>;
nation: Map<string, GeneralActionDefinition>;
}> => {
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 };
};
+21 -469
View File
@@ -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<string, unknown> =>
@@ -98,134 +42,6 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
const asRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
const resolveNumber = (
source: Record<string, unknown>,
keys: string[],
fallback: number
): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const resolveOptionalString = (
source: Record<string, unknown>,
keys: string[]
): string | null => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string') {
return normalizeCode(value);
}
}
return null;
};
const buildCommandEnv = (
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<string, GeneralActionDefinition> => {
const definitions = new Map<string, GeneralActionDefinition>();
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<string, GeneralActionDefinition> => {
const definitions = new Map<string, GeneralActionDefinition>();
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<string, unknown> =>
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<string, unknown>;
const buildActionContext = (
key: string,
base: ActionContextBase,
options: {
world: TurnWorldState;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
worldRef: InMemoryTurnWorld | null;
actionArgs: Record<string, unknown>;
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<GeneralTurnHandler> => {
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);
@@ -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<TurnCommandProfile> => {
const raw = await fs.readFile(filePath, 'utf8');
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
};
export const loadTurnCommandProfile = async (
options?: TurnCommandProfileOptions
): Promise<TurnCommandProfile> => {
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;
}
};
+14 -3
View File
@@ -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);
+2
View File
@@ -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';
@@ -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;
}
@@ -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<string, unknown> =>
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',
}),
};
};
@@ -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(),
};
@@ -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(),
};
@@ -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,
}),
};
@@ -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 }),
};
@@ -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,
}),
};
@@ -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),
};
@@ -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 }),
};
@@ -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 }),
};
@@ -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(),
};
@@ -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),
};
@@ -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),
};
@@ -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(),
};
@@ -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 }),
};
@@ -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([], {}),
};
@@ -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(),
};
@@ -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 }),
};
@@ -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),
};
@@ -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,
}),
};
@@ -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<string, unknown>;
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<GeneralTurnCommandSpec[]> => {
const specs: GeneralTurnCommandSpec[] = [];
const seen = new Set<string>();
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';
@@ -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(),
};
@@ -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({}),
};
@@ -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(),
};
@@ -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,
});
},
};
@@ -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<string, unknown>;
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<NationTurnCommandSpec[]> => {
const specs: NationTurnCommandSpec[] = [];
const seen = new Set<string>();
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,
@@ -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(),
};
+29
View File
@@ -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_선전포고"
]
}