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
@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '@sammo-ts/logic';
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import type { GeneralAI } from '../src/turn/ai/generalAi.js';
import { resolveLegacyAiStats } from '../src/turn/ai/generalAi/core.js';
import { GeneralAI } from '../src/turn/ai/generalAi.js';
import {
calculateRecentWarTurn,
resolveLegacyAiStats,
resolveLegacyAiStatsWithModules,
} from '../src/turn/ai/generalAi/core.js';
import { withCanonicalArgumentAliases } from '../src/turn/ai/aiUtils.js';
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
@@ -12,7 +16,12 @@ import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
import { do내정워프, do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
import { doNPC몰수, doNPC포상, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
import { doNPC전방발령, doNPC후방발령 } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
import { do천도 } from '../src/turn/ai/generalAi/nation/capital.js';
import {
doNPC구출발령,
doNPC전방발령,
doNPC후방발령,
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
type Candidate = {
action: string;
@@ -125,6 +134,19 @@ const baseGeneral = (): General & { turnTime: Date } => ({
meta: { killturn: 100, fullLeadership: 70 },
});
describe('GeneralAI recent war clock parity', () => {
it('uses raw logical ticks at an exact turn boundary', () => {
const general = {
...baseGeneral(),
turnTick: 72_000_099,
recentWarTick: 36_000_100,
recentWarTime: new Date('0189-12-31T23:50:00.000Z'),
} as ReturnType<typeof baseGeneral> & { turnTick: number; recentWarTick: number; recentWarTime: Date };
expect(calculateRecentWarTurn(general, 10)).toBe(0);
});
});
const baseCity = (): City => ({
id: 1,
name: '가상도시',
@@ -376,6 +398,52 @@ const makeAi = (
* selection and RNG-sensitive gates, not TypeScript implementation details.
*/
describe('legacy NPC AI final-decision parity', () => {
it('blocks another officer from starting a capital move within half a turn', () => {
const base = makeAi({ general: { officerLevel: 10, turnTick: 36_000_100 } });
const ai = Object.assign(Object.create(GeneralAI.prototype), base, {
nation: { ...base.nation!, meta: { ...base.nation!.meta, lastCapitalMoveTrial: [12, 36_000_000] } },
}) as GeneralAI;
expect(do천도(ai)).toBeNull();
});
it('continues the same capital move and records the legacy trial tick', () => {
const base = makeAi({ general: { officerLevel: 12, turnTick: 72_000_100 } });
const ai = Object.assign(Object.create(GeneralAI.prototype), base, {
nation: {
...base.nation!,
capitalCityId: 1,
meta: { ...base.nation!.meta, turn_last_12: { command: '천도', arg: { destCityID: 2 } } },
},
promotionPatches: [],
promotionNationMeta: null,
}) as GeneralAI;
expect(do천도(ai)).toMatchObject({ action: 'che_천도', args: { destCityID: 2 } });
expect(ai.consumePromotionPatches().nationMeta).toMatchObject({
lastCapitalMoveTrial: [12, 72_000_100],
});
});
it('persists the legacy last-attackable month through the nation meta patch channel', () => {
const ai = Object.assign(Object.create(GeneralAI.prototype), {
general: { ...baseGeneral(), nationId: 16 },
nation: { ...baseNation(), id: 16, meta: { last_attackable: 2234 } },
world: { currentYear: 187, currentMonth: 2, meta: {} },
worldRef: {
listDiplomacy: () => [{ fromNationId: 16, toNationId: 2, state: 0, term: 0 }],
listCities: () => [{ ...baseCity(), nationId: 16, frontState: 3 }],
},
startYear: 180,
promotionPatches: [],
promotionNationMeta: { last_attackable: 2234, chief_set: 3584 },
}) as GeneralAI;
(ai as unknown as { calcDiplomacyState: () => void }).calcDiplomacyState();
expect(ai.consumePromotionPatches().nationMeta).toMatchObject({ last_attackable: 2245, chief_set: 3584 });
});
it('normalizes legacy uppercase destination IDs before AI constraint checks', () => {
expect(
withCanonicalArgumentAliases({
@@ -409,6 +477,32 @@ describe('legacy NPC AI final-decision parity', () => {
effectiveLeadership: 70,
});
});
it('applies active action modules to the full stats used by legacy AI recruitment', () => {
const general = {
...baseGeneral(),
stats: { leadership: 68, strength: 40, intelligence: 60 },
meta: { killturn: 100 },
};
const leadershipTrait = {
onCalcStat: (context: { general: General }, statName: string, value: number): number =>
statName === 'leadership' ? value + context.general.stats.leadership * 0.25 : value,
};
const modules = singleActionModuleStack(leadershipTrait);
const world = {
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0189-01-01T00:00:00Z'),
meta: {},
};
expect(resolveLegacyAiStatsWithModules(general, baseNation(), 100, modules, null, world, 180)).toMatchObject({
fullLeadership: 85,
effectiveLeadership: 85,
});
});
it.each([
['Core scenario name', '강유'],
['Ref stored name', 'ⓝ강유'],
@@ -970,6 +1064,24 @@ describe('legacy NPC AI final-decision parity', () => {
expect(rng.choices).toEqual([0, 0]);
});
it('draws a rescue city for every lost NPC before choosing the completed pair', () => {
const rng = makeRng([], [0, 1, 1]);
const first = { ...baseGeneral(), id: 2 };
const second = { ...baseGeneral(), id: 3 };
const ai = makeAi({ rng });
ai.lostGenerals = { 2: first, 3: second };
ai.supplyCities = {
40: { ...baseCity(), id: 40, dev: 1, important: 1 },
64: { ...baseCity(), id: 64, dev: 1, important: 1 },
};
expect(doNPC구출발령(ai)).toMatchObject({
action: 'che_발령',
args: { destGeneralId: 3, destCityId: 64 },
});
expect(rng.choices).toEqual([]);
});
it('draws the NPC front-assignment general before the weighted destination city', () => {
const rng = makeRng([], [1, 20]);
const first = { ...baseGeneral(), id: 2, crew: 3000, train: 100, atmos: 100 };