619 lines
21 KiB
TypeScript
619 lines
21 KiB
TypeScript
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, actionContextBuilder, orderDefenderGenerals } from '../src/actions/turn/general/che_출병.js';
|
|
import type { DispatchResolveContext } 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';
|
|
import type { ItemModule } from '../src/items/types.js';
|
|
import { orderLegacyActionLoggerFlush } from '../src/logging/actionLogger.js';
|
|
import { LogCategory, LogFormat, LogScope } from '../src/logging/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: { killturn: 24 },
|
|
});
|
|
|
|
const buildCity = (id: number, nationId: number): City => ({
|
|
id,
|
|
name: `City${id}`,
|
|
nationId,
|
|
level: 2,
|
|
state: 0,
|
|
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 = {
|
|
nextFloat1: () => 0.1,
|
|
nextBool: (probability: number) => probability >= 0.1,
|
|
nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive,
|
|
};
|
|
|
|
const schedule: TurnSchedule = {
|
|
entries: [{ startMinute: 0, tickMinutes: 60 }],
|
|
};
|
|
|
|
const uniqueItem: ItemModule = {
|
|
key: 'test_unique',
|
|
name: '테스트 유니크',
|
|
rawName: '테스트 유니크',
|
|
info: 'test',
|
|
slot: 'item',
|
|
cost: null,
|
|
buyable: false,
|
|
consumable: false,
|
|
reqSecu: 0,
|
|
unique: true,
|
|
};
|
|
|
|
describe('che_출병', () => {
|
|
it.each(['full', 'onlyRandom'] as const)(
|
|
'reads %s join mode from current world config for collapse aftermath',
|
|
(joinMode) => {
|
|
const general = { ...buildGeneral(1, 1, 1), turnTime: new Date('0200-01-01T00:00:00.000Z') };
|
|
const destination = buildCity(2, 2);
|
|
const context = actionContextBuilder(
|
|
{ general, rng },
|
|
{
|
|
world: { currentYear: 200, currentMonth: 1, tickSeconds: 3600, meta: { joinMode: 'full' } },
|
|
worldConfig: { joinMode },
|
|
scenarioConfig: {
|
|
stat: { total: 0, min: 0, max: 0, npcTotal: 0, npcMin: 0, npcMax: 0, chiefMin: 0 },
|
|
iconPath: '',
|
|
map: {},
|
|
const: {},
|
|
environment: { mapName: 'test', unitSet: 'test' },
|
|
},
|
|
map: { id: 'test', name: 'test', cities: [] },
|
|
unitSet,
|
|
worldRef: {
|
|
getCityById: () => destination,
|
|
getNationById: () => buildNation(2),
|
|
listCities: () => [destination],
|
|
listNations: () => [buildNation(2)],
|
|
listGenerals: () => [general],
|
|
listDiplomacy: () => [],
|
|
} as never,
|
|
actionArgs: { destCityId: destination.id },
|
|
createGeneralId: () => 3,
|
|
createNationId: () => 3,
|
|
seedBase: 'test',
|
|
}
|
|
);
|
|
|
|
expect(context?.aftermathConfig).toMatchObject({ joinMode });
|
|
}
|
|
);
|
|
|
|
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 neutralCity = buildCity(3, 0);
|
|
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
|
attacker.officerLevel = 12;
|
|
attacker.turnTime = new Date('2000-01-01T00:00:00Z');
|
|
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
|
defender.officerLevel = 12;
|
|
defender.crew = 0;
|
|
defenderCity.defence = 0;
|
|
defenderCity.wall = 0;
|
|
|
|
const definition = new ActionDefinition();
|
|
const context: Omit<DispatchResolveContext, 'addLog'> & {
|
|
uniqueLottery: () => ItemModule;
|
|
} = {
|
|
general: attacker,
|
|
city: attackerCity,
|
|
nation: attackerNation,
|
|
rng,
|
|
destCity: defenderCity,
|
|
destNation: defenderNation,
|
|
cities: [attackerCity, defenderCity, neutralCity],
|
|
nations: [attackerNation, defenderNation],
|
|
generals: [attacker, defender],
|
|
unitSet,
|
|
map: {
|
|
id: 'test-map',
|
|
name: 'test-map',
|
|
cities: [
|
|
{
|
|
id: 1,
|
|
name: 'City1',
|
|
level: 2,
|
|
region: 1,
|
|
position: { x: 0, y: 0 },
|
|
connections: [2, 3],
|
|
max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
|
initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'City2',
|
|
level: 2,
|
|
region: 1,
|
|
position: { x: 1, y: 0 },
|
|
connections: [1],
|
|
max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
|
initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
|
},
|
|
{
|
|
id: 3,
|
|
name: 'City3',
|
|
level: 2,
|
|
region: 1,
|
|
position: { x: 0, y: 1 },
|
|
connections: [1],
|
|
max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
|
initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
|
},
|
|
],
|
|
},
|
|
diplomacy: [
|
|
{ fromNationId: attackerNation.id, toNationId: defenderNation.id, state: 0, term: 0 },
|
|
{ fromNationId: defenderNation.id, toNationId: attackerNation.id, state: 0, term: 0 },
|
|
],
|
|
time: {
|
|
year: 200,
|
|
month: 1,
|
|
startYear: 180,
|
|
},
|
|
seedBase: 'test-seed-2',
|
|
warConfig,
|
|
aftermathConfig,
|
|
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
|
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
|
|
uniqueLottery: () => uniqueItem,
|
|
};
|
|
const resolution = resolveGeneralAction(
|
|
definition,
|
|
context,
|
|
{
|
|
now: new Date('2000-01-01T00:00:00Z'),
|
|
schedule,
|
|
},
|
|
{
|
|
destCityId: defenderCity.id,
|
|
}
|
|
);
|
|
|
|
expect(resolution.logs.length).toBeGreaterThan(0);
|
|
expect(resolution.destroyedNationIds).toEqual([defenderNation.id]);
|
|
expect(resolution.general.recentWarTime?.toISOString()).toBe(attacker.turnTime.toISOString());
|
|
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({ city: resolution.city, patches: resolution.patches?.cities }).toMatchObject({
|
|
city: { frontState: 2 },
|
|
});
|
|
expect(
|
|
resolution.effects.some(
|
|
(effect) =>
|
|
effect.type === 'diplomacy:patch' &&
|
|
effect.srcNationId === attackerNation.id &&
|
|
effect.destNationId === defenderNation.id
|
|
)
|
|
).toBe(true);
|
|
expect(resolution.effects.find((effect) => effect.type === 'message:add')).toEqual({
|
|
type: 'message:add',
|
|
draft: expect.objectContaining({
|
|
msgType: 'private',
|
|
dest: expect.objectContaining({
|
|
generalId: defender.id,
|
|
nationId: 0,
|
|
nationName: '재야',
|
|
icon: 'https://ref.example/image/icons/default.jpg',
|
|
}),
|
|
text: 'Nation1로 망명 권유 서신',
|
|
option: { action: 'scout' },
|
|
sendDestOnly: true,
|
|
}),
|
|
});
|
|
|
|
const uniqueGroups = new Set(resolution.postProgressionLogs.map((log) => log.legacyFlushGroup));
|
|
expect(uniqueGroups.size).toBe(1);
|
|
const [internalFinalGroup] = uniqueGroups;
|
|
expect(internalFinalGroup).toBeTypeOf('number');
|
|
expect(internalFinalGroup).toBeLessThan(0);
|
|
expect(resolution.logs.find((log) => log.text.includes('정복으로 금'))?.legacyFlushGroup).toBe(
|
|
internalFinalGroup
|
|
);
|
|
|
|
const outerProgressionLog = {
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.ACTION,
|
|
format: LogFormat.PLAIN,
|
|
generalId: attacker.id,
|
|
text: 'outer progression',
|
|
} as const;
|
|
const ordered = orderLegacyActionLoggerFlush([
|
|
...resolution.logs,
|
|
...resolution.postProgressionLogs,
|
|
outerProgressionLog,
|
|
]);
|
|
expect(ordered.at(-1)?.text).toBe(outerProgressionLog.text);
|
|
});
|
|
|
|
it('keeps a ronin general in a neutral city out of the defence even with full combat stats', () => {
|
|
const attackerNation = buildNation(1);
|
|
const attackerCity = buildCity(1, attackerNation.id);
|
|
const targetCity = buildCity(2, 0);
|
|
targetCity.defence = 0;
|
|
targetCity.wall = 0;
|
|
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
|
// The ronin must be combat-capable: crew/train/atmos/rice/crewType all
|
|
// satisfy the defender selection criteria, so only nationId === 0 can
|
|
// keep it out of defenderGenerals.
|
|
const ronin = buildGeneral(2, 0, targetCity.id);
|
|
expect(ronin.crew).toBeGreaterThan(0);
|
|
|
|
const mapCity = (id: number, connections: number[]) => ({
|
|
id,
|
|
name: `City${id}`,
|
|
level: 2,
|
|
region: 1,
|
|
position: { x: id, y: 0 },
|
|
connections,
|
|
max: {
|
|
population: 1,
|
|
agriculture: 1,
|
|
commerce: 1,
|
|
security: 1,
|
|
defence: 1,
|
|
wall: 1,
|
|
},
|
|
initial: {
|
|
population: 1,
|
|
agriculture: 1,
|
|
commerce: 1,
|
|
security: 1,
|
|
defence: 1,
|
|
wall: 1,
|
|
},
|
|
});
|
|
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
|
general: attacker,
|
|
city: attackerCity,
|
|
nation: attackerNation,
|
|
rng,
|
|
destCity: targetCity,
|
|
destNation: null,
|
|
cities: [attackerCity, targetCity],
|
|
nations: [attackerNation],
|
|
generals: [attacker, ronin],
|
|
unitSet,
|
|
map: {
|
|
id: 'neutral-ronin',
|
|
name: 'neutral-ronin',
|
|
cities: [mapCity(1, [2]), mapCity(2, [1])],
|
|
},
|
|
diplomacy: [],
|
|
time: { year: 200, month: 1, startYear: 180 },
|
|
seedBase: 'neutral-ronin-seed',
|
|
warConfig,
|
|
aftermathConfig,
|
|
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
|
};
|
|
|
|
const resolution = resolveGeneralAction(
|
|
new ActionDefinition(),
|
|
context,
|
|
{ now: new Date('2000-01-01T00:00:00Z'), schedule },
|
|
{ destCityId: targetCity.id }
|
|
);
|
|
|
|
// The sortie must still run the siege against the empty city.
|
|
expect(resolution.completed).toBe(true);
|
|
// The ronin never enters defenderGenerals, so it takes no battle
|
|
// damage and receives no general patch.
|
|
expect(resolution.patches?.generals.some((patch) => patch.id === ronin.id)).toBe(false);
|
|
expect(ronin.crew).toBe(1500);
|
|
// The neutral city is still conquered by the attacker as before.
|
|
const targetPatch = resolution.patches?.cities.find((patch) => patch.id === targetCity.id);
|
|
expect(targetPatch?.patch.nationId).toBe(attackerNation.id);
|
|
});
|
|
|
|
it('lets a crewed general of the owning nation defend its city', () => {
|
|
const attackerNation = buildNation(1);
|
|
const defenderNation = buildNation(2);
|
|
const attackerCity = buildCity(1, attackerNation.id);
|
|
const defenderCity = buildCity(2, defenderNation.id);
|
|
defenderCity.defence = 0;
|
|
defenderCity.wall = 0;
|
|
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
|
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
|
|
|
const mapCity = (id: number, connections: number[]) => ({
|
|
id,
|
|
name: `City${id}`,
|
|
level: 2,
|
|
region: 1,
|
|
position: { x: id, y: 0 },
|
|
connections,
|
|
max: {
|
|
population: 1,
|
|
agriculture: 1,
|
|
commerce: 1,
|
|
security: 1,
|
|
defence: 1,
|
|
wall: 1,
|
|
},
|
|
initial: {
|
|
population: 1,
|
|
agriculture: 1,
|
|
commerce: 1,
|
|
security: 1,
|
|
defence: 1,
|
|
wall: 1,
|
|
},
|
|
});
|
|
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
|
general: attacker,
|
|
city: attackerCity,
|
|
nation: attackerNation,
|
|
rng,
|
|
destCity: defenderCity,
|
|
destNation: defenderNation,
|
|
cities: [attackerCity, defenderCity],
|
|
nations: [attackerNation, defenderNation],
|
|
generals: [attacker, defender],
|
|
unitSet,
|
|
map: {
|
|
id: 'national-defender',
|
|
name: 'national-defender',
|
|
cities: [mapCity(1, [2]), mapCity(2, [1])],
|
|
},
|
|
diplomacy: [
|
|
{ fromNationId: attackerNation.id, toNationId: defenderNation.id, state: 0, term: 0 },
|
|
{ fromNationId: defenderNation.id, toNationId: attackerNation.id, state: 0, term: 0 },
|
|
],
|
|
time: { year: 200, month: 1, startYear: 180 },
|
|
seedBase: 'national-defender-seed',
|
|
warConfig,
|
|
aftermathConfig,
|
|
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
|
};
|
|
|
|
const resolution = resolveGeneralAction(
|
|
new ActionDefinition(),
|
|
context,
|
|
{ now: new Date('2000-01-01T00:00:00Z'), schedule },
|
|
{ destCityId: defenderCity.id }
|
|
);
|
|
|
|
expect(resolution.completed).toBe(true);
|
|
// The defender fought the battle: it entered defenderGenerals and
|
|
// takes a patch with reduced crew (the city has no officerCity
|
|
// assignment, so the aftermath alone would not patch it).
|
|
const defenderPatch = resolution.patches?.generals.find((patch) => patch.id === defender.id);
|
|
expect(defenderPatch).toBeDefined();
|
|
expect(defenderPatch?.patch.crew ?? defender.crew).toBeLessThan(1500);
|
|
});
|
|
|
|
it('orders equal-priority defender inputs by general number before the stable battle sort', () => {
|
|
const defender2 = buildGeneral(2, 2, 2);
|
|
const defender3 = buildGeneral(3, 2, 2);
|
|
|
|
expect(orderDefenderGenerals([defender3, defender2]).map((general) => general.id)).toEqual([2, 3]);
|
|
});
|
|
|
|
it('prefers an enemy on the shortest route layer before considering the next layer', () => {
|
|
const attackerNation = buildNation(1);
|
|
const attackerCity = buildCity(1, attackerNation.id);
|
|
const targetCity = buildCity(2, 0);
|
|
const alternateCity = buildCity(3, 0);
|
|
targetCity.defence = 0;
|
|
targetCity.wall = 0;
|
|
alternateCity.defence = 0;
|
|
alternateCity.wall = 0;
|
|
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
|
const pickLastRng = {
|
|
...rng,
|
|
nextInt: (_minInclusive: number, maxExclusive: number) => maxExclusive - 1,
|
|
};
|
|
const definition = new ActionDefinition();
|
|
const mapCity = (id: number, connections: number[]) => ({
|
|
id,
|
|
name: `City${id}`,
|
|
level: 2,
|
|
region: 1,
|
|
position: { x: id, y: 0 },
|
|
connections,
|
|
max: {
|
|
population: 1,
|
|
agriculture: 1,
|
|
commerce: 1,
|
|
security: 1,
|
|
defence: 1,
|
|
wall: 1,
|
|
},
|
|
initial: {
|
|
population: 1,
|
|
agriculture: 1,
|
|
commerce: 1,
|
|
security: 1,
|
|
defence: 1,
|
|
wall: 1,
|
|
},
|
|
});
|
|
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
|
general: attacker,
|
|
city: attackerCity,
|
|
nation: attackerNation,
|
|
rng: pickLastRng,
|
|
destCity: targetCity,
|
|
destNation: null,
|
|
cities: [attackerCity, targetCity, alternateCity],
|
|
nations: [attackerNation],
|
|
generals: [attacker],
|
|
unitSet,
|
|
map: {
|
|
id: 'triangle',
|
|
name: 'triangle',
|
|
cities: [mapCity(1, [2, 3]), mapCity(2, [1, 3]), mapCity(3, [1, 2])],
|
|
},
|
|
diplomacy: [],
|
|
time: { year: 200, month: 1, startYear: 180 },
|
|
seedBase: 'route-layer-seed',
|
|
warConfig,
|
|
aftermathConfig,
|
|
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
|
};
|
|
|
|
const resolution = resolveGeneralAction(
|
|
definition,
|
|
context,
|
|
{ now: new Date('2000-01-01T00:00:00Z'), schedule },
|
|
{ destCityId: targetCity.id }
|
|
);
|
|
|
|
expect(resolution.patches?.cities.some((patch) => patch.id === targetCity.id)).toBe(true);
|
|
expect(resolution.patches?.cities.some((patch) => patch.id === alternateCity.id)).toBe(false);
|
|
});
|
|
});
|