337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { MINIMAL_MAP } from '../fixtures/minimalMap.js';
|
|
import { InMemoryWorld, TestGameRunner } from '../testEnv.js';
|
|
import type { City, General, Nation } from '../../src/domain/entities.js';
|
|
import type { WorldSnapshot } from '../../src/world/types.js';
|
|
import { commandSpec as declareWarSpec } from '../../src/actions/turn/nation/che_선전포고.js';
|
|
import { commandSpec as deploySpec } from '../../src/actions/turn/general/che_출병.js';
|
|
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
|
import { processDiplomacyMonth, DIPLOMACY_STATE, type DiplomacyEntry } from '../../src/diplomacy/index.js';
|
|
import { evaluateConstraints } from '../../src/constraints/evaluate.js';
|
|
import type { ConstraintContext, RequirementKey, StateView } from '../../src/constraints/types.js';
|
|
|
|
describe('Diplomacy Scenario', () => {
|
|
it('changes deployment from denied to allowed when a declaration becomes war', async () => {
|
|
// 1. Setup World
|
|
const NATION_A_ID = 1;
|
|
const NATION_B_ID = 2; // Target nation
|
|
|
|
const mockNationA: Nation = {
|
|
id: NATION_A_ID,
|
|
name: 'Nation A',
|
|
color: '#FF0000',
|
|
capitalCityId: 1,
|
|
chiefGeneralId: 1,
|
|
gold: 10000,
|
|
rice: 10000,
|
|
power: 0,
|
|
level: 5,
|
|
typeCode: 'test',
|
|
meta: {},
|
|
};
|
|
const mockNationB: Nation = {
|
|
id: NATION_B_ID,
|
|
name: 'Nation B',
|
|
color: '#0000FF',
|
|
capitalCityId: 2,
|
|
chiefGeneralId: 2,
|
|
gold: 10000,
|
|
rice: 10000,
|
|
power: 0,
|
|
level: 5,
|
|
typeCode: 'test',
|
|
meta: {},
|
|
};
|
|
|
|
const cityADef = MINIMAL_MAP.cities[0];
|
|
const cityBDef = MINIMAL_MAP.cities[1];
|
|
if (!cityADef || !cityBDef) throw new Error('City definition missing');
|
|
|
|
const mockCityA: City = {
|
|
id: 1,
|
|
name: cityADef.name,
|
|
nationId: NATION_A_ID,
|
|
level: 1,
|
|
state: 0,
|
|
population: 10000,
|
|
populationMax: 10000,
|
|
agriculture: 1000,
|
|
agricultureMax: 1000,
|
|
commerce: 1000,
|
|
commerceMax: 1000,
|
|
security: 1000,
|
|
securityMax: 1000,
|
|
defence: 1000,
|
|
defenceMax: 1000,
|
|
wall: 1000,
|
|
wallMax: 1000,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
meta: {},
|
|
};
|
|
const mockCityB: City = {
|
|
id: 2,
|
|
name: cityBDef.name,
|
|
nationId: NATION_B_ID,
|
|
level: 1,
|
|
state: 0,
|
|
population: 10000,
|
|
populationMax: 10000,
|
|
agriculture: 1000,
|
|
agricultureMax: 1000,
|
|
commerce: 1000,
|
|
commerceMax: 1000,
|
|
security: 1000,
|
|
securityMax: 1000,
|
|
defence: 1000,
|
|
defenceMax: 1000,
|
|
wall: 1000,
|
|
wallMax: 1000,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
meta: {},
|
|
};
|
|
|
|
const mockGeneralA: General = {
|
|
id: 1,
|
|
name: 'Leader A',
|
|
nationId: NATION_A_ID,
|
|
cityId: 1,
|
|
troopId: 0,
|
|
npcState: 0,
|
|
experience: 100,
|
|
dedication: 100,
|
|
officerLevel: 5,
|
|
gold: 1000,
|
|
rice: 1000,
|
|
crew: 5000,
|
|
crewTypeId: 1,
|
|
train: 100,
|
|
atmos: 100,
|
|
injury: 0,
|
|
age: 30,
|
|
stats: { leadership: 100, strength: 100, intelligence: 100 },
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: null,
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
},
|
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
|
meta: { killturn: 24 },
|
|
};
|
|
|
|
const snapshot: WorldSnapshot = {
|
|
scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' }, options: {} } as any,
|
|
scenarioMeta: {
|
|
title: 'Test',
|
|
startYear: 200,
|
|
life: 0,
|
|
fiction: 0,
|
|
history: [],
|
|
ignoreDefaultEvents: false,
|
|
},
|
|
map: MINIMAL_MAP,
|
|
unitSet: {
|
|
id: 'default',
|
|
name: 'default',
|
|
defaultCrewTypeId: 1,
|
|
armTypes: { 0: '성벽', 1: '보병' },
|
|
crewTypes: [
|
|
{
|
|
id: 0,
|
|
name: '성벽',
|
|
armType: 0,
|
|
attack: 0,
|
|
defence: 100,
|
|
speed: 1,
|
|
avoid: 0,
|
|
magicCoef: 0,
|
|
cost: 0,
|
|
rice: 0,
|
|
requirements: [],
|
|
attackCoef: {},
|
|
defenceCoef: {},
|
|
info: [],
|
|
initSkillTrigger: null,
|
|
phaseSkillTrigger: null,
|
|
iActionList: null,
|
|
},
|
|
{
|
|
id: 1,
|
|
name: '보병',
|
|
armType: 1,
|
|
attack: 100,
|
|
defence: 100,
|
|
speed: 5,
|
|
avoid: 0,
|
|
magicCoef: 0,
|
|
cost: 10,
|
|
rice: 1,
|
|
requirements: [],
|
|
attackCoef: {},
|
|
defenceCoef: {},
|
|
info: [],
|
|
initSkillTrigger: null,
|
|
phaseSkillTrigger: null,
|
|
iActionList: null,
|
|
},
|
|
],
|
|
},
|
|
nations: [mockNationA, mockNationB],
|
|
cities: [mockCityA, mockCityB],
|
|
generals: [mockGeneralA],
|
|
troops: [],
|
|
diplomacy: [],
|
|
events: [],
|
|
initialEvents: [],
|
|
};
|
|
|
|
const world = new InMemoryWorld(snapshot);
|
|
const runner = new TestGameRunner(world, 200, 1);
|
|
|
|
const systemEnv: TurnCommandEnv = {
|
|
develCost: 50,
|
|
trainDelta: 5,
|
|
atmosDelta: 5,
|
|
maxTrainByCommand: 100,
|
|
maxAtmosByCommand: 100,
|
|
sabotageDefaultProb: 0.5,
|
|
sabotageProbCoefByStat: 0.1,
|
|
sabotageDefenceCoefByGeneralCount: 0.1,
|
|
sabotageDamageMin: 10,
|
|
sabotageDamageMax: 20,
|
|
openingPartYear: 200,
|
|
maxGeneral: 10,
|
|
defaultNpcGold: 1000,
|
|
defaultNpcRice: 1000,
|
|
defaultCrewTypeId: 1,
|
|
defaultSpecialDomestic: null,
|
|
defaultSpecialWar: null,
|
|
initialNationGenLimit: 10,
|
|
maxTechLevel: 10,
|
|
baseGold: 1000,
|
|
baseRice: 1000,
|
|
maxResourceActionAmount: 1000,
|
|
};
|
|
|
|
// 2. Declare War
|
|
const declareWarDef = declareWarSpec.createDefinition(systemEnv);
|
|
await runner.runTurn([
|
|
{
|
|
generalId: 1,
|
|
commandKey: 'che_선전포고',
|
|
resolver: declareWarDef,
|
|
args: { destNationId: NATION_B_ID },
|
|
context: {
|
|
destNation: mockNationB,
|
|
},
|
|
},
|
|
]);
|
|
|
|
const diplomacyAB = world.getDiplomacy(NATION_A_ID, NATION_B_ID);
|
|
expect(diplomacyAB).toBeDefined();
|
|
expect(diplomacyAB?.state).toBe(DIPLOMACY_STATE.DECLARATION);
|
|
|
|
const dispatchArgs = { destCityId: 2 };
|
|
|
|
const buildConstraintView = (): StateView => {
|
|
const get = (requirement: RequirementKey): unknown | null => {
|
|
switch (requirement.kind) {
|
|
case 'general':
|
|
case 'destGeneral':
|
|
return world.getGeneral(requirement.id) ?? null;
|
|
case 'generalList':
|
|
return world.getAllGenerals();
|
|
case 'city':
|
|
case 'destCity':
|
|
return world.getCity(requirement.id) ?? null;
|
|
case 'nation':
|
|
case 'destNation':
|
|
return world.getNation(requirement.id) ?? null;
|
|
case 'nationList':
|
|
return world.getAllNations();
|
|
case 'diplomacy':
|
|
return world.getDiplomacy(requirement.srcNationId, requirement.destNationId) ?? null;
|
|
case 'diplomacyList':
|
|
return world.snapshot.diplomacy;
|
|
case 'arg':
|
|
return dispatchArgs[requirement.key as keyof typeof dispatchArgs] ?? null;
|
|
case 'env':
|
|
if (requirement.key === 'map') return world.snapshot.map;
|
|
if (requirement.key === 'cities') return world.getAllCities();
|
|
if (requirement.key === 'nations') return world.getAllNations();
|
|
if (requirement.key === 'relYear') return 200;
|
|
if (requirement.key === 'openingPartYear') return 200;
|
|
return null;
|
|
}
|
|
};
|
|
return {
|
|
has: (requirement) => get(requirement) !== null,
|
|
get,
|
|
};
|
|
};
|
|
const buildDispatchConstraintContext = (): ConstraintContext => ({
|
|
actorId: mockGeneralA.id,
|
|
cityId: mockGeneralA.cityId,
|
|
nationId: mockGeneralA.nationId,
|
|
destCityId: dispatchArgs.destCityId,
|
|
destNationId: NATION_B_ID,
|
|
args: dispatchArgs,
|
|
env: {
|
|
relYear: 200,
|
|
openingPartYear: 200,
|
|
},
|
|
mode: 'full',
|
|
});
|
|
|
|
// 3. A declaration is not yet a war, so deployment must be rejected.
|
|
const deployDef = deploySpec.createDefinition(systemEnv);
|
|
const declarationResult = evaluateConstraints(
|
|
deployDef.buildConstraints(buildDispatchConstraintContext(), dispatchArgs),
|
|
buildDispatchConstraintContext(),
|
|
buildConstraintView()
|
|
);
|
|
expect(declarationResult).toMatchObject({
|
|
kind: 'deny',
|
|
constraintName: 'hasRouteWithEnemy',
|
|
reason: '교전중인 국가가 아닙니다.',
|
|
});
|
|
expect(world.getGeneral(1)?.experience).toBe(100);
|
|
|
|
// 4. Simulate State Transition
|
|
for (let i = 0; i < 24; i++) {
|
|
const entries: DiplomacyEntry[] = world.snapshot.diplomacy.map((s) => ({
|
|
fromNationId: s.fromNationId,
|
|
toNationId: s.toNationId,
|
|
state: s.state,
|
|
term: s.durationMonths,
|
|
dead: 0,
|
|
meta: {},
|
|
}));
|
|
|
|
const updatedEntries = processDiplomacyMonth(entries, new Map());
|
|
|
|
world.snapshot.diplomacy = updatedEntries.map((e) => ({
|
|
fromNationId: e.fromNationId,
|
|
toNationId: e.toNationId,
|
|
state: e.state,
|
|
durationMonths: e.term,
|
|
}));
|
|
|
|
runner.currentDate.setMonth(runner.currentDate.getMonth() + 1);
|
|
}
|
|
|
|
const diplomacyAB_After = world.getDiplomacy(NATION_A_ID, NATION_B_ID);
|
|
expect(diplomacyAB_After?.state).toBe(DIPLOMACY_STATE.WAR);
|
|
|
|
// 5. The same command becomes valid after the declaration reaches WAR.
|
|
const warResult = evaluateConstraints(
|
|
deployDef.buildConstraints(buildDispatchConstraintContext(), dispatchArgs),
|
|
buildDispatchConstraintContext(),
|
|
buildConstraintView()
|
|
);
|
|
expect(warResult).toEqual({ kind: 'allow' });
|
|
});
|
|
});
|