216 lines
7.9 KiB
TypeScript
216 lines
7.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { City, General, Nation } from '../src/domain/entities.js';
|
|
import {
|
|
ActionDefinition as DraftActionDefinition,
|
|
CommandResolver as RecruitmentCommandResolver,
|
|
} from '../src/actions/turn/general/che_징병.js';
|
|
import { ActionDefinition as MercenaryActionDefinition } from '../src/actions/turn/general/che_모병.js';
|
|
import { StrategyCommandResolver, type StrategyActionConfig } from '../src/actions/turn/general/strategyCommand.js';
|
|
import { traitModule as recruitTrait } from '../src/actionModules/traits/war/che_징병.js';
|
|
import { traitModule as footmanTrait } from '../src/actionModules/traits/war/che_보병.js';
|
|
import { traitModule as archerTrait } from '../src/actionModules/traits/war/che_궁병.js';
|
|
import { traitModule as cavalryTrait } from '../src/actionModules/traits/war/che_기병.js';
|
|
import { traitModule as wizardTrait } from '../src/actionModules/traits/war/che_귀병.js';
|
|
import { traitModule as siegeTrait } from '../src/actionModules/traits/war/che_공성.js';
|
|
import { traitModule as strategistTrait } from '../src/actionModules/traits/war/che_신산.js';
|
|
|
|
const buildGeneral = (overrides: Partial<General> = {}): General => ({
|
|
id: 1,
|
|
name: '특기 감사 장수',
|
|
nationId: 1,
|
|
cityId: 1,
|
|
troopId: 0,
|
|
npcState: 0,
|
|
experience: 0,
|
|
dedication: 0,
|
|
officerLevel: 1,
|
|
gold: 100_000,
|
|
rice: 100_000,
|
|
crew: 0,
|
|
crewTypeId: 1,
|
|
train: 0,
|
|
atmos: 0,
|
|
injury: 0,
|
|
age: 30,
|
|
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: 'che_징병',
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
},
|
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
|
meta: { killturn: 24 },
|
|
...overrides,
|
|
});
|
|
|
|
const buildNation = (): Nation => ({
|
|
id: 1,
|
|
name: '특기 감사국',
|
|
color: '#000000',
|
|
capitalCityId: 1,
|
|
chiefGeneralId: 1,
|
|
gold: 100_000,
|
|
rice: 100_000,
|
|
power: 0,
|
|
level: 1,
|
|
typeCode: 'che_중립',
|
|
meta: { tech: 0 },
|
|
});
|
|
|
|
const buildCity = (): City => ({
|
|
id: 2,
|
|
name: '특기 감사성',
|
|
nationId: 2,
|
|
level: 1,
|
|
state: 0,
|
|
population: 10_000,
|
|
populationMax: 20_000,
|
|
agriculture: 1_000,
|
|
agricultureMax: 2_000,
|
|
commerce: 1_000,
|
|
commerceMax: 2_000,
|
|
security: 1_000,
|
|
securityMax: 2_000,
|
|
defence: 500,
|
|
defenceMax: 1_000,
|
|
wall: 500,
|
|
wallMax: 1_000,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
meta: { trust: 50 },
|
|
});
|
|
|
|
describe('전투 특기의 비전투 커맨드 효과', () => {
|
|
it('징병 특기가 징병과 모병의 훈련·사기를 각각 70과 84로 설정한다', () => {
|
|
const context = { general: buildGeneral(), nation: buildNation() };
|
|
const draft = new RecruitmentCommandResolver([recruitTrait], {
|
|
actionName: '징병',
|
|
defaultTrain: 40,
|
|
defaultAtmos: 40,
|
|
});
|
|
const mercenary = new RecruitmentCommandResolver([recruitTrait], {
|
|
actionName: '모병',
|
|
costOffset: 2,
|
|
defaultTrain: 70,
|
|
defaultAtmos: 70,
|
|
});
|
|
|
|
expect(draft.getTrain(context)).toBe(70);
|
|
expect(draft.getAtmos(context)).toBe(70);
|
|
expect(mercenary.getTrain(context)).toBe(84);
|
|
expect(mercenary.getAtmos(context)).toBe(84);
|
|
});
|
|
|
|
it('실제 징병·모병 action이 특기 훈사와 인구 보존을 저장 상태에 반영한다', () => {
|
|
const unitSet = {
|
|
id: 'war-trait-command-audit',
|
|
name: '전투특기 커맨드 감사',
|
|
crewTypes: [{ id: 101, name: '감사병', armType: 1, cost: 10, rice: 1, requirements: [] }],
|
|
};
|
|
const map = { id: 'war-trait-command-audit', name: '전투특기 커맨드 감사', cities: [] };
|
|
|
|
for (const [definition, expectedReadiness] of [
|
|
[new DraftActionDefinition([recruitTrait], {}), 70],
|
|
[new MercenaryActionDefinition([recruitTrait]), 84],
|
|
] as const) {
|
|
const general = buildGeneral();
|
|
const city = { ...buildCity(), id: 1, nationId: 1, population: 100_000 };
|
|
definition.resolve(
|
|
{
|
|
general,
|
|
city,
|
|
nation: buildNation(),
|
|
map,
|
|
unitSet,
|
|
cities: [city],
|
|
addLog: () => undefined,
|
|
} as never,
|
|
{ crewType: 101, amount: 1_000 }
|
|
);
|
|
|
|
expect(general.crew).toBe(1_000);
|
|
expect(general.train).toBe(expectedReadiness);
|
|
expect(general.atmos).toBe(expectedReadiness);
|
|
expect(city.population).toBe(100_000);
|
|
}
|
|
});
|
|
|
|
it('징병 특기가 통솔 상한을 25% 높이고 징병 인구를 소모하지 않는다', () => {
|
|
const context = { general: buildGeneral(), nation: buildNation() };
|
|
const command = new RecruitmentCommandResolver([recruitTrait], { actionName: '징병' });
|
|
|
|
expect(command.resolveLeadership(context)).toBe(100);
|
|
expect(command.resolveCrewPlan(context, 2, 20_000)).toEqual({ requested: 20_000, applied: 10_000 });
|
|
expect(command.getRecruitPopulation(context, 10_000)).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
['che_보병', footmanTrait, 1],
|
|
['che_궁병', archerTrait, 2],
|
|
['che_기병', cavalryTrait, 3],
|
|
['che_귀병', wizardTrait, 4],
|
|
['che_공성', siegeTrait, 5],
|
|
] as const)('%s 특기가 해당 계통의 징병·모병 비용만 10% 낮춘다', (_key, trait, armType) => {
|
|
const context = { general: buildGeneral(), nation: buildNation() };
|
|
const crewTypeId = 100 + armType;
|
|
const command = new RecruitmentCommandResolver([trait], {
|
|
actionName: '모병',
|
|
costOffset: 2,
|
|
defaultTrain: 70,
|
|
defaultAtmos: 70,
|
|
});
|
|
|
|
expect(command.getCost(context, crewTypeId, 1_000, { armType, cost: 10 }).gold).toBe(180);
|
|
expect(command.getCost(context, crewTypeId, 1_000, { armType: 9, cost: 10 }).gold).toBe(200);
|
|
});
|
|
|
|
it.each([
|
|
['che_화계', '화계', 'intelligence', 'fire', true],
|
|
['che_선동', '선동', 'leadership', 'agitate', true],
|
|
['che_파괴', '파괴', 'strength', 'destroy', true],
|
|
['che_탈취', '탈취', 'strength', 'seize', false],
|
|
] as const)('신산 특기가 %s 성공 공격값을 10%p 높인다', (key, name, statKey, damageMode, injuryGeneral) => {
|
|
const config: StrategyActionConfig = {
|
|
key,
|
|
name,
|
|
statKey,
|
|
statExpKey:
|
|
statKey === 'intelligence' ? 'intel_exp' : statKey === 'leadership' ? 'leadership_exp' : 'strength_exp',
|
|
damageMode,
|
|
injuryGeneral,
|
|
};
|
|
const env = {
|
|
develCost: 100,
|
|
sabotageDefaultProb: 0.5,
|
|
sabotageProbCoefByStat: 300,
|
|
sabotageDefenceCoefByGeneralCount: 0.1,
|
|
sabotageDamageMin: 10,
|
|
sabotageDamageMax: 20,
|
|
};
|
|
const general = buildGeneral();
|
|
const context = {
|
|
general,
|
|
city: { ...buildCity(), id: 1, nationId: 1 },
|
|
nation: buildNation(),
|
|
destCity: buildCity(),
|
|
destNation: { ...buildNation(), id: 2 },
|
|
destGenerals: [],
|
|
distance: 1,
|
|
};
|
|
const effectiveStat =
|
|
statKey === 'intelligence'
|
|
? general.stats.intelligence + Math.round(general.stats.strength / 4)
|
|
: statKey === 'strength'
|
|
? general.stats.strength + Math.round(general.stats.intelligence / 4)
|
|
: general.stats.leadership;
|
|
const base = effectiveStat / env.sabotageProbCoefByStat;
|
|
|
|
expect(new StrategyCommandResolver([strategistTrait], env, config).getProbability(context).attack).toBeCloseTo(
|
|
base + 0.1,
|
|
12
|
|
);
|
|
});
|
|
});
|