feat: enhance war action handling with new configurations and context management
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user