defenderGenerals 선정이 nationId === defenderCity.nationId만 비교해 공백지(nationId 0)와 재야 장수(nationId 0)가 0 === 0으로 같은 세력으로 판정되었다. nationId 0는 실제 국가가 아니라 무소속을 뜻하므로 general.nationId !== 0 조건을 추가해 war/aftermath.ts의 수비국 장수 조건과 같은 의미로 맞춘다. 공백지 공격/공성/점령과 경로 탐색의 공백지 허용은 그대로 두고, 병력·훈련·사기를 갖춘 재야 장수는 수비하지 않고 국가 소속 수비 장수는 계속 수비함을 회귀 테스트로 고정한다.
581 lines
19 KiB
TypeScript
581 lines
19 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, 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('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);
|
|
});
|
|
});
|