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 };
@@ -146,11 +146,7 @@ describe('ProvideNPCTroopLeader monthly action', () => {
const created = world.peekDirtyState().createdGenerals;
expect(created).toHaveLength(3);
expect(created.map((general) => general.name)).toEqual([
'㉥부대장 9',
'㉥부대장 10',
'㉥부대장 11',
]);
expect(created.map((general) => general.name)).toEqual(['㉥부대장 9', '㉥부대장 10', '㉥부대장 11']);
expect(created[0]).toMatchObject({
nationId: 1,
cityId: process.env.REF_HIDDEN_SEED ? 2 : 1,
@@ -177,6 +173,9 @@ describe('ProvideNPCTroopLeader monthly action', () => {
}))
);
for (const general of created) {
expect(general.turnTick).toBeTypeOf('number');
expect(general.turnTick! - world.dateToGameTick(general.turnTime)).toBeGreaterThanOrEqual(0);
expect(general.turnTick! - world.dateToGameTick(general.turnTime)).toBeLessThan(60);
expect(reservedTurns.getGeneralTurns(general.id)).toEqual(
Array.from({ length: 30 }, () => ({ action: 'che_집합', args: {} }))
);
@@ -186,11 +185,9 @@ describe('ProvideNPCTroopLeader monthly action', () => {
const probe = new RandUtil(
new LiteHashDRBG(simpleSerialize(process.env.REF_HIDDEN_SEED, 'troopLeader', 200, 1, 1))
);
expect([
probe.choice([1, 2]),
probe.nextRangeInt(0, 599),
probe.nextRangeInt(0, 999_999),
]).toEqual([2, 567, 821_811]);
expect([probe.choice([1, 2]), probe.nextRangeInt(0, 599), probe.nextRangeInt(0, 999_999)]).toEqual([
2, 567, 821_811,
]);
expect(
created.map((general) => ({
cityId: general.cityId,
@@ -161,7 +161,7 @@ describe('monthly speciality and betrayal actions', () => {
const logs = world.peekDirtyState().logs;
expect(logs).toHaveLength(6);
expect(logs.slice(2, 4)).toEqual([
expect(logs.filter((log) => log.generalId === 3)).toEqual([
expect.objectContaining({
generalId: 3,
category: LogCategory.HISTORY,
@@ -199,7 +199,7 @@ describe('monthly speciality and betrayal actions', () => {
expect(world.getGeneralById(1)?.role.specialWar).not.toBeNull();
});
it('persists creation scan order for speciality RNG across a reload', async () => {
it('uses general ID order instead of persisted Aria scan order', async () => {
const world = buildWorld();
const laterId = buildGeneral({
id: 5,
@@ -249,7 +249,7 @@ describe('monthly speciality and betrayal actions', () => {
.peekDirtyState()
.logs.filter((log) => log.category === LogCategory.HISTORY)
.map((log) => log.generalId)
).toEqual([1, 5, 4, 3, 2]);
).toEqual([1, 4, 5, 2, 3]);
});
it('applies the two default scenario betrayal steps only to values within each threshold', async () => {
+14 -1
View File
@@ -42,7 +42,12 @@ describe('InMemoryTurnProcessor ordering', () => {
const generals: TurnGeneral[] = [
buildGeneral(1, addMinutes(baseTime, 20)),
buildGeneral(2, addMinutes(baseTime, 10)),
{
...buildGeneral(2, addMinutes(baseTime, 10)),
turnTick: 6_000_004,
recentWarTime: null,
recentWarTick: null,
},
buildGeneral(3, addMinutes(baseTime, 10)),
];
@@ -140,6 +145,11 @@ describe('InMemoryTurnProcessor ordering', () => {
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: {
execute: ({ general }) => ({
general: general.id === 2 ? { ...general, recentWarTime: new Date(baseTime.getTime()) } : general,
}),
},
});
const executed: number[] = [];
@@ -163,6 +173,9 @@ describe('InMemoryTurnProcessor ordering', () => {
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
expect(tiedGeneralResult.processedTurns).toBe(0);
expect(executed).toEqual([2, 3]);
expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime());
expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull();
expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4);
await processor.run(addMinutes(baseTime, 30), budget);