fix: match scenario 2601 monthly seed progression
This commit is contained in:
@@ -233,6 +233,43 @@ describe('crew type catalog', () => {
|
||||
|
||||
expect(computeBattleOrder(defender, attacker)).toBe(10000);
|
||||
});
|
||||
|
||||
it('uses live injured and full action-adjusted stats for defender order', () => {
|
||||
const footman = crewType(1100, 1, '보병');
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true);
|
||||
const defenderGeneral = { ...buildGeneral(2, footman.id), injury: 50 };
|
||||
const defender = buildGeneralUnit(
|
||||
rng,
|
||||
defenderGeneral,
|
||||
footman,
|
||||
false,
|
||||
new WarActionPipeline([
|
||||
{
|
||||
onCalcStat: (_context, statName, value) =>
|
||||
statName === 'leadership' && typeof value === 'number' ? value + 40 : value,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(computeBattleOrder(defender, attacker)).toBe(260);
|
||||
});
|
||||
|
||||
it('excludes defenders below the legacy defence training threshold', () => {
|
||||
const footman = crewType(1100, 1, '보병');
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true);
|
||||
const baseDefender = buildGeneral(2, footman.id);
|
||||
const defenderGeneral = {
|
||||
...baseDefender,
|
||||
train: 79,
|
||||
atmos: 80,
|
||||
meta: { ...baseDefender.meta, defence_train: 80 },
|
||||
};
|
||||
const defender = buildGeneralUnit(rng, defenderGeneral, footman, false);
|
||||
|
||||
expect(computeBattleOrder(defender, attacker)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crew type war triggers', () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createRefOrderedActionStack } from '../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../src/actionModules/general.js';
|
||||
import { ActionDefinition } from '../src/actions/turn/general/che_소집해제.js';
|
||||
|
||||
describe('che_소집해제', () => {
|
||||
it('applies legacy experience and dedication stat hooks', () => {
|
||||
const personality = {
|
||||
eventHandlers: {},
|
||||
onCalcStat: (_context, statName, value) => {
|
||||
if (statName === 'experience') return Number(value) * 1.1;
|
||||
if (statName === 'dedication') return Number(value) * 0.9;
|
||||
return value;
|
||||
},
|
||||
} satisfies GeneralActionModule;
|
||||
const noOp = {};
|
||||
const definition = new ActionDefinition({
|
||||
generalActionModules: createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
}),
|
||||
} as never);
|
||||
const general = { crew: 500, experience: 1_000, dedication: 2_000 };
|
||||
const city = { population: 10_000 };
|
||||
|
||||
definition.resolve(
|
||||
{
|
||||
general,
|
||||
city,
|
||||
addLog: () => undefined,
|
||||
} as never,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(general.experience).toBe(1_077);
|
||||
expect(general.dedication).toBe(2_090);
|
||||
});
|
||||
});
|
||||
@@ -181,8 +181,12 @@ describe('che_출병', () => {
|
||||
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);
|
||||
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
||||
defender.crew = 0;
|
||||
defenderCity.defence = 0;
|
||||
defenderCity.wall = 0;
|
||||
|
||||
const definition = new ActionDefinition();
|
||||
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
||||
@@ -192,10 +196,23 @@ describe('che_출병', () => {
|
||||
rng,
|
||||
destCity: defenderCity,
|
||||
destNation: defenderNation,
|
||||
cities: [attackerCity, defenderCity],
|
||||
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,
|
||||
@@ -220,6 +237,9 @@ describe('che_출병', () => {
|
||||
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({ city: resolution.city, patches: resolution.patches?.cities }).toMatchObject({
|
||||
city: { frontState: 2 },
|
||||
});
|
||||
expect(
|
||||
resolution.effects.some(
|
||||
(effect) =>
|
||||
@@ -229,4 +249,77 @@ describe('che_출병', () => {
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,6 +191,10 @@ describe('Blank Start Scenario', () => {
|
||||
const newNation = world.getNation(newNationId)!;
|
||||
expect(newNation.chiefGeneralId).toBe(gen0.id);
|
||||
expect(newNation.level).toBe(0); // Wandering Nation
|
||||
// Ref che_거병 does not install a nation-specific NPC policy. Leaving
|
||||
// this key absent preserves AutorunNationPolicy's 50,000 population
|
||||
// floor instead of silently overriding it with zero.
|
||||
expect(newNation.meta).not.toHaveProperty('npc_nation_policy');
|
||||
|
||||
// --- Step 2: Gen 1 performs Appointment ---
|
||||
// Before appointment, Gen 0 should FAIL Founding because general count = 1
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { MapDefinition } from '../../../src/world/types.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import { createRefOrderedActionStack } from '../../../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../../../src/actionModules/general.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
@@ -146,7 +148,7 @@ function createViewState(world: InMemoryWorld, year: number = 200, env: TurnComm
|
||||
}
|
||||
|
||||
describe('che_귀환', () => {
|
||||
it('should return to capital if normal officer', async () => {
|
||||
it('should return to capital and apply legacy experience modifiers', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
@@ -203,7 +205,23 @@ describe('che_귀환', () => {
|
||||
generalId: general.id,
|
||||
commandKey: 'che_귀환',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_귀환.js')).commandSpec.createDefinition(
|
||||
{} as any
|
||||
{
|
||||
...systemEnv,
|
||||
generalActionModules: createRefOrderedActionStack<GeneralActionModule>({
|
||||
nation: {
|
||||
onCalcStat: (_context, statName, value) =>
|
||||
statName === 'experience' && typeof value === 'number' ? value * 1.1 : value,
|
||||
},
|
||||
officer: {},
|
||||
domestic: {},
|
||||
war: {},
|
||||
personality: {},
|
||||
crewType: null,
|
||||
inheritance: {},
|
||||
scenario: null,
|
||||
items: [],
|
||||
}),
|
||||
}
|
||||
),
|
||||
args: {},
|
||||
},
|
||||
@@ -211,7 +229,7 @@ describe('che_귀환', () => {
|
||||
|
||||
const updated = world.getGeneral(general.id);
|
||||
expect(updated?.cityId).toBe(101); // Capital
|
||||
expect(updated?.experience).toBe(70);
|
||||
expect(updated?.experience).toBe(77);
|
||||
expect(updated?.dedication).toBe(100);
|
||||
expect(updated?.meta.leadership_exp).toBe(1);
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ 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 procureSpec } from '../../src/actions/turn/general/che_물자조달.js';
|
||||
import {
|
||||
commandSpec as procureSpec,
|
||||
roundLegacyAccumulatedInteger,
|
||||
} from '../../src/actions/turn/general/che_물자조달.js';
|
||||
import { commandSpec as donateSpec } from '../../src/actions/turn/general/che_헌납.js';
|
||||
import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js';
|
||||
import { commandSpec as wanderSpec } from '../../src/actions/turn/general/che_방랑.js';
|
||||
@@ -24,8 +27,65 @@ import {
|
||||
loadItemModules,
|
||||
} from '../../src/items/index.js';
|
||||
import { createRefOrderedActionStack } from '../../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../../src/actionModules/general.js';
|
||||
import {
|
||||
normalizeLegacyGeneratedDex,
|
||||
resolveLegacySpecialityAge,
|
||||
} from '../../src/actions/turn/general/che_인재탐색.js';
|
||||
import {
|
||||
addLegacyStoredTech,
|
||||
readLegacyStoredTech,
|
||||
toLegacyStoredTech,
|
||||
} from '../../src/actions/turn/general/che_기술연구.js';
|
||||
import {
|
||||
readLegacyCityTrust,
|
||||
storeLegacyCityTrust,
|
||||
} from '../../src/actions/turn/general/legacyCityTrust.js';
|
||||
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
|
||||
|
||||
describe('General Commands New Scenario', () => {
|
||||
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
|
||||
expect(normalizeLegacyGeneratedDex([36.5, 7.9, 7.1, 7.99, 0.75])).toEqual([36, 7, 7, 7, 0]);
|
||||
});
|
||||
|
||||
it('rounds the accumulated procurement experience like a MariaDB INT assignment', () => {
|
||||
const delta = (45 * 0.7) / 3;
|
||||
expect(delta).toBe(10.499999999999998);
|
||||
expect(Math.round(delta)).toBe(10);
|
||||
expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565);
|
||||
});
|
||||
|
||||
it('persists generated NPC speciality ages from the legacy creation date', () => {
|
||||
expect(resolveLegacySpecialityAge(80, 22, 12)).toBe(27);
|
||||
expect(resolveLegacySpecialityAge(80, 22, 6)).toBe(32);
|
||||
expect(resolveLegacySpecialityAge(80, 24, 12)).toBe(29);
|
||||
});
|
||||
|
||||
it('stores technology as binary32 without per-update decimal quantization', () => {
|
||||
const value = 433.51797;
|
||||
expect(toLegacyStoredTech(value)).toBe(Math.fround(value));
|
||||
expect(toLegacyStoredTech(value)).not.toBe(Number(Math.fround(value).toPrecision(6)));
|
||||
expect(readLegacyStoredTech(624.0966796875)).toBe(624.097);
|
||||
expect(addLegacyStoredTech(624.0966796875, 22.9)).toBe(Math.fround(624.097 + 22.9));
|
||||
});
|
||||
|
||||
it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => {
|
||||
const stored = storeLegacyCityTrust(88.306755);
|
||||
|
||||
expect(stored).toBe(Math.fround(88.306755));
|
||||
expect(stored).not.toBe(readLegacyCityTrust(stored));
|
||||
expect(readLegacyCityTrust(stored)).toBe(88.3068);
|
||||
expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068);
|
||||
});
|
||||
|
||||
it('rounds recruitment cost across the PHP half boundary', () => {
|
||||
const cavalryCost = (11 * 1.15 * 7000) / 100;
|
||||
|
||||
expect(cavalryCost).toBe(885.4999999999999);
|
||||
expect(Math.round(cavalryCost)).toBe(885);
|
||||
expect(roundLegacyRecruitCost(cavalryCost)).toBe(886);
|
||||
});
|
||||
|
||||
// 1. Setup Environment
|
||||
const systemEnv: TurnCommandEnv = {
|
||||
develCost: 100,
|
||||
@@ -168,7 +228,26 @@ describe('General Commands New Scenario', () => {
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
// 1. Procure
|
||||
const procureDef = procureSpec.createDefinition(systemEnv);
|
||||
const procureDef = procureSpec.createDefinition({
|
||||
...systemEnv,
|
||||
generalActionModules: (() => {
|
||||
const noOp = {};
|
||||
return createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality: {
|
||||
eventHandlers: {},
|
||||
onCalcStat: (_context, statName, value) => (statName === 'experience' ? value * 1.1 : value),
|
||||
} satisfies GeneralActionModule,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
});
|
||||
})(),
|
||||
});
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: 1,
|
||||
@@ -191,8 +270,12 @@ describe('General Commands New Scenario', () => {
|
||||
]);
|
||||
|
||||
const n1_after_procure = world.getNation(1)!;
|
||||
const g1_after_procure = world.getGeneral(1)!;
|
||||
// Nation gains gold
|
||||
expect(n1_after_procure.gold).toBeGreaterThan(10000);
|
||||
// Ref's addExperience/addDedication route rewards through onCalcStat.
|
||||
expect(g1_after_procure.experience).toBe(183);
|
||||
expect(g1_after_procure.dedication).toBe(208);
|
||||
|
||||
// 2. Donate
|
||||
const donateDef = donateSpec.createDefinition(systemEnv);
|
||||
|
||||
@@ -7,8 +7,18 @@ import { commandSpec as recruitSpec } from '../../src/actions/turn/general/che_
|
||||
import { commandSpec as trainSpec } from '../../src/actions/turn/general/che_훈련.js';
|
||||
import { commandSpec as atmosSpec } from '../../src/actions/turn/general/che_사기진작.js';
|
||||
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||
import { createRefOrderedActionStack } from '../../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../../src/actionModules/general.js';
|
||||
import { applyLegacyInjury, finalizeLegacyStat } from '../../src/actions/turn/general/legacyGeneralStat.js';
|
||||
|
||||
describe('Troop Management Scenario', () => {
|
||||
it('applies injury before action stat modifiers and floors like Ref General::getLeadership()', () => {
|
||||
const injured = applyLegacyInjury(62, 3);
|
||||
expect(injured).toBeCloseTo(60.14, 12);
|
||||
expect(finalizeLegacyStat(injured * 2)).toBe(120);
|
||||
expect(Math.round(((120 * 100) / 6076) * 30)).toBe(59);
|
||||
});
|
||||
|
||||
it('should successfully draft troops, then train and boost morale', async () => {
|
||||
// 1. Setup World
|
||||
const mockNation: Nation = {
|
||||
@@ -130,6 +140,29 @@ describe('Troop Management Scenario', () => {
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
maxResourceActionAmount: 1000,
|
||||
...(snapshot.unitSet ? { unitSet: snapshot.unitSet } : {}),
|
||||
generalActionModules: (() => {
|
||||
const noOp = {};
|
||||
return createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality: {
|
||||
eventHandlers: {},
|
||||
onCalcStat: (_context, statName, value) => {
|
||||
if (typeof value !== 'number') return value;
|
||||
if (statName === 'experience') return value * 0.9;
|
||||
if (statName === 'addDex') return value * 0.5;
|
||||
return value;
|
||||
},
|
||||
} satisfies GeneralActionModule,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
});
|
||||
})(),
|
||||
};
|
||||
|
||||
// 2. Draft Troops
|
||||
@@ -145,8 +178,20 @@ describe('Troop Management Scenario', () => {
|
||||
|
||||
const generalAfterDraft = world.getGeneral(1)!;
|
||||
expect(generalAfterDraft.crew).toBe(1000);
|
||||
// Ref's General::addExperience() applies personality/item stat modules
|
||||
// after rounding the crew-based base reward. Dedication is routed
|
||||
// through the same pipeline but remains unchanged in this fixture.
|
||||
expect(generalAfterDraft.experience).toBe(109);
|
||||
expect(generalAfterDraft.dedication).toBe(110);
|
||||
// General::addDex(): 보병(armType 1)은 징병 인원 / 100만큼 숙련도가 오른다.
|
||||
expect(generalAfterDraft.meta.dex1).toBe(10);
|
||||
expect(generalAfterDraft.meta.dex1).toBe(5);
|
||||
// Ref che_징병 stores aux.armType and its AI keeps that category on
|
||||
// later recruitment decisions.
|
||||
expect(generalAfterDraft.meta.armType).toBe(1);
|
||||
// 훈련/사기진작의 실제 증가분과 addDex 파이프라인을 관찰할 여유를 만든다.
|
||||
world.snapshot.generals = world.snapshot.generals.map((general) =>
|
||||
general.id === generalAfterDraft.id ? { ...general, train: 80, atmos: 80 } : general
|
||||
);
|
||||
|
||||
// 3. Train
|
||||
const trainDef = trainSpec.createDefinition(systemEnv);
|
||||
@@ -162,6 +207,10 @@ describe('Troop Management Scenario', () => {
|
||||
const generalAfterTrain = world.getGeneral(1)!;
|
||||
// 레거시 훈련식: round(통솔 * 100 * trainDelta / 병력), 상한까지 적용.
|
||||
expect(generalAfterTrain.train).toBe(100);
|
||||
// General::addExperience()/addDedication()/addDex()와 동일하게 모듈 보정을 거친다.
|
||||
expect(generalAfterTrain.experience).toBe(199);
|
||||
expect(generalAfterTrain.dedication).toBe(180);
|
||||
expect(generalAfterTrain.meta.dex1).toBe(15);
|
||||
|
||||
// 4. Boost Morale
|
||||
const atmosDef = atmosSpec.createDefinition(systemEnv);
|
||||
@@ -177,5 +226,8 @@ describe('Troop Management Scenario', () => {
|
||||
const generalAfterAtmos = world.getGeneral(1)!;
|
||||
// 레거시 사기진작식: round(통솔 * 100 / 병력 * atmosDelta), 명령 상한까지 적용.
|
||||
expect(generalAfterAtmos.atmos).toBe(100);
|
||||
expect(generalAfterAtmos.experience).toBe(289);
|
||||
expect(generalAfterAtmos.dedication).toBe(250);
|
||||
expect(generalAfterAtmos.meta.dex1).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createGeneralTriggerContext } from '../src/triggers/general.js';
|
||||
import {
|
||||
createTraitCatalog,
|
||||
createTraitModules,
|
||||
loadEventDomesticTraitModules,
|
||||
loadDomesticTraitModules,
|
||||
loadWarTraitModules,
|
||||
} from '../src/actionModules/traits/index.js';
|
||||
@@ -251,6 +252,40 @@ describe('trait modules', () => {
|
||||
expect(general.triggerState.flags['pre.치료']).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns event-의술 healing draws in legacy general-id order', async () => {
|
||||
const eventDomestic = await loadEventDomesticTraitModules(['che_event_의술']);
|
||||
const registry = createTraitCatalog({ domestic: eventDomestic });
|
||||
const traitModules = createTraitModules(registry);
|
||||
const pipeline = new GeneralActionPipeline(traitModules.general);
|
||||
const healer = buildGeneral({
|
||||
id: 818,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: 'che_event_의술',
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
});
|
||||
const lowerIdPatient = buildGeneral({ id: 444, name: 'Lower', injury: 12 });
|
||||
const higherIdPatient = buildGeneral({ id: 775, name: 'Higher', injury: 30 });
|
||||
const worldView = {
|
||||
listGeneralsByCity: (_cityId: number) => [healer, higherIdPatient, lowerIdPatient],
|
||||
listGenerals: () => [healer, higherIdPatient, lowerIdPatient],
|
||||
};
|
||||
let draw = 0;
|
||||
const rng: RandomGenerator = {
|
||||
nextFloat1: () => 0,
|
||||
nextBool: () => draw++ === 0,
|
||||
nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive,
|
||||
};
|
||||
const triggerContext = createGeneralTriggerContext({ general: healer, rng, worldView });
|
||||
|
||||
pipeline.getPreTurnExecuteTriggerList({ general: healer, worldView }).fire(triggerContext, {});
|
||||
|
||||
expect(lowerIdPatient.injury).toBe(0);
|
||||
expect(higherIdPatient.injury).toBe(30);
|
||||
});
|
||||
|
||||
it('activates 의술 battle trigger and reduces damage', async () => {
|
||||
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
|
||||
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
|
||||
|
||||
@@ -170,13 +170,49 @@ describe('war aftermath', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(attackerNation.meta.tech).toBe(1000.6);
|
||||
expect(defenderNation.meta.tech).toBe(1000.9);
|
||||
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
|
||||
expect(defenderNation.meta.tech).toBe(Math.fround(1000.9));
|
||||
expect(outcome.diplomacyDeltas).toHaveLength(2);
|
||||
expect(attackerCity.meta.dead).toBe(60);
|
||||
expect(defenderCity.meta.dead).toBe(90);
|
||||
});
|
||||
|
||||
it('truncates each city casualty split before accumulating it', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
attackerCity.meta.dead = 10;
|
||||
defenderCity.meta.dead = 20;
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
|
||||
resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: false,
|
||||
reports: [
|
||||
{ id: attacker.id, type: 'general', name: attacker.name, isAttacker: true, killed: 101, dead: 52 },
|
||||
],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation],
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
});
|
||||
|
||||
expect(attackerCity.meta.dead).toBe(71);
|
||||
expect(defenderCity.meta.dead).toBe(111);
|
||||
});
|
||||
|
||||
it('logs emergency relocation when a surviving nation loses its capital', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
@@ -307,6 +343,62 @@ describe('war aftermath', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches ruined-nation lord ordering and NPC appointment draws', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
defenderNation.chiefGeneralId = 10;
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
const lord = buildGeneral(10, 2, 2);
|
||||
const npc = buildGeneral(2, 2, 2);
|
||||
npc.npcState = 2;
|
||||
|
||||
const rangeDraws = [0.2, 0.21, 0.4, 0.41];
|
||||
const nextBool = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
const rng = {
|
||||
nextRange: vi.fn(() => rangeDraws.shift()!),
|
||||
nextBool,
|
||||
nextRangeInt: vi.fn(() => 6),
|
||||
} as unknown as RandUtil;
|
||||
|
||||
const outcome = resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: true,
|
||||
reports: [],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation],
|
||||
cities: [attackerCity, defenderCity],
|
||||
// The caller order deliberately puts the lord first.
|
||||
generals: [attacker, lord, npc],
|
||||
unitSet: buildUnitSet(),
|
||||
config: {
|
||||
...buildConfig(),
|
||||
joinMode: 'full',
|
||||
joinRuinedNpcProbability: 0.1,
|
||||
},
|
||||
time: { year: 186, month: 1, startYear: 179 },
|
||||
rng,
|
||||
});
|
||||
|
||||
expect(npc.gold).toBe(800);
|
||||
expect(npc.rice).toBe(790);
|
||||
expect(lord.gold).toBe(600);
|
||||
expect(lord.rice).toBe(590);
|
||||
expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.1, 0.5]);
|
||||
expect(outcome.conquest?.ruinedNpcJoinPlans).toEqual([
|
||||
{ generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches city conquest to every stationed defender before collapse RNG', () => {
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const draws = [0.01, 0.02, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
|
||||
|
||||
@@ -166,6 +166,64 @@ const buildGeneral = (strength: number): General => ({
|
||||
});
|
||||
|
||||
describe('war triggers', () => {
|
||||
it('normalizes accumulated dexterity to the PHP SQL float precision', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.meta.dex4 = 14_677.199999999997;
|
||||
const wizard = new WarCrewType({
|
||||
...buildUnitSet().crewTypes![0]!,
|
||||
id: 104,
|
||||
armType: 4,
|
||||
name: '귀병',
|
||||
});
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
general,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
wizard,
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
|
||||
unit.addDex(wizard, 2047);
|
||||
|
||||
expect(general.meta.dex4).toBe(16_519.5);
|
||||
});
|
||||
|
||||
it('preserves the legacy fractional morale gain after a win', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.atmos = 105;
|
||||
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
general,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
const defenderCity = new WarUnitCity(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
{ ...buildCity(), nationId: 2 },
|
||||
{ ...buildNation(), id: 2 },
|
||||
new WarCrewType(buildUnitSet().crewTypes![1]!),
|
||||
new ActionLogger({ generalId: 0, nationId: 2 }),
|
||||
200,
|
||||
180
|
||||
);
|
||||
unit.setOppose(defenderCity);
|
||||
|
||||
unit.addWin();
|
||||
|
||||
expect(general.atmos).toBeCloseTo(115.5, 12);
|
||||
});
|
||||
|
||||
it('updates the legacy experience level and applies item experience modifiers immediately', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.experience = 90;
|
||||
@@ -224,6 +282,28 @@ describe('war triggers', () => {
|
||||
expect(general.meta.dex5).toBe(5090);
|
||||
});
|
||||
|
||||
it('consumes one accumulated stat-exp threshold when a battle finishes', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.meta.intel_exp = 30;
|
||||
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
general,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
|
||||
unit.finishBattle();
|
||||
|
||||
expect(general.stats.intelligence).toBe(71);
|
||||
expect(general.meta.intel_exp).toBe(0);
|
||||
});
|
||||
|
||||
it('activates and applies critical damage', async () => {
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const config = buildConfig();
|
||||
@@ -326,6 +406,30 @@ describe('war triggers', () => {
|
||||
expect(city.conflict).toEqual({ 1: 1.05, 2: 1 });
|
||||
expect(city.meta.conflict_order).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('uses the legacy virtual rice reserve for a neutral defender', () => {
|
||||
const events: string[] = [];
|
||||
const city = { ...buildCity(), nationId: 0 };
|
||||
|
||||
resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
attacker: {
|
||||
general: buildGeneral(80),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
},
|
||||
defenders: [],
|
||||
defenderCity: city,
|
||||
defenderNation: null,
|
||||
trace: (event) => events.push(event.event),
|
||||
});
|
||||
|
||||
expect(events).not.toContain('supply_retreat');
|
||||
expect(events).toContain('phase_damage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWarBattle', () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { round } from '../src/war/utils.js';
|
||||
|
||||
describe('legacy war rounding', () => {
|
||||
it('matches PHP round() at drifted positive and negative half boundaries', () => {
|
||||
expect(round(4159.499999999999)).toBe(4160);
|
||||
expect(round(-4159.499999999999)).toBe(-4160);
|
||||
});
|
||||
|
||||
it('keeps values meaningfully below a half boundary on the lower integer', () => {
|
||||
expect(round(4159.499999)).toBe(4159);
|
||||
expect(round(-4159.499999)).toBe(-4159);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCityDistance, searchDistance } from '@sammo-ts/logic/world/distance.js';
|
||||
import { getCityDistance, searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
|
||||
describe('World Distance', () => {
|
||||
@@ -69,5 +69,22 @@ describe('World Distance', () => {
|
||||
const result = searchDistance(mockMap, 1, 10);
|
||||
expect(result).not.to.have.property('8');
|
||||
});
|
||||
|
||||
it('preserves legacy BFS visit order independently of numeric object-key ordering', () => {
|
||||
const orderMap: MapDefinition = {
|
||||
id: 'order-map',
|
||||
name: 'Order Map',
|
||||
cities: [
|
||||
{ id: 1, connections: [10, 2] },
|
||||
{ id: 10, connections: [1] },
|
||||
{ id: 2, connections: [1] },
|
||||
] as any[],
|
||||
};
|
||||
expect(searchDistanceEntries(orderMap, 1, 1)).toEqual([
|
||||
[1, 0],
|
||||
[10, 1],
|
||||
[2, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,38 @@ describe('scenario bootstrap', () => {
|
||||
special: 'Special',
|
||||
text: 'Test line',
|
||||
},
|
||||
{
|
||||
affinity: 11,
|
||||
name: 'MedicalGeneral',
|
||||
picture: null,
|
||||
nation: 1,
|
||||
city: 'Alpha',
|
||||
leadership: 40,
|
||||
strength: 40,
|
||||
intelligence: 70,
|
||||
officerLevel: 1,
|
||||
birthYear: 180,
|
||||
deathYear: 240,
|
||||
personality: '출세',
|
||||
special: '의술',
|
||||
text: null,
|
||||
},
|
||||
{
|
||||
affinity: 12,
|
||||
name: 'ChargeGeneral',
|
||||
picture: null,
|
||||
nation: 1,
|
||||
city: 'Alpha',
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intelligence: 20,
|
||||
officerLevel: 1,
|
||||
birthYear: 180,
|
||||
deathYear: 240,
|
||||
personality: '패권',
|
||||
special: '돌격',
|
||||
text: null,
|
||||
},
|
||||
],
|
||||
generalsEx: [],
|
||||
generalsNeutral: [],
|
||||
@@ -129,17 +161,49 @@ describe('scenario bootstrap', () => {
|
||||
expect(result.snapshot.generals[0]?.crewTypeId).toBe(1200);
|
||||
expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special');
|
||||
expect(result.snapshot.generals[0]?.role.specialWar).toBeNull();
|
||||
expect(result.snapshot.generals[1]?.role).toMatchObject({
|
||||
personality: 'che_출세',
|
||||
specialDomestic: 'che_event_의술',
|
||||
specialWar: null,
|
||||
});
|
||||
expect(result.seed.generals[1]).toMatchObject({
|
||||
special: 'che_event_의술',
|
||||
specialWar: null,
|
||||
});
|
||||
expect(result.snapshot.generals[2]?.role).toMatchObject({
|
||||
personality: 'che_패권',
|
||||
specialDomestic: 'che_event_돌격',
|
||||
specialWar: null,
|
||||
});
|
||||
expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
||||
expect(result.seed.generals[0]?.meta).toMatchObject({
|
||||
deathMonth: expect.any(Number),
|
||||
specage: 25,
|
||||
specage2: 30,
|
||||
});
|
||||
const preOpening = buildScenarioBootstrap({
|
||||
scenario,
|
||||
map,
|
||||
unitSet,
|
||||
options: { initialYear: scenario.startYear! - 1 },
|
||||
});
|
||||
expect(preOpening.snapshot.generals[0]).toMatchObject({
|
||||
age: 19,
|
||||
meta: { specage: 25, specage2: 30 },
|
||||
});
|
||||
expect(preOpening.seed.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
||||
expect(buildScenarioBootstrap({ scenario, map, unitSet }).snapshot.generals[0]?.meta).toEqual(
|
||||
result.snapshot.generals[0]?.meta
|
||||
);
|
||||
expect(result.seed.generals[0]?.npcType).toBe(2);
|
||||
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
||||
expect(result.seed.events[0]).toEqual(['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']]);
|
||||
expect(result.seed.events.flat(3)).toContain('ProcessSemiAnnual');
|
||||
expect(result.seed.events.flat(3)).toContain('NewYear');
|
||||
expect(result.seed.initialEvents[0]).toEqual([
|
||||
true,
|
||||
['NoticeToHistoryLog', '<S>2년간 거병 및 건국이 가능합니다.</>', 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('places generals without an explicit city in a deterministic valid city', () => {
|
||||
@@ -210,7 +274,7 @@ describe('scenario bootstrap', () => {
|
||||
cities: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
ignoreDefaultEvents: false,
|
||||
ignoreDefaultEvents: true,
|
||||
};
|
||||
const map: MapDefinition = {
|
||||
id: 'test-map',
|
||||
@@ -248,6 +312,36 @@ describe('scenario bootstrap', () => {
|
||||
expect(first.seed.generals[0]?.cityId).toBe(1);
|
||||
expect([1, 2]).toContain(first.seed.generals[1]?.cityId);
|
||||
expect(first.seed.generals.every((general) => general.cityId > 0)).toBe(true);
|
||||
expect(
|
||||
first.seed.generals.map((general) => ({
|
||||
cityId: general.cityId,
|
||||
affinity: general.affinity,
|
||||
personality: general.personality,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
deathMonth: general.meta.deathMonth,
|
||||
initialTurnOffsetMicros: general.meta.initialTurnOffsetMicros,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
cityId: 1,
|
||||
affinity: 10,
|
||||
personality: 'che_안전',
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
deathMonth: 12,
|
||||
initialTurnOffsetMicros: 2_161_529_667,
|
||||
},
|
||||
{
|
||||
cityId: 2,
|
||||
affinity: 20,
|
||||
personality: 'che_재간',
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
deathMonth: 7,
|
||||
initialTurnOffsetMicros: 3_203_248_275,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('defers future generals into birth-year registration events and omits expired rows', () => {
|
||||
@@ -306,7 +400,7 @@ describe('scenario bootstrap', () => {
|
||||
cities: [],
|
||||
events: [['Month', 500, ['Date', '>=', 200, 1], ['Existing']]],
|
||||
initialEvents: [],
|
||||
ignoreDefaultEvents: false,
|
||||
ignoreDefaultEvents: true,
|
||||
};
|
||||
const map: MapDefinition = {
|
||||
id: 'test-map',
|
||||
|
||||
Reference in New Issue
Block a user