fix: align scenario 2400 monthly parity

This commit is contained in:
2026-08-05 13:25:39 +00:00
parent f731f1f38e
commit 5d6923f11a
26 changed files with 564 additions and 89 deletions
+40 -4
View File
@@ -2,7 +2,7 @@ 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 } from '../src/actions/turn/general/che_출병.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';
@@ -183,6 +183,7 @@ describe('che_출병', () => {
const defenderCity = buildCity(2, defenderNation.id);
const neutralCity = buildCity(3, 0);
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
attacker.turnTime = new Date('2000-01-01T00:00:00Z');
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
defender.crew = 0;
defenderCity.defence = 0;
@@ -204,9 +205,36 @@ describe('che_출병', () => {
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 } },
{
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: [
@@ -235,6 +263,7 @@ describe('che_출병', () => {
);
expect(resolution.logs.length).toBeGreaterThan(0);
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({
@@ -250,6 +279,13 @@ describe('che_출병', () => {
).toBe(true);
});
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);
@@ -20,6 +20,7 @@ import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_
import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js';
import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js';
import { commandSpec as fireSpec } from '../../src/actions/turn/general/che_화계.js';
import { ActionDefinition as AppointmentAction } from '../../src/actions/turn/general/che_임관.js';
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
import {
createItemActionModules,
@@ -42,6 +43,7 @@ import {
import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js';
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js';
import { traitModule as ambitiousPersonality } from '../../src/actionModules/traits/personality/che_출세.js';
describe('General Commands New Scenario', () => {
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
@@ -75,6 +77,8 @@ describe('General Commands New Scenario', () => {
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));
expect(readLegacyStoredTech(533.3125)).toBe(533.312);
expect(readLegacyStoredTech(533.4375)).toBe(533.438);
});
it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => {
@@ -84,6 +88,7 @@ describe('General Commands New Scenario', () => {
expect(stored).not.toBe(readLegacyCityTrust(stored));
expect(readLegacyCityTrust(stored)).toBe(88.3068);
expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068);
expect(readLegacyCityTrust(storeLegacyCityTrust(93.40625))).toBe(93.4062);
});
it('rounds recruitment cost across the PHP half boundary', () => {
@@ -100,6 +105,38 @@ describe('General Commands New Scenario', () => {
expect(resolveLegacyDomesticTrust(null)).toBe(50);
});
it('applies the personality experience modifier to appointment rewards', () => {
const action = new AppointmentAction({
initialNationGenLimit: 10,
generalActionModules: [ambitiousPersonality],
} as unknown as TurnCommandEnv);
const general = {
id: 1,
name: 'General',
experience: 1_000,
role: {
personality: 'che_출세',
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
meta: {},
} as General;
const result = action.resolve(
{
general,
destNation: { id: 2, name: 'Nation', meta: {} } as Nation,
destNationGeneralCount: 10,
destCityId: 3,
addLog: () => undefined,
} as unknown as Parameters<typeof action.resolve>[0],
{ destNationId: 2 }
);
const generalPatch = result.effects.find((effect) => effect.type === 'general:patch');
expect(generalPatch).toMatchObject({ patch: { experience: 1_110 } });
});
// 1. Setup Environment
const systemEnv: TurnCommandEnv = {
develCost: 100,
+53
View File
@@ -8,6 +8,8 @@ import type { UnitSetDefinition } from '../src/world/types.js';
import { resolveWarAftermath } from '../src/war/aftermath.js';
import type { WarAftermathConfig } from '../src/war/types.js';
import { LogFormat } from '../src/logging/types.js';
import { buildWarAftermathConfig } from '../src/actions/turn/actionContextHelpers.js';
import type { ScenarioConfig } from '../src/scenario/types.js';
const buildUnitSet = (): UnitSetDefinition => ({
id: 'test',
@@ -129,6 +131,12 @@ const buildGeneral = (id: number, nationId: number, cityId: number): General =>
});
describe('war aftermath', () => {
it('defaults the omitted legacy maximum tech level to 12', () => {
const config = buildWarAftermathConfig({ const: {} } as ScenarioConfig, 999);
expect(config.maxTechLevel).toBe(12);
});
it('updates tech and diplomacy deltas', () => {
const attackerNation = buildNation(1);
const defenderNation = buildNation(2);
@@ -268,6 +276,51 @@ describe('war aftermath', () => {
);
});
it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => {
const attackerNation = buildNation(1);
const defenderNation = buildNation(2);
defenderNation.rice = 6000;
defenderNation.capitalCityId = 3;
const attackerCity = buildCity(1, 1);
const defenderCity = buildCity(2, 2);
const defenderCapital = buildCity(3, 2);
defenderCity.meta.supply = 1;
const attacker = buildGeneral(1, 1, 1);
resolveWarAftermath({
battle: {
attacker,
defenders: [],
defenderCity,
logs: [],
conquered: true,
reports: [
{
id: defenderCity.id,
type: 'city',
name: defenderCity.name,
isAttacker: false,
killed: 0,
dead: 100,
phase: 0,
},
],
},
attackerNation,
defenderNation,
attackerCity,
defenderCity,
nations: [attackerNation, defenderNation],
cities: [attackerCity, defenderCity, defenderCapital],
generals: [attacker],
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
});
expect(defenderNation.rice).toBe(6500);
});
it('applies conquest collapse rewards', () => {
const rng = new RandUtil(new ConstantRNG(0));
const attackerNation = buildNation(1);
+6
View File
@@ -312,6 +312,7 @@ describe('war triggers', () => {
const city = buildCity();
const attackerGeneral = buildGeneral(80);
attackerGeneral.turnTime = new Date('2026-07-26T13:38:45.000Z');
attackerGeneral.turnTick = 123_456;
const attacker = new WarUnitGeneral(
rng,
@@ -338,8 +339,13 @@ describe('war triggers', () => {
attacker.setOppose(defender);
defender.setOppose(attacker);
expect(attackerGeneral.recentWarTime?.toISOString()).toBe('2026-07-26T13:38:45.000Z');
expect(attackerGeneral.recentWarTick).toBe(123_400);
expect(attackerGeneral.meta.recent_war_phase).toBe(0);
attackerGeneral.turnTick = 123_556;
attacker.setOppose(null);
expect(attackerGeneral.recentWarTick).toBe(123_500);
attacker.beginPhase();
const [module] = await loadWarTriggerModules(['che_필살']);