- Introduced new general turn commands: 'che_거병', 'che_임관', 'che_건국', 'che_훈련', 'che_사기진작', 'che_요양', 'che_출병', 'che_주민선정', 'che_농지개간', 'che_기술연구', 'che_치안강화', 'che_수비강화', 'che_성벽보수'. - Implemented action definitions for each new command, including constraints and resolution logic. - Added new constraints: 'beNeutral', 'remainCityCapacityByMax', 'existsDestNation'. - Enhanced city development actions for '농지개간', '성벽보수', and '수비강화'. - Created a new action for declaring war: 'che_선전포고' with appropriate constraints. - Updated existing action definitions to accommodate new game mechanics and ensure proper functionality.
900 lines
30 KiB
TypeScript
900 lines
30 KiB
TypeScript
import type {
|
|
City,
|
|
General,
|
|
GeneralActionDefinition,
|
|
GeneralActionResolveContext,
|
|
LogEntryDraft,
|
|
MapDefinition,
|
|
Nation,
|
|
ScenarioConfig,
|
|
ScenarioDiplomacy,
|
|
ScenarioMeta,
|
|
Troop,
|
|
UnitSetDefinition,
|
|
} from '@sammo-ts/logic';
|
|
import {
|
|
AppointmentActionDefinition,
|
|
AssignmentActionDefinition,
|
|
BoostMoraleActionDefinition,
|
|
AwardActionDefinition,
|
|
CommerceInvestmentActionDefinition,
|
|
DeclarationActionDefinition,
|
|
DefenceUpgradeActionDefinition,
|
|
DispatchActionDefinition,
|
|
evaluateConstraints,
|
|
FireAttackActionDefinition,
|
|
FarmingActionDefinition,
|
|
FoundingActionDefinition,
|
|
NationRestActionDefinition,
|
|
RecoveryActionDefinition,
|
|
ResidentsSelectionActionDefinition,
|
|
RecruitActionDefinition,
|
|
resolveGeneralAction,
|
|
RestActionDefinition,
|
|
SecurityUpgradeActionDefinition,
|
|
TechResearchActionDefinition,
|
|
TalentScoutActionDefinition,
|
|
TrainingActionDefinition,
|
|
UprisingActionDefinition,
|
|
VolunteerRecruitActionDefinition,
|
|
} from '@sammo-ts/logic';
|
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
|
import { LiteHashDRBG } from '@sammo-ts/common';
|
|
|
|
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
|
|
|
import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js';
|
|
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';
|
|
|
|
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> =>
|
|
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;
|
|
};
|
|
|
|
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
|
|
): Record<string, unknown> => {
|
|
const startYear =
|
|
typeof scenarioMeta?.startYear === 'number'
|
|
? scenarioMeta.startYear
|
|
: undefined;
|
|
const relYear =
|
|
typeof startYear === 'number'
|
|
? world.currentYear - startYear
|
|
: undefined;
|
|
|
|
return {
|
|
currentYear: world.currentYear,
|
|
currentMonth: world.currentMonth,
|
|
year: world.currentYear,
|
|
month: world.currentMonth,
|
|
startYear,
|
|
relYear,
|
|
};
|
|
};
|
|
|
|
const buildDiplomacyMap = (
|
|
diplomacy: ScenarioDiplomacy[]
|
|
): Map<string, number> => {
|
|
const map = new Map<string, number>();
|
|
for (const row of diplomacy) {
|
|
map.set(`${row.fromNationId}:${row.toNationId}`, row.state);
|
|
}
|
|
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);
|
|
const rawSeed = meta.hiddenSeed ?? meta.seed ?? world.id;
|
|
return String(rawSeed);
|
|
};
|
|
|
|
const serializeSeed = (...values: Array<string | number>): string =>
|
|
values
|
|
.map((value) =>
|
|
typeof value === 'string'
|
|
? `str(${value.length},${value})`
|
|
: `int(${Math.floor(value)})`
|
|
)
|
|
.join('|');
|
|
|
|
class DeterministicRandom {
|
|
constructor(private readonly rng: LiteHashDRBG) {}
|
|
|
|
nextFloat(): number {
|
|
return this.rng.nextFloat1();
|
|
}
|
|
|
|
nextBool(probability: number): boolean {
|
|
if (probability >= 1) {
|
|
return true;
|
|
}
|
|
if (probability <= 0) {
|
|
return false;
|
|
}
|
|
return this.nextFloat() < probability;
|
|
}
|
|
|
|
nextInt(minInclusive: number, maxExclusive: number): number {
|
|
const span = maxExclusive - minInclusive;
|
|
if (span <= 1) {
|
|
return minInclusive;
|
|
}
|
|
return minInclusive + this.rng.nextInt(span - 1);
|
|
}
|
|
}
|
|
|
|
class WorldStateView implements StateView {
|
|
constructor(
|
|
private readonly world: InMemoryTurnWorld | null,
|
|
private readonly diplomacy: Map<string, number>,
|
|
private readonly env: Record<string, unknown>,
|
|
private readonly args: Record<string, unknown>,
|
|
private readonly overrides?: {
|
|
general?: TurnGeneral;
|
|
city?: City;
|
|
nation?: Nation | null;
|
|
}
|
|
) {}
|
|
|
|
has(req: Parameters<StateView['has']>[0]): boolean {
|
|
return this.get(req) !== null;
|
|
}
|
|
|
|
get(req: Parameters<StateView['get']>[0]): unknown | null {
|
|
if (!this.world) {
|
|
return null;
|
|
}
|
|
switch (req.kind) {
|
|
case 'general':
|
|
if (this.overrides?.general && this.overrides.general.id === req.id) {
|
|
return this.overrides.general;
|
|
}
|
|
return this.world.getGeneralById(req.id);
|
|
case 'destGeneral':
|
|
return this.world.getGeneralById(req.id);
|
|
case 'city':
|
|
if (this.overrides?.city && this.overrides.city.id === req.id) {
|
|
return this.overrides.city;
|
|
}
|
|
return this.world.getCityById(req.id);
|
|
case 'destCity':
|
|
return this.world.getCityById(req.id);
|
|
case 'nation':
|
|
if (this.overrides?.nation && this.overrides.nation.id === req.id) {
|
|
return this.overrides.nation;
|
|
}
|
|
return this.world.getNationById(req.id);
|
|
case 'destNation':
|
|
return this.world.getNationById(req.id);
|
|
case 'diplomacy':
|
|
return this.diplomacy.get(
|
|
`${req.srcNationId}:${req.destNationId}`
|
|
) ?? null;
|
|
case 'arg':
|
|
return this.args[req.key] ?? null;
|
|
case 'env':
|
|
return this.env[req.key] ?? null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
city: City | undefined,
|
|
nation: Nation | null | undefined,
|
|
args: Record<string, unknown>,
|
|
env: Record<string, unknown>
|
|
): ConstraintContext => ({
|
|
actorId: general.id,
|
|
cityId: city?.id,
|
|
nationId: nation?.id,
|
|
args,
|
|
env,
|
|
mode: 'full',
|
|
});
|
|
|
|
const createActionLog = (message: string): LogEntryDraft => ({
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.ACTION,
|
|
format: LogFormat.MONTH,
|
|
text: message,
|
|
});
|
|
|
|
const resolveDefinition = (
|
|
actionKey: string,
|
|
definitions: Map<string, GeneralActionDefinition>,
|
|
fallback: GeneralActionDefinition
|
|
): GeneralActionDefinition => definitions.get(actionKey) ?? fallback;
|
|
|
|
export const createReservedTurnHandler = (options: {
|
|
reservedTurns: InMemoryReservedTurnStore;
|
|
scenarioConfig: ScenarioConfig;
|
|
scenarioMeta?: ScenarioMeta;
|
|
diplomacy: ScenarioDiplomacy[];
|
|
map?: MapDefinition;
|
|
unitSet?: UnitSetDefinition;
|
|
getWorld: () => InMemoryTurnWorld | null;
|
|
}): GeneralTurnHandler => {
|
|
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
|
|
const generalDefinitions = buildGeneralDefinitions(env);
|
|
const nationDefinitions = buildNationDefinitions(env);
|
|
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
|
|
const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!;
|
|
const diplomacyMap = buildDiplomacyMap(options.diplomacy);
|
|
|
|
let nextGeneralId: number | null = null;
|
|
const createGeneralId = (): number => {
|
|
if (nextGeneralId === null) {
|
|
const world = options.getWorld();
|
|
const ids = world ? world.listGenerals().map((general) => general.id) : [];
|
|
nextGeneralId = ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
}
|
|
const result = nextGeneralId;
|
|
nextGeneralId += 1;
|
|
return result;
|
|
};
|
|
|
|
return {
|
|
execute(context): GeneralTurnResult {
|
|
const worldRef = options.getWorld();
|
|
const constraintEnv = {
|
|
...resolveConstraintEnv(context.world, options.scenarioMeta),
|
|
...(options.map ? { map: options.map } : {}),
|
|
...(options.unitSet ? { unitSet: options.unitSet } : {}),
|
|
cities: worldRef?.listCities() ?? [],
|
|
};
|
|
const logs: LogEntryDraft[] = [];
|
|
const patches = {
|
|
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
|
|
cities: [] as Array<{ id: number; patch: Partial<City> }>,
|
|
nations: [] as Array<{ id: number; patch: Partial<Nation> }>,
|
|
troops: [] as Array<{ id: number; patch: Partial<Troop> }>,
|
|
};
|
|
const created: TurnGeneral[] = [];
|
|
|
|
let currentGeneral = context.general;
|
|
let currentCity = context.city;
|
|
let currentNation = context.nation ?? null;
|
|
|
|
const runAction = (
|
|
definitionMap: Map<string, GeneralActionDefinition>,
|
|
fallbackDefinition: GeneralActionDefinition,
|
|
command: ReservedTurnEntry,
|
|
applyNextTurnAt: boolean
|
|
): Date | undefined => {
|
|
const resolvedDefinition = resolveDefinition(
|
|
command.action,
|
|
definitionMap,
|
|
fallbackDefinition
|
|
);
|
|
const rawArgs = extractArgsRecord(command.args);
|
|
let parsedArgs = resolvedDefinition.parseArgs(rawArgs);
|
|
let definition = resolvedDefinition;
|
|
let actionArgs = parsedArgs ?? {};
|
|
let actionKey = definition.key;
|
|
|
|
if (parsedArgs === null) {
|
|
definition = fallbackDefinition;
|
|
actionArgs = definition.parseArgs({}) ?? {};
|
|
actionKey = definition.key;
|
|
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
|
}
|
|
|
|
const constraintCtx = buildConstraintContext(
|
|
currentGeneral,
|
|
currentCity,
|
|
currentNation,
|
|
actionArgs as Record<string, unknown>,
|
|
constraintEnv
|
|
);
|
|
const view = new WorldStateView(
|
|
worldRef,
|
|
diplomacyMap,
|
|
constraintEnv,
|
|
actionArgs as Record<string, unknown>,
|
|
{
|
|
general: currentGeneral,
|
|
city: currentCity,
|
|
nation: currentNation,
|
|
}
|
|
);
|
|
const constraints = definition.buildConstraints(
|
|
constraintCtx,
|
|
actionArgs
|
|
);
|
|
const result = evaluateConstraints(constraints, constraintCtx, view);
|
|
if (result.kind !== 'allow') {
|
|
definition = fallbackDefinition;
|
|
actionArgs = definition.parseArgs({}) ?? {};
|
|
actionKey = definition.key;
|
|
const reason =
|
|
result.kind === 'deny'
|
|
? result.reason
|
|
: '조건을 확인할 수 없습니다.';
|
|
logs.push(createActionLog(reason));
|
|
}
|
|
|
|
const seedBase = buildSeedBase(context.world);
|
|
const buildRng = (key: string) => {
|
|
const rngSeed = serializeSeed(
|
|
seedBase,
|
|
key,
|
|
context.world.currentYear,
|
|
context.world.currentMonth,
|
|
currentGeneral.id
|
|
);
|
|
return new DeterministicRandom(new LiteHashDRBG(rngSeed));
|
|
};
|
|
|
|
const actionArgsRecord = extractArgsRecord(actionArgs);
|
|
let baseContext: ActionContextBase = {
|
|
general: currentGeneral,
|
|
city: currentCity,
|
|
nation: currentNation,
|
|
rng: buildRng(actionKey),
|
|
};
|
|
let specificContext = buildActionContext(actionKey, baseContext, {
|
|
world: context.world,
|
|
scenarioMeta: options.scenarioMeta,
|
|
map: options.map,
|
|
unitSet: options.unitSet,
|
|
worldRef,
|
|
actionArgs: actionArgsRecord,
|
|
createGeneralId,
|
|
});
|
|
if (!specificContext && actionKey !== fallbackDefinition.key) {
|
|
definition = fallbackDefinition;
|
|
actionArgs = definition.parseArgs({}) ?? {};
|
|
actionKey = definition.key;
|
|
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
|
baseContext = {
|
|
general: currentGeneral,
|
|
city: currentCity,
|
|
nation: currentNation,
|
|
rng: buildRng(actionKey),
|
|
};
|
|
specificContext = baseContext;
|
|
}
|
|
const actionContext = specificContext ?? baseContext;
|
|
|
|
const resolution = resolveGeneralAction(
|
|
definition,
|
|
actionContext as GeneralActionResolveContext,
|
|
{
|
|
now: currentGeneral.turnTime,
|
|
schedule: context.schedule,
|
|
},
|
|
actionArgs
|
|
);
|
|
|
|
currentGeneral = resolution.general as TurnGeneral;
|
|
currentCity = resolution.city ?? currentCity;
|
|
currentNation = resolution.nation ?? currentNation;
|
|
logs.push(...resolution.logs);
|
|
|
|
if (resolution.patches) {
|
|
patches.generals.push(
|
|
...(resolution.patches.generals as Array<{
|
|
id: number;
|
|
patch: Partial<TurnGeneral>;
|
|
}>)
|
|
);
|
|
patches.cities.push(
|
|
...(resolution.patches.cities as Array<{
|
|
id: number;
|
|
patch: Partial<City>;
|
|
}>)
|
|
);
|
|
patches.nations.push(
|
|
...(resolution.patches.nations as Array<{
|
|
id: number;
|
|
patch: Partial<Nation>;
|
|
}>)
|
|
);
|
|
}
|
|
|
|
if (resolution.created?.generals) {
|
|
created.push(...(resolution.created.generals as TurnGeneral[]));
|
|
}
|
|
|
|
return applyNextTurnAt ? resolution.nextTurnAt : undefined;
|
|
};
|
|
|
|
if (currentNation && currentGeneral.officerLevel >= 5) {
|
|
const nationCommand = options.reservedTurns.getNationTurn(
|
|
currentNation.id,
|
|
currentGeneral.officerLevel,
|
|
0
|
|
);
|
|
runAction(nationDefinitions, nationFallback, nationCommand, false);
|
|
options.reservedTurns.shiftNationTurns(
|
|
currentNation.id,
|
|
currentGeneral.officerLevel,
|
|
-1
|
|
);
|
|
}
|
|
|
|
const generalCommand = options.reservedTurns.getGeneralTurn(
|
|
currentGeneral.id,
|
|
0
|
|
);
|
|
const nextTurnAt = runAction(
|
|
generalDefinitions,
|
|
generalFallback,
|
|
generalCommand,
|
|
true
|
|
);
|
|
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
|
|
|
|
const result: GeneralTurnResult = {
|
|
general: currentGeneral,
|
|
city: currentCity,
|
|
nation: currentNation,
|
|
nextTurnAt,
|
|
logs,
|
|
patches,
|
|
created: created.length > 0 ? { generals: created } : undefined,
|
|
};
|
|
|
|
return result;
|
|
},
|
|
};
|
|
};
|