test: 자동 선포의 유저국 국력 우선도를 고정한다

Ref 월간 국력 산식의 유저 장수 통솔 기여와 자동 선포 역가중 연결을 대칭 역할 교환 회귀로 검증한다. 선포 단계에 중복 배율을 추가하지 않도록 계약도 명시한다.
This commit is contained in:
2026-08-24 12:35:57 +00:00
parent 8ef93ecadc
commit f59fe782c8
3 changed files with 94 additions and 12 deletions
@@ -82,6 +82,11 @@ const calculateNationPower = (
const killCrew = readNumber(meta.rank_killcrew_person);
const deathCrew = readNumber(meta.rank_deathcrew_person);
const ratio = (killCrew + 1000) / (deathCrew + 1000);
// Ref boosts the leadership contribution of user generals here. Its
// automatic declaration AI later applies 1 / sqrt(nation.power + 1),
// so this monthly value is also the indirect lower target priority for
// nations with users; the declaration step must not add another ruler
// type multiplier on top of it.
const npcMultiplier = general.npcState < 2 ? 1.2 : 1;
const leadership = general.stats.leadership;
const leaderCore = leadership >= 40 ? leadership : 0;
@@ -420,11 +420,7 @@ const makePromotionGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => (
meta: { ...baseGeneral().meta, belong: 1, ...overrides.meta },
});
const makeDeclarationAi = (
targetNations: Nation[],
targetRulers: General[],
rng: ScriptedRng
): GeneralAI => {
const makeDeclarationAi = (targetNations: Nation[], targetRulers: General[], rng: ScriptedRng): GeneralAI => {
const actorNation = {
...baseNation(),
meta: { ...baseNation().meta, tech: 2_000 },
@@ -458,8 +454,7 @@ const makeDeclarationAi = (
listGenerals: () => [actor, ...targetRulers],
listCities: () => cities,
getNationById: (id: number) => [actorNation, ...targetNations].find((nation) => nation.id === id) ?? null,
getGeneralById: (id: number) =>
[actor, ...targetRulers].find((general) => general.id === id) ?? null,
getGeneralById: (id: number) => [actor, ...targetRulers].find((general) => general.id === id) ?? null,
getCityById: (id: number) => cities.find((city) => city.id === id) ?? null,
},
map: {
@@ -862,14 +857,14 @@ describe('legacy NPC AI final-decision parity', () => {
expect(rng.choices).toEqual([0]);
});
it('keeps active user and NPC nations equally eligible while excluding the user wandering nation', () => {
it('uses monthly power to give an active user nation lower declaration weight while excluding its wandering nation', () => {
const targets = [
{ id: 2, name: '유저방랑군', level: 0, npcState: 0 },
{ id: 3, name: '정식유저국', level: 1, npcState: 0 },
{ id: 4, name: '정식NPC국', level: 1, npcState: 2 },
];
const nations = targets.map(
({ id, name, level }) =>
({ id, name, level, npcState }) =>
({
...baseNation(),
id,
@@ -877,7 +872,11 @@ describe('legacy NPC AI final-decision parity', () => {
capitalCityId: id,
chiefGeneralId: id,
level,
power: 100,
// Ref monthly power for otherwise identical fixtures is
// 64 with a user general and 62 with an NPC general. The
// declaration AI reads that stored result; it does not
// inspect the target ruler type directly.
power: npcState < 2 ? 64 : 62,
}) satisfies Nation
);
const rulers = targets.map(
@@ -901,10 +900,11 @@ describe('legacy NPC AI final-decision parity', () => {
});
expect(userTargetRng.weightedChoices).toEqual([
{
'3': 1 / Math.sqrt(101),
'4': 1 / Math.sqrt(101),
'3': 1 / Math.sqrt(65),
'4': 1 / Math.sqrt(63),
},
]);
expect(userTargetRng.weightedChoices[0]?.['3']).toBeLessThan(userTargetRng.weightedChoices[0]?.['4'] ?? 0);
const npcTargetRng = makeRng([true], [1]);
const npcTargetAi = makeDeclarationAi(nations, rulers, npcTargetRng);
@@ -243,4 +243,81 @@ describe('monthly nation statistics boundary', () => {
},
});
});
it('raises stored power for an otherwise identical user general before declaration weighting', async () => {
const run = async (firstNpcState: number, secondNpcState: number): Promise<number[]> => {
const state: TurnWorldState = {
id: 1,
currentYear: 193,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
meta: { hiddenSeed: 'monthly-post-nation-stats-fixture' },
};
const symmetricGeneral = (id: number, nationId: number, npcState: number): TurnGeneral =>
buildGeneral({
id,
nationId,
npcState,
gold: 1_000,
rice: 2_000,
crew: 100,
leadership: 50,
strength: 40,
intelligence: 30,
experience: 100,
dedication: 200,
dexterity: [1_000, 0, 0, 0, 0],
killCrew: 100,
deathCrew: 0,
});
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
diplomacy: [],
events: [],
initialEvents: [],
generals: [symmetricGeneral(1, 1, firstNpcState), symmetricGeneral(2, 2, secondNpcState)],
cities: [buildCity(1, 1, 1), buildCity(2, 2, 1)],
nations: [
buildNation(1, {
gold: 10_000,
rice: 20_000,
power: 7,
level: 2,
meta: { tech: 100, gennum: 1 },
}),
buildNation(2, {
gold: 10_000,
rice: 20_000,
power: 8,
level: 2,
meta: { tech: 100, gennum: 1 },
}),
],
troops: [],
};
let world: InMemoryTurnWorld | null = null;
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
autoAdvanceDiplomacyMonth: false,
calendarHandler: createMonthlyNationStatsHandler({ getWorld: () => world }),
});
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
return [world.getNationById(1)?.power ?? -1, world.getNationById(2)?.power ?? -1];
};
// Disposable Ref MariaDB produces the same role-swap pairs. Comparing
// each nation ID across the swap removes its separate monthly RNG roll.
expect(await run(0, 2)).toEqual([64, 62]);
expect(await run(2, 0)).toEqual([62, 64]);
});
});