fix: align scenario 2400 monthly parity
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user