feat: enhance war action handling with new configurations and context management
This commit is contained in:
@@ -3,7 +3,11 @@ import type {
|
|||||||
General,
|
General,
|
||||||
MapDefinition,
|
MapDefinition,
|
||||||
Nation,
|
Nation,
|
||||||
|
ScenarioConfig,
|
||||||
ScenarioMeta,
|
ScenarioMeta,
|
||||||
|
WarAftermathConfig,
|
||||||
|
WarEngineConfig,
|
||||||
|
WarTimeContext,
|
||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
@@ -39,12 +43,14 @@ export type ActionResolveContext = ActionContextBase & Record<string, unknown>;
|
|||||||
|
|
||||||
export interface ActionContextOptions {
|
export interface ActionContextOptions {
|
||||||
world: TurnWorldState;
|
world: TurnWorldState;
|
||||||
|
scenarioConfig: ScenarioConfig;
|
||||||
scenarioMeta?: ScenarioMeta;
|
scenarioMeta?: ScenarioMeta;
|
||||||
map?: MapDefinition;
|
map?: MapDefinition;
|
||||||
unitSet?: UnitSetDefinition;
|
unitSet?: UnitSetDefinition;
|
||||||
worldRef: InMemoryTurnWorld | null;
|
worldRef: InMemoryTurnWorld | null;
|
||||||
actionArgs: Record<string, unknown>;
|
actionArgs: Record<string, unknown>;
|
||||||
createGeneralId: () => number;
|
createGeneralId: () => number;
|
||||||
|
seedBase: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionContextBuilder = (
|
type ActionContextBuilder = (
|
||||||
@@ -141,6 +147,166 @@ const resolveStartYear = (
|
|||||||
const resolveTurnTermMinutes = (world: TurnWorldState): number =>
|
const resolveTurnTermMinutes = (world: TurnWorldState): number =>
|
||||||
Math.max(1, Math.round(world.tickSeconds / 60));
|
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> = {
|
const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
|
||||||
che_인재탐색: (base, options) => ({
|
che_인재탐색: (base, options) => ({
|
||||||
@@ -221,6 +387,41 @@ const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
|
|||||||
currentYear: options.world.currentYear,
|
currentYear: options.world.currentYear,
|
||||||
currentMonth: options.world.currentMonth,
|
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 = (
|
export const buildActionContext = (
|
||||||
|
|||||||
@@ -329,12 +329,14 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
};
|
};
|
||||||
let specificContext = buildActionContext(actionKey, baseContext, {
|
let specificContext = buildActionContext(actionKey, baseContext, {
|
||||||
world: context.world,
|
world: context.world,
|
||||||
|
scenarioConfig: options.scenarioConfig,
|
||||||
scenarioMeta: options.scenarioMeta,
|
scenarioMeta: options.scenarioMeta,
|
||||||
map: options.map,
|
map: options.map,
|
||||||
unitSet: options.unitSet,
|
unitSet: options.unitSet,
|
||||||
worldRef,
|
worldRef,
|
||||||
actionArgs: actionArgsRecord,
|
actionArgs: actionArgsRecord,
|
||||||
createGeneralId,
|
createGeneralId,
|
||||||
|
seedBase,
|
||||||
});
|
});
|
||||||
if (!specificContext && actionKey !== fallbackDefinition.key) {
|
if (!specificContext && actionKey !== fallbackDefinition.key) {
|
||||||
definition = fallbackDefinition;
|
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 type { Constraint, ConstraintContext } from '../../../constraints/types.js';
|
||||||
import {
|
import {
|
||||||
existsDestCity,
|
existsDestCity,
|
||||||
@@ -9,17 +14,47 @@ import {
|
|||||||
} from '../../../constraints/presets.js';
|
} from '../../../constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '../../definition.js';
|
import type { GeneralActionDefinition } from '../../definition.js';
|
||||||
import type {
|
import type {
|
||||||
|
GeneralActionEffect,
|
||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
} from '../../engine.js';
|
} 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 { TurnCommandEnv } from '../commandEnv.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.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 {
|
export interface DispatchArgs {
|
||||||
destCityId: number;
|
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 ACTION_NAME = '출병';
|
||||||
|
|
||||||
const parseCityId = (raw: unknown): number | null => {
|
const parseCityId = (raw: unknown): number | null => {
|
||||||
@@ -29,9 +64,45 @@ const parseCityId = (raw: unknown): number | null => {
|
|||||||
return raw > 0 ? Math.floor(raw) : 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<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
> implements GeneralActionDefinition<TriggerState, DispatchArgs> {
|
> implements
|
||||||
|
GeneralActionDefinition<
|
||||||
|
TriggerState,
|
||||||
|
DispatchArgs,
|
||||||
|
DispatchResolveContext<TriggerState>
|
||||||
|
> {
|
||||||
public readonly key = 'che_출병';
|
public readonly key = 'che_출병';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
|
||||||
@@ -55,14 +126,152 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
context: DispatchResolveContext<TriggerState>,
|
||||||
args: DispatchArgs
|
args: DispatchArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
context.addLog(`${ACTION_NAME}을 준비했습니다. (목표 도시 ${args.destCityId})`, {
|
void args;
|
||||||
category: LogCategory.ACTION,
|
const attackerCity = context.city;
|
||||||
format: LogFormat.MONTH,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user