feat: enhance war action handling with new configurations and context management

This commit is contained in:
2026-01-02 19:35:36 +00:00
parent fba363c6ce
commit ea2ef91b31
4 changed files with 653 additions and 8 deletions
@@ -3,7 +3,11 @@ import type {
General,
MapDefinition,
Nation,
ScenarioConfig,
ScenarioMeta,
WarAftermathConfig,
WarEngineConfig,
WarTimeContext,
UnitSetDefinition,
} from '@sammo-ts/logic';
@@ -39,12 +43,14 @@ export type ActionResolveContext = ActionContextBase & Record<string, unknown>;
export interface ActionContextOptions {
world: TurnWorldState;
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
worldRef: InMemoryTurnWorld | null;
actionArgs: Record<string, unknown>;
createGeneralId: () => number;
seedBase: string;
}
type ActionContextBuilder = (
@@ -141,6 +147,166 @@ const resolveStartYear = (
const resolveTurnTermMinutes = (world: TurnWorldState): number =>
Math.max(1, Math.round(world.tickSeconds / 60));
const DEFAULT_WAR_CONFIG = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
};
const DEFAULT_AFTER_CONFIG = {
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
defaultCityWall: 1000,
};
const asRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const resolveNumber = (
record: Record<string, unknown>,
keys: string[],
fallback: number
): number => {
for (const key of keys) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const resolveCastleCrewTypeId = (
unitSet: UnitSetDefinition,
fallback: number
): number => {
const crewTypes = unitSet.crewTypes ?? [];
const byName = crewTypes.find((crewType) => crewType.name.includes('성벽'));
if (byName) {
return byName.id;
}
const byRequirement = crewTypes.find((crewType) =>
crewType.requirements.some(
(requirement) => requirement.type === 'Impossible'
)
);
if (byRequirement) {
return byRequirement.id;
}
if (typeof unitSet.defaultCrewTypeId === 'number') {
return unitSet.defaultCrewTypeId;
}
return crewTypes[0]?.id ?? fallback;
};
const resolveCastleArmType = (
unitSet: UnitSetDefinition,
castleCrewTypeId: number
): number => {
const crewTypes = unitSet.crewTypes ?? [];
return (
crewTypes.find((crewType) => crewType.id === castleCrewTypeId)?.armType ??
0
);
};
const buildWarConfig = (
scenarioConfig: ScenarioConfig,
unitSet: UnitSetDefinition
): WarEngineConfig => {
const constValues = asRecord(scenarioConfig.const);
const castleCrewTypeId = resolveNumber(
constValues,
['castleCrewTypeId'],
resolveCastleCrewTypeId(unitSet, 0)
);
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
return {
armPerPhase: resolveNumber(
constValues,
['armPerPhase', 'armperphase'],
DEFAULT_WAR_CONFIG.armPerPhase
),
maxTrainByCommand: resolveNumber(
constValues,
['maxTrainByCommand'],
DEFAULT_WAR_CONFIG.maxTrainByCommand
),
maxAtmosByCommand: resolveNumber(
constValues,
['maxAtmosByCommand'],
DEFAULT_WAR_CONFIG.maxAtmosByCommand
),
maxTrainByWar: resolveNumber(
constValues,
['maxTrainByWar'],
DEFAULT_WAR_CONFIG.maxTrainByWar
),
maxAtmosByWar: resolveNumber(
constValues,
['maxAtmosByWar'],
DEFAULT_WAR_CONFIG.maxAtmosByWar
),
castleCrewTypeId,
armTypes: {
footman: 1,
archer: 2,
cavalry: 3,
wizard: 4,
siege: 5,
misc: 6,
castle: castleArmType,
},
};
};
const buildWarAftermathConfig = (
scenarioConfig: ScenarioConfig,
castleCrewTypeId: number
): WarAftermathConfig => {
const constValues = asRecord(scenarioConfig.const);
return {
initialNationGenLimit: resolveNumber(
constValues,
['initialNationGenLimit'],
0
),
techLevelIncYear: resolveNumber(
constValues,
['techLevelIncYear'],
DEFAULT_AFTER_CONFIG.techLevelIncYear
),
initialAllowedTechLevel: resolveNumber(
constValues,
['initialAllowedTechLevel'],
DEFAULT_AFTER_CONFIG.initialAllowedTechLevel
),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
defaultCityWall: resolveNumber(
constValues,
['defaultCityWall'],
DEFAULT_AFTER_CONFIG.defaultCityWall
),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
castleCrewTypeId,
};
};
const buildWarTime = (
world: TurnWorldState,
scenarioMeta?: ScenarioMeta
): WarTimeContext => ({
year: world.currentYear,
month: world.currentMonth,
startYear: resolveStartYear(world, scenarioMeta),
});
// 커맨드별로 필요한 컨텍스트 확장 데이터를 구성한다.
const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
che_인재탐색: (base, options) => ({
@@ -221,6 +387,41 @@ const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
}),
che_출병: (base, options) => {
if (!options.unitSet || !options.worldRef) {
return null;
}
const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') {
return null;
}
const destCity = options.worldRef.getCityById(destCityId);
if (!destCity) {
return null;
}
const destNation =
destCity.nationId > 0
? options.worldRef.getNationById(destCity.nationId)
: null;
const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet);
const aftermathConfig = buildWarAftermathConfig(
options.scenarioConfig,
warConfig.castleCrewTypeId
);
return {
...base,
destCity,
destNation,
cities: options.worldRef.listCities(),
nations: options.worldRef.listNations(),
generals: options.worldRef.listGenerals(),
unitSet: options.unitSet,
time: buildWarTime(options.world, options.scenarioMeta),
seedBase: options.seedBase,
warConfig,
aftermathConfig,
};
},
};
export const buildActionContext = (
@@ -329,12 +329,14 @@ export const createReservedTurnHandler = async (options: {
};
let specificContext = buildActionContext(actionKey, baseContext, {
world: context.world,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
worldRef,
actionArgs: actionArgsRecord,
createGeneralId,
seedBase,
});
if (!specificContext && actionKey !== fallbackDefinition.key) {
definition = fallbackDefinition;
@@ -1,4 +1,9 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import type {
City,
General,
GeneralTriggerState,
Nation,
} from '../../../domain/entities.js';
import type { Constraint, ConstraintContext } from '../../../constraints/types.js';
import {
existsDestCity,
@@ -9,17 +14,47 @@ import {
} from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { LogCategory, LogFormat } from '../../../logging/types.js';
import {
createCityPatchEffect,
createDiplomacyPatchEffect,
createGeneralPatchEffect,
createNationPatchEffect,
} from '../../engine.js';
import type { TurnCommandEnv } from '../commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js';
import type {
WarAftermathConfig,
WarEngineConfig,
WarTimeContext,
} from '../../../war/types.js';
import { resolveWarAftermath } from '../../../war/aftermath.js';
import { resolveWarBattle } from '../../../war/engine.js';
import { simpleSerialize } from '../../../war/utils.js';
import type { UnitSetDefinition } from '../../../world/types.js';
export interface DispatchArgs {
destCityId: number;
}
export interface DispatchResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destCity: City;
destNation?: Nation | null;
cities: City[];
nations: Nation[];
generals: General<TriggerState>[];
unitSet: UnitSetDefinition;
time: WarTimeContext;
seedBase: string;
warConfig: WarEngineConfig;
aftermathConfig: WarAftermathConfig;
}
const ACTION_NAME = '출병';
const parseCityId = (raw: unknown): number | null => {
@@ -29,9 +64,45 @@ const parseCityId = (raw: unknown): number | null => {
return raw > 0 ? Math.floor(raw) : null;
};
const cloneGeneral = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>
): General<TriggerState> => ({
...general,
stats: { ...general.stats },
role: {
...general.role,
items: {
...general.role.items,
},
},
triggerState: {
...general.triggerState,
flags: { ...general.triggerState.flags },
counters: { ...general.triggerState.counters },
modifiers: { ...general.triggerState.modifiers },
meta: { ...general.triggerState.meta },
},
meta: { ...general.meta },
});
const cloneCity = (city: City): City => ({
...city,
meta: { ...city.meta },
});
const cloneNation = (nation: Nation): Nation => ({
...nation,
meta: { ...nation.meta },
});
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, DispatchArgs> {
> implements
GeneralActionDefinition<
TriggerState,
DispatchArgs,
DispatchResolveContext<TriggerState>
> {
public readonly key = 'che_출병';
public readonly name = ACTION_NAME;
@@ -55,14 +126,152 @@ export class ActionDefinition<
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
context: DispatchResolveContext<TriggerState>,
args: DispatchArgs
): GeneralActionOutcome<TriggerState> {
context.addLog(`${ACTION_NAME}을 준비했습니다. (목표 도시 ${args.destCityId})`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
void args;
const attackerCity = context.city;
if (!attackerCity) {
throw new Error('Dispatch requires a city context.');
}
const attackerNation = context.nation;
if (!attackerNation) {
throw new Error('Dispatch requires a nation context.');
}
const destCity = context.destCity;
const unitSet = context.unitSet;
const time = context.time;
const seed = simpleSerialize(
context.seedBase,
this.key,
time.year,
time.month,
context.general.id,
destCity.id
);
const cities = context.cities.map(cloneCity);
const nations = context.nations.map(cloneNation);
const generals = context.generals.map(cloneGeneral);
const cityMap = new Map(cities.map((city) => [city.id, city]));
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const defenderCity = cityMap.get(destCity.id) ?? cloneCity(destCity);
const defenderNation =
defenderCity.nationId > 0
? nationMap.get(defenderCity.nationId) ?? null
: null;
const defenderGenerals = generals.filter(
(general) =>
general.cityId === defenderCity.id &&
general.nationId === defenderCity.nationId
);
const battle = resolveWarBattle({
seed,
unitSet,
config: context.warConfig,
time,
attacker: {
general: context.general,
city: attackerCity,
nation: attackerNation,
},
defenders: defenderGenerals.map((general) => ({
general,
city: defenderCity,
nation: defenderNation,
})),
defenderCity,
defenderNation,
});
return { effects: [] };
const aftermath = resolveWarAftermath({
battle,
attackerNation,
defenderNation,
attackerCity,
defenderCity,
nations,
cities,
generals,
unitSet,
config: context.aftermathConfig,
time,
hiddenSeed: context.seedBase,
});
const effects: Array<GeneralActionEffect<TriggerState>> = [];
for (const entry of battle.logs) {
effects.push({ type: 'log', entry });
}
for (const entry of aftermath.logs) {
effects.push({ type: 'log', entry });
}
const generalPatches = new Map<number, General<TriggerState>>();
const cityPatches = new Map<number, City>();
const nationPatches = new Map<number, Nation>();
const addGeneralPatch = (general: General<TriggerState>): void => {
if (general.id === context.general.id) {
return;
}
generalPatches.set(general.id, cloneGeneral(general));
};
const addCityPatch = (city: City): void => {
if (context.city && city.id === context.city.id) {
return;
}
cityPatches.set(city.id, cloneCity(city));
};
const addNationPatch = (nation: Nation): void => {
if (context.nation && nation.id === context.nation.id) {
return;
}
nationPatches.set(nation.id, cloneNation(nation));
};
for (const defender of battle.defenders) {
addGeneralPatch(defender);
}
for (const general of aftermath.generals) {
addGeneralPatch(general);
}
addCityPatch(defenderCity);
for (const city of aftermath.cities) {
addCityPatch(city);
}
if (defenderNation) {
addNationPatch(defenderNation);
}
for (const nation of aftermath.nations) {
addNationPatch(nation);
}
for (const [id, patch] of generalPatches) {
effects.push(createGeneralPatchEffect(patch, id));
}
for (const [id, patch] of cityPatches) {
effects.push(createCityPatchEffect(patch, id));
}
for (const [id, patch] of nationPatches) {
effects.push(createNationPatchEffect(patch, id));
}
for (const delta of aftermath.diplomacyDeltas) {
effects.push(
createDiplomacyPatchEffect(delta.fromNationId, delta.toNationId, {
deadDelta: delta.deadDelta,
})
);
}
return { effects };
}
}
@@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../src/domain/entities.js';
import { resolveGeneralAction } from '../src/actions/engine.js';
import { ActionDefinition } from '../src/actions/turn/general/che_출병.js';
import type { TurnSchedule } from '../src/turn/calendar.js';
import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js';
import type { UnitSetDefinition } from '../src/world/types.js';
const buildGeneral = (id: number, nationId: number, cityId: number): General => ({
id,
name: `General${id}`,
nationId,
cityId,
troopId: 0,
stats: {
leadership: 70,
strength: 70,
intelligence: 70,
},
experience: 100,
dedication: 100,
officerLevel: 3,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: null,
weapon: null,
book: null,
item: null,
},
},
injury: 0,
gold: 1000,
rice: 2000,
crew: 1500,
crewTypeId: 100,
train: 80,
atmos: 80,
age: 25,
npcState: 0,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
meta: {},
});
const buildCity = (id: number, nationId: number): City => ({
id,
name: `City${id}`,
nationId,
level: 2,
population: 10000,
populationMax: 10000,
agriculture: 1000,
agricultureMax: 1000,
commerce: 1000,
commerceMax: 1000,
security: 1000,
securityMax: 1000,
supplyState: 1,
frontState: 0,
defence: 200,
defenceMax: 400,
wall: 200,
wallMax: 400,
meta: {},
});
const buildNation = (id: number): Nation => ({
id,
name: `Nation${id}`,
color: '#000000',
capitalCityId: id,
chiefGeneralId: null,
gold: 5000,
rice: 5000,
power: 0,
level: 1,
typeCode: 'test',
meta: {
tech: 1000,
},
});
const unitSet: UnitSetDefinition = {
id: 'test',
name: 'test',
defaultCrewTypeId: 100,
crewTypes: [
{
id: 100,
armType: 1,
name: 'Infantry',
attack: 10,
defence: 10,
speed: 3,
avoid: 5,
magicCoef: 0,
cost: 0,
rice: 1,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
{
id: 999,
armType: 5,
name: 'Castle',
attack: 0,
defence: 0,
speed: 1,
avoid: 0,
magicCoef: 0,
cost: 0,
rice: 10,
requirements: [{ type: 'Impossible' }],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
};
const warConfig: WarEngineConfig = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 999,
armTypes: {
footman: 1,
archer: 2,
cavalry: 3,
wizard: 4,
siege: 5,
misc: 6,
castle: 5,
},
};
const aftermathConfig: WarAftermathConfig = {
initialNationGenLimit: 1,
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
maxTechLevel: 12,
defaultCityWall: 1000,
baseGold: 0,
baseRice: 0,
castleCrewTypeId: 999,
};
const rng = {
nextFloat: () => 0.1,
nextBool: (probability: number) => probability >= 0.1,
nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive,
};
const schedule: TurnSchedule = {
entries: [{ startMinute: 0, tickMinutes: 60 }],
};
describe('che_출병', () => {
it('runs war battle and emits patches/logs', () => {
const attackerNation = buildNation(1);
const defenderNation = buildNation(2);
const attackerCity = buildCity(1, attackerNation.id);
const defenderCity = buildCity(2, defenderNation.id);
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
const definition = new ActionDefinition();
const resolution = resolveGeneralAction(
definition,
{
general: attacker,
city: attackerCity,
nation: attackerNation,
rng,
destCity: defenderCity,
destNation: defenderNation,
cities: [attackerCity, defenderCity],
nations: [attackerNation, defenderNation],
generals: [attacker, defender],
unitSet,
time: {
year: 200,
month: 1,
startYear: 180,
},
seedBase: 'test-seed',
warConfig,
aftermathConfig,
},
{
now: new Date('2000-01-01T00:00:00Z'),
schedule,
},
{
destCityId: defenderCity.id,
}
);
expect(resolution.logs.length).toBeGreaterThan(0);
expect(resolution.patches?.generals.some((patch) => patch.id === defender.id)).toBe(
true
);
expect(resolution.patches?.cities.some((patch) => patch.id === defenderCity.id)).toBe(
true
);
expect(
resolution.effects.some(
(effect) =>
effect.type === 'diplomacy:patch' &&
effect.srcNationId === attackerNation.id &&
effect.destNationId === defenderNation.id
)
).toBe(true);
});
});