fix: match scenario 2601 monthly seed progression
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
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 { 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';
|
||||
import { do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
|
||||
import { do거병, do건국, do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
|
||||
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후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
|
||||
import { doNPC몰수, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.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';
|
||||
|
||||
type Candidate = {
|
||||
action: string;
|
||||
@@ -74,7 +78,24 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng =>
|
||||
};
|
||||
};
|
||||
|
||||
const baseGeneral = (): General => ({
|
||||
const singleActionModuleStack = (
|
||||
module: NonNullable<GeneralAI['commandEnv']['generalActionModules']>[number]
|
||||
): NonNullable<GeneralAI['commandEnv']['generalActionModules']> => {
|
||||
const noOp = {};
|
||||
return createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality: module,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
});
|
||||
};
|
||||
|
||||
const baseGeneral = (): General & { turnTime: Date } => ({
|
||||
id: 1,
|
||||
name: '가상장수',
|
||||
nationId: 1,
|
||||
@@ -99,6 +120,7 @@ const baseGeneral = (): General => ({
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 2,
|
||||
turnTime: new Date('0190-01-01T00:00:00Z'),
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 100, fullLeadership: 70 },
|
||||
});
|
||||
@@ -155,6 +177,7 @@ const makeAi = (
|
||||
nations?: Nation[];
|
||||
generals?: General[];
|
||||
disabledPolicyActions?: string[];
|
||||
generalActionModules?: NonNullable<GeneralAI['commandEnv']['generalActionModules']>;
|
||||
} = {}
|
||||
): GeneralAI => {
|
||||
const general = {
|
||||
@@ -278,6 +301,7 @@ const makeAi = (
|
||||
maxTechLevel: 10,
|
||||
techLevelIncYear: 5,
|
||||
initialAllowedTechLevel: 1,
|
||||
generalActionModules: overrides.generalActionModules ?? [],
|
||||
},
|
||||
aiConst: {
|
||||
baseGold: 1000,
|
||||
@@ -352,6 +376,109 @@ const makeAi = (
|
||||
* selection and RNG-sensitive gates, not TypeScript implementation details.
|
||||
*/
|
||||
describe('legacy NPC AI final-decision parity', () => {
|
||||
it('normalizes legacy uppercase destination IDs before AI constraint checks', () => {
|
||||
expect(
|
||||
withCanonicalArgumentAliases({
|
||||
destGeneralID: 2,
|
||||
destCityID: 3,
|
||||
destNationID: 4,
|
||||
destTroopID: 5,
|
||||
})
|
||||
).toMatchObject({
|
||||
destGeneralId: 2,
|
||||
destCityId: 3,
|
||||
destNationId: 4,
|
||||
destTroopId: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the legacy nation-level leadership bonus for officers', () => {
|
||||
const ruler = { ...baseGeneral(), officerLevel: 12, injury: 0 };
|
||||
const nation = { ...baseNation(), level: 1 };
|
||||
|
||||
expect(resolveLegacyAiStats(ruler, nation, 255)).toMatchObject({
|
||||
fullLeadership: 72,
|
||||
effectiveLeadership: 72,
|
||||
});
|
||||
expect(resolveLegacyAiStats({ ...ruler, officerLevel: 5 }, nation, 255)).toMatchObject({
|
||||
fullLeadership: 71,
|
||||
effectiveLeadership: 71,
|
||||
});
|
||||
expect(resolveLegacyAiStats({ ...ruler, officerLevel: 1 }, nation, 255)).toMatchObject({
|
||||
fullLeadership: 70,
|
||||
effectiveLeadership: 70,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
['Core scenario name', '강유'],
|
||||
['Ref stored name', 'ⓝ강유'],
|
||||
])('uses the full ruler name and Ref nation-type/color RNG order: %s', (_label, name) => {
|
||||
const rng = makeRng([], ['che_음양가', 19]);
|
||||
const ai = makeAi({ general: { name }, rng });
|
||||
ai.aiConst.availableNationTypes = ['che_도적', 'che_음양가'];
|
||||
|
||||
expect(do건국(ai)).toMatchObject({
|
||||
action: 'che_건국',
|
||||
args: { nationName: '㉿강유', nationType: 'che_음양가', colorType: 19 },
|
||||
});
|
||||
});
|
||||
|
||||
it('counts a wandering-nation ruler on a neutral city as occupying the uprising radius', () => {
|
||||
const rng = makeRng([false, true]);
|
||||
const currentCity = { ...baseCity(), id: 1, nationId: 0, level: 1 };
|
||||
const rulerCity = { ...baseCity(), id: 2, nationId: 0, level: 5 };
|
||||
const ruler = { ...baseGeneral(), id: 2, nationId: 9, cityId: 2, officerLevel: 12 };
|
||||
const ai = {
|
||||
general: { ...baseGeneral(), nationId: 0, cityId: 1, npcState: 2, meta: {} },
|
||||
city: currentCity,
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
defaults: {},
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '현재',
|
||||
level: 1,
|
||||
region: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
connections: [2],
|
||||
max: {},
|
||||
initial: {},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '군주',
|
||||
level: 5,
|
||||
region: 1,
|
||||
position: { x: 1, y: 0 },
|
||||
connections: [1],
|
||||
max: {},
|
||||
initial: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
worldRef: {
|
||||
listCities: () => [currentCity, rulerCity],
|
||||
listGenerals: () => [ruler],
|
||||
getCityById: (id: number) => (id === 1 ? currentCity : id === 2 ? rulerCity : null),
|
||||
},
|
||||
generalPolicy: { can: (name: string) => name === '건국' },
|
||||
rng,
|
||||
aiConst: { defaultStatNpcMax: 100, chiefStatMin: 70 },
|
||||
world: { currentYear: 179, meta: { initYear: 179 } },
|
||||
startYear: 179,
|
||||
buildGeneralCandidate: (action: string, args: Record<string, unknown>, reason: string) => ({
|
||||
action,
|
||||
args,
|
||||
reason,
|
||||
}),
|
||||
} as unknown as GeneralAI;
|
||||
|
||||
expect(do거병(ai)).toBeNull();
|
||||
expect(rng.bools).toEqual([true]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 0],
|
||||
[0, 2],
|
||||
@@ -386,6 +513,30 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('applies legacy personality cost modifiers before halving recruit crew', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 2,
|
||||
general: {
|
||||
gold: 1_000,
|
||||
rice: 10_000,
|
||||
meta: { killturn: 100, fullLeadership: 70, rank_killcrew: 0, rank_deathcrew: 1 },
|
||||
},
|
||||
generalActionModules: singleActionModuleStack(
|
||||
{
|
||||
eventHandlers: {},
|
||||
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
|
||||
}
|
||||
),
|
||||
rng: makeRng([], [0, 0]),
|
||||
});
|
||||
|
||||
expect(do징병(ai)).toMatchObject({
|
||||
action: 'che_징병',
|
||||
args: { crewType: 1, amount: 3_500 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 0],
|
||||
[0, 2000],
|
||||
@@ -433,6 +584,68 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(agriculture[1]).toBe(420);
|
||||
});
|
||||
|
||||
it('coerces fractional nation tech to an integer before the legacy modulo', () => {
|
||||
const rng = makeRng([false], [0]);
|
||||
const ai = makeAi({
|
||||
dipState: 1,
|
||||
genType: 6,
|
||||
general: {
|
||||
stats: { leadership: 73, strength: 44, intelligence: 98 },
|
||||
meta: {
|
||||
killturn: 100,
|
||||
effectiveLeadership: 73,
|
||||
effectiveStrength: 69,
|
||||
effectiveIntelligence: 109,
|
||||
},
|
||||
},
|
||||
city: {
|
||||
population: 75_098,
|
||||
populationMax: 108_500,
|
||||
},
|
||||
nation: { meta: { tech: 564.87353515625 } },
|
||||
year: 185,
|
||||
rng,
|
||||
});
|
||||
|
||||
do전쟁내정(ai);
|
||||
const weights = rng.weightedPairs.at(-1)!;
|
||||
const technology = weights.find(([candidate]) => (candidate as Candidate).action === 'che_기술연구')!;
|
||||
expect(technology[1]).toBeCloseTo(109 / (565 / 3000), 12);
|
||||
});
|
||||
|
||||
it('uses injury-adjusted legacy stats when weighting domestic choices', () => {
|
||||
const rng = makeRng([], [2]);
|
||||
const ai = makeAi({
|
||||
genType: 5,
|
||||
general: {
|
||||
stats: { leadership: 68, strength: 71, intelligence: 40 },
|
||||
meta: {
|
||||
killturn: 100,
|
||||
effectiveLeadership: 68,
|
||||
effectiveStrength: 81,
|
||||
effectiveIntelligence: 58,
|
||||
},
|
||||
},
|
||||
city: {
|
||||
population: 100_000,
|
||||
populationMax: 293_700,
|
||||
defence: 2000,
|
||||
defenceMax: 5900,
|
||||
wall: 2000,
|
||||
wallMax: 6300,
|
||||
security: 1000,
|
||||
securityMax: 4000,
|
||||
meta: { trust: 50, trade: 100 },
|
||||
},
|
||||
rng,
|
||||
});
|
||||
|
||||
expect(do일반내정(ai)?.action).toBe('che_수비강화');
|
||||
const weights = rng.weightedPairs.at(-1)!;
|
||||
const defence = weights.find(([candidate]) => (candidate as Candidate).action === 'che_수비강화')!;
|
||||
expect(defence[1]).toBeCloseTo(238.95);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1500, 400, null],
|
||||
[10_000, 1000, 'che_군량매매'],
|
||||
@@ -443,6 +656,48 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do금쌀구매(ai)?.action ?? null).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses the global command cap rather than the nation-specific reward cap for trade', () => {
|
||||
const ai = makeAi({ general: { gold: 355, rice: 3177 } });
|
||||
ai.maxResourceActionAmount = 1200;
|
||||
|
||||
// 명령 parser가 이 원시 수량을 Ref처럼 백 단위 1400으로 반올림한다.
|
||||
expect(do금쌀구매(ai)?.args).toEqual({ buyRice: false, amount: 1411 });
|
||||
});
|
||||
|
||||
it('uses full leadership and the unit rice price for the trade reserve estimate', () => {
|
||||
const ai = makeAi({
|
||||
general: {
|
||||
gold: 4000,
|
||||
rice: 2000,
|
||||
stats: { leadership: 10, strength: 70, intelligence: 70 },
|
||||
meta: { killturn: 100, fullLeadership: 70 },
|
||||
},
|
||||
});
|
||||
const crewType = ai.unitSet?.crewTypes?.[0];
|
||||
if (!crewType) throw new Error('missing test crew type');
|
||||
crewType.cost = 9;
|
||||
crewType.rice = 20;
|
||||
|
||||
expect(do금쌀구매(ai)?.action).toBe('che_군량매매');
|
||||
});
|
||||
|
||||
it('applies domestic action cost modifiers to the trade recruit reserve estimate', () => {
|
||||
const ai = makeAi({
|
||||
general: { gold: 900, rice: 100 },
|
||||
generalActionModules: singleActionModuleStack({
|
||||
eventHandlers: {},
|
||||
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||
turnType === '징병' && varType === 'cost' ? value * 2 : value,
|
||||
}),
|
||||
});
|
||||
const crewType = ai.unitSet?.crewTypes?.[0];
|
||||
if (!crewType) throw new Error('missing test crew type');
|
||||
crewType.cost = 9;
|
||||
crewType.rice = 9;
|
||||
|
||||
expect(do금쌀구매(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('randomly chooses between supply and search when national resources are sufficient', () => {
|
||||
const ai = makeAi({ rng: makeRng([], [1]) });
|
||||
expect(do중립(ai)?.action).toBe('che_인재탐색');
|
||||
@@ -481,6 +736,16 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do국가선택(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not count the Core-only neutral nation as a legacy nation', () => {
|
||||
const ai = makeAi({
|
||||
general: { nationId: 0 },
|
||||
year: 181,
|
||||
nations: [{ ...baseNation(), id: 0, name: '재야' }],
|
||||
rng: makeRng([true]),
|
||||
});
|
||||
expect(do국가선택(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 4, 100, 100, 2000, null],
|
||||
[true, 3, 100, 100, 2000, null],
|
||||
@@ -519,6 +784,32 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do후방워프(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses full leadership for the legacy rear-warp recruitment floor', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 4,
|
||||
general: {
|
||||
crew: 0,
|
||||
stats: { leadership: 10, strength: 70, intelligence: 70 },
|
||||
meta: { killturn: 100, fullLeadership: 100 },
|
||||
},
|
||||
city: { population: 10_000 },
|
||||
});
|
||||
ai.categorizeNationCities = () => {
|
||||
const candidate = {
|
||||
...baseCity(),
|
||||
id: 2,
|
||||
population: 35_000,
|
||||
populationMax: 50_000,
|
||||
dev: 0.7,
|
||||
important: 0,
|
||||
};
|
||||
ai.backupCities = { 2: candidate };
|
||||
ai.supplyCities = { 2: candidate };
|
||||
};
|
||||
|
||||
expect(do후방워프(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('categorizes generals before weighting a front-line warp destination', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 4,
|
||||
@@ -537,6 +828,32 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(categorizedGenerals).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the legacy empty city-general counts when weighting a domestic warp', () => {
|
||||
const ai = makeAi({ rng: makeRng([false, true]) });
|
||||
ai.categorizeNationCities = () => {
|
||||
ai.supplyCities = {
|
||||
1: { ...baseCity(), dev: 1, important: 0, generals: {} },
|
||||
2: {
|
||||
...baseCity(),
|
||||
id: 2,
|
||||
agriculture: 1000,
|
||||
commerce: 1000,
|
||||
security: 1000,
|
||||
defence: 1000,
|
||||
wall: 1000,
|
||||
dev: 0.1,
|
||||
important: 0,
|
||||
generals: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
ai.categorizeNationGeneral = () => {
|
||||
throw new Error('Ref does not categorize generals in do내정워프');
|
||||
};
|
||||
|
||||
expect(do내정워프(ai)?.action).toBe('che_NPC능동');
|
||||
});
|
||||
|
||||
it('awards a resource-poor civil user general like the legacy nation AI', () => {
|
||||
const ai = makeAi();
|
||||
const civilGeneral = {
|
||||
@@ -553,6 +870,116 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do유저장포상(ai)?.action).toBe('che_포상');
|
||||
});
|
||||
|
||||
it('consumes the legacy reward draw before a selected command fails constraints', () => {
|
||||
const rng = makeRng();
|
||||
const ai = makeAi({ rng, blockedActions: ['che_포상'] });
|
||||
const civilGeneral = {
|
||||
...baseGeneral(),
|
||||
id: 2,
|
||||
npcState: 0,
|
||||
gold: 0,
|
||||
rice: 20_000,
|
||||
meta: { killturn: 100, fullLeadership: 70 },
|
||||
turnTime: new Date('0190-01-01T00:00:00Z'),
|
||||
};
|
||||
ai.userGenerals = { 2: civilGeneral };
|
||||
ai.userWarGenerals = {};
|
||||
|
||||
expect(do유저장포상(ai)).toBeNull();
|
||||
expect(rng.weightedPairs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps Ref multiplication order at an exact NPC reward resource boundary', () => {
|
||||
const rng = makeRng();
|
||||
const ai = makeAi({
|
||||
year: 185,
|
||||
startYear: 180,
|
||||
nation: { rice: 12_088, meta: { tech: 1_011.416320800781 } },
|
||||
rng,
|
||||
});
|
||||
ai.nationPolicy.reqNationRice = 10_000;
|
||||
ai.nationPolicy.reqNpcWarRice = 4_000;
|
||||
ai.maxResourceActionAmount = 2_400;
|
||||
const candidate = (id: number, leadership: number, rice: number) => ({
|
||||
...baseGeneral(),
|
||||
id,
|
||||
stats: { ...baseGeneral().stats, leadership },
|
||||
rice,
|
||||
crewTypeId: 1,
|
||||
meta: { killturn: 100, fullLeadership: leadership },
|
||||
});
|
||||
ai.npcWarGenerals = {
|
||||
180: candidate(180, 88, 3_005),
|
||||
743: candidate(743, 80, 3_036),
|
||||
};
|
||||
ai.npcCivilGenerals = {};
|
||||
|
||||
expect(doNPC포상(ai)).toMatchObject({
|
||||
action: 'che_포상',
|
||||
args: { destGeneralId: 180, isGold: false },
|
||||
});
|
||||
expect(rng.weightedPairs[0]?.[0]?.[0]).toMatchObject({ destGeneralId: 180 });
|
||||
expect(rng.weightedPairs[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes no-population recruitment specialists before NPC rear assignment draws RNG', () => {
|
||||
const rng = makeRng([], [0, 0]);
|
||||
const specialist = {
|
||||
...baseGeneral(),
|
||||
id: 2,
|
||||
cityId: 2,
|
||||
crew: 0,
|
||||
role: { ...baseGeneral().role, specialWar: 'che_징병' },
|
||||
turnTime: new Date('0190-01-01T00:00:00Z'),
|
||||
};
|
||||
const ai = makeAi({
|
||||
dipState: 4,
|
||||
rng,
|
||||
generals: [baseGeneral(), specialist],
|
||||
generalActionModules: singleActionModuleStack(
|
||||
{
|
||||
eventHandlers: {},
|
||||
onCalcDomestic: (context, turnType, varType, value) =>
|
||||
context.general.id === 2 && turnType === '징집인구' && varType === 'score' ? 0 : value,
|
||||
}
|
||||
),
|
||||
});
|
||||
ai.frontCities = { 1: { ...baseCity(), frontState: 3, dev: 1, important: 1 } };
|
||||
ai.supplyCities = {
|
||||
2: {
|
||||
...baseCity(),
|
||||
id: 2,
|
||||
population: 40_000,
|
||||
populationMax: 100_000,
|
||||
dev: 1,
|
||||
important: 1,
|
||||
},
|
||||
3: { ...baseCity(), id: 3, population: 100_000, dev: 1, important: 1 },
|
||||
};
|
||||
ai.backupCities = { 3: ai.supplyCities[3]! };
|
||||
ai.npcWarGenerals = { 2: specialist };
|
||||
|
||||
expect(doNPC후방발령(ai)).toBeNull();
|
||||
expect(rng.choices).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
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 };
|
||||
const second = { ...baseGeneral(), id: 3, crew: 3000, train: 100, atmos: 100 };
|
||||
const ai = makeAi({ dipState: 4, rng });
|
||||
ai.npcWarGenerals = { 2: first, 3: second };
|
||||
ai.nationCities = { 1: { ...baseCity(), dev: 1, important: 1 } };
|
||||
ai.frontCities = {
|
||||
20: { ...baseCity(), id: 20, frontState: 2, dev: 1, important: 1 },
|
||||
};
|
||||
|
||||
expect(doNPC전방발령(ai)).toMatchObject({
|
||||
action: 'che_발령',
|
||||
args: { destGeneralId: 3, destCityId: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it('seizes a small war-NPC surplus while the treasury is below 1.5x reserve', () => {
|
||||
const ai = makeAi({ nation: { gold: 12_000, rice: 100_000 } });
|
||||
const warGeneral = {
|
||||
|
||||
@@ -152,6 +152,49 @@ const makeState = (): TurnWorldState => ({
|
||||
});
|
||||
|
||||
describe('legacy general-turn execution contract', () => {
|
||||
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(makeGeneral()),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
|
||||
harness.world.updateGeneral(1, {
|
||||
experience: 10.5,
|
||||
dedication: 20.49,
|
||||
gold: 1_000.5,
|
||||
rice: 1_000.49,
|
||||
meta: { killturn: 24, dex4: 100.5, intel_exp: 29.5 },
|
||||
});
|
||||
|
||||
expect(harness.world.getGeneralById(1)).toMatchObject({
|
||||
experience: 11,
|
||||
dedication: 20,
|
||||
gold: 1_001,
|
||||
rice: 1_000,
|
||||
meta: { killturn: 24, dex4: 101, intel_exp: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('applies inherited domestic stat progression after farming', async () => {
|
||||
const general = makeGeneral({ meta: { killturn: 24, intel_exp: 29 } });
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_농지개간', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
expect(harness.world.getGeneralById(1)).toMatchObject({
|
||||
stats: { leadership: 80, strength: 70, intelligence: 61 },
|
||||
meta: { intel_exp: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('runs injury recovery and troop rice consumption before the reserved command', async () => {
|
||||
const general = makeGeneral({ injury: 25, crew: 200, rice: 1 });
|
||||
const harness = await createTurnTestHarness({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type N
|
||||
|
||||
import { createIncomeHandler } from '../src/turn/incomeHandler.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js';
|
||||
import {
|
||||
createNewYearHandler,
|
||||
createNoticeToHistoryLogHandler,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
createResetOfficerLockHandler,
|
||||
} from '../src/turn/monthlyCoreEventAction.js';
|
||||
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const map: MapDefinition = {
|
||||
@@ -318,4 +320,148 @@ describe('core monthly event actions at the real month boundary', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps fractional income for the legacy salary payout ratio', async () => {
|
||||
const nation = buildNation();
|
||||
nation.gold = 0;
|
||||
nation.meta = { ...nation.meta, rate: 15, rate_tmp: 15, bill: 100 };
|
||||
const world = buildWorld(
|
||||
[
|
||||
{
|
||||
id: 4,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [['ProcessIncome', 'gold']],
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
(getWorld) => {
|
||||
const incomeHandler = createIncomeHandler({
|
||||
getWorld,
|
||||
scenarioConfig: {
|
||||
stat: {
|
||||
total: 300,
|
||||
min: 10,
|
||||
max: 100,
|
||||
npcTotal: 150,
|
||||
npcMax: 50,
|
||||
npcMin: 10,
|
||||
chiefMin: 70,
|
||||
},
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { baseGold: 0, baseRice: 0 },
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
},
|
||||
nationTraits: new Map(),
|
||||
});
|
||||
return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]);
|
||||
},
|
||||
{
|
||||
currentMonth: 12,
|
||||
generals: [buildGeneral(1, 1, 3)],
|
||||
nations: [nation],
|
||||
cities: [buildCity()],
|
||||
}
|
||||
);
|
||||
|
||||
await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
|
||||
|
||||
expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5);
|
||||
});
|
||||
|
||||
it('uses the Ref default nation resource floors when scenario const omits them', async () => {
|
||||
const nation = buildNation();
|
||||
nation.rice = 0;
|
||||
const general = buildGeneral(1, 1, 3);
|
||||
const world = buildWorld(
|
||||
[
|
||||
{
|
||||
id: 5,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [['ProcessIncome', 'rice']],
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
(getWorld) => {
|
||||
const incomeHandler = createIncomeHandler({
|
||||
getWorld,
|
||||
scenarioConfig: {
|
||||
stat: {
|
||||
total: 300,
|
||||
min: 10,
|
||||
max: 100,
|
||||
npcTotal: 150,
|
||||
npcMax: 50,
|
||||
npcMin: 10,
|
||||
chiefMin: 70,
|
||||
},
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
},
|
||||
nationTraits: new Map(),
|
||||
});
|
||||
return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]);
|
||||
},
|
||||
{
|
||||
currentMonth: 6,
|
||||
generals: [general],
|
||||
nations: [nation],
|
||||
cities: [buildCity()],
|
||||
}
|
||||
);
|
||||
|
||||
await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
|
||||
|
||||
expect(world.getNationById(1)?.rice).toBe(2_000);
|
||||
expect(world.getGeneralById(1)?.rice).toBe(general.rice);
|
||||
});
|
||||
|
||||
it('excludes the NPC ruler stipend when choosing the nation bill like Ref', () => {
|
||||
const config: TurnWorldSnapshot['scenarioConfig'] = {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { baseGold: 0, baseRice: 0 },
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
};
|
||||
const buildFinanceWorld = (chiefDedication: number) => {
|
||||
const chief = buildGeneral(1, 1, 3);
|
||||
chief.npcState = 2;
|
||||
chief.officerLevel = 12;
|
||||
chief.dedication = chiefDedication;
|
||||
const subordinate = buildGeneral(2, 1, 3);
|
||||
subordinate.npcState = 2;
|
||||
return buildWorld([], () => new Map(), {
|
||||
currentMonth: 12,
|
||||
generals: [chief, subordinate],
|
||||
nations: [buildNation()],
|
||||
cities: [buildCity()],
|
||||
});
|
||||
};
|
||||
const options = { scenarioConfig: config, commandEnv: buildCommandEnv(config) };
|
||||
const lowChiefWorld = buildFinanceWorld(1);
|
||||
const highChiefWorld = buildFinanceWorld(100_000);
|
||||
|
||||
const lowChiefBill = calculateNpcNationFinance(
|
||||
lowChiefWorld,
|
||||
lowChiefWorld.getNationById(1)!,
|
||||
12,
|
||||
options
|
||||
)?.bill;
|
||||
const highChiefBill = calculateNpcNationFinance(
|
||||
highChiefWorld,
|
||||
highChiefWorld.getNationById(1)!,
|
||||
12,
|
||||
options
|
||||
)?.bill;
|
||||
|
||||
expect(lowChiefBill).toBeTypeOf('number');
|
||||
expect(highChiefBill).toBe(lowChiefBill);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +140,9 @@ describe('monthly event pipeline', () => {
|
||||
[
|
||||
'Trace',
|
||||
(args, environment) => {
|
||||
trace.push(`${String(args[0])}:${environment.year}-${environment.month}`);
|
||||
trace.push(
|
||||
`${String(args[0])}:${environment.year}-${environment.month}:${environment.turnTime.toISOString()}`
|
||||
);
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -176,7 +178,11 @@ describe('monthly event pipeline', () => {
|
||||
|
||||
await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
|
||||
|
||||
expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']);
|
||||
expect(trace).toEqual([
|
||||
'pre:189-12:0189-12-31T23:50:00.000Z',
|
||||
'month-high:190-1:0189-12-31T23:50:00.000Z',
|
||||
'month-low:190-1:0189-12-31T23:50:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports logic conditions and persists DeleteEvent through dirty state', async () => {
|
||||
@@ -236,7 +242,7 @@ describe('monthly event pipeline', () => {
|
||||
},
|
||||
],
|
||||
actions,
|
||||
[buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7), buildCity(6, 8)]
|
||||
[buildCity(6, 8), buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7)]
|
||||
);
|
||||
actions.set(
|
||||
'RandomizeCityTradeRate',
|
||||
@@ -254,12 +260,12 @@ describe('monthly event pipeline', () => {
|
||||
marker: city.meta.marker,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 6, trade: 102, marker: 6 },
|
||||
{ id: 1, trade: null, marker: 1 },
|
||||
{ id: 2, trade: null, marker: 2 },
|
||||
{ id: 3, trade: 101, marker: 3 },
|
||||
{ id: 4, trade: 100, marker: 4 },
|
||||
{ id: 5, trade: 105, marker: 5 },
|
||||
{ id: 6, trade: 102, marker: 6 },
|
||||
]);
|
||||
expect(world.peekDirtyState().cities.map((city) => city.id)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
@@ -386,7 +392,7 @@ describe('monthly event pipeline', () => {
|
||||
wall: 81,
|
||||
meta: { trade: 100, marker: 1 },
|
||||
});
|
||||
expect(damagedCity?.meta.trust).toBeCloseTo(79.6);
|
||||
expect(damagedCity?.meta.trust).toBe(Math.fround(79.6));
|
||||
expect(world.getGeneralById(1)).toMatchObject({ injury: 0, crew: 99, atmos: 50, train: 51 });
|
||||
expect(world.getGeneralById(2)).toMatchObject({ injury: 7, crew: 97, atmos: 49, train: 50 });
|
||||
expect(world.getGeneralById(3)).toMatchObject({ injury: 80, crew: 97, atmos: 49, train: 50 });
|
||||
|
||||
@@ -50,7 +50,7 @@ const buildCity = (id: number, nationId = 1, level = 4): City => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const buildNation = (level: number): Nation => ({
|
||||
const buildNation = (level: number, patch: Partial<Nation> = {}): Nation => ({
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#000000',
|
||||
@@ -62,6 +62,7 @@ const buildNation = (level: number): Nation => ({
|
||||
level,
|
||||
typeCode: 'che_중립',
|
||||
meta: { marker: 1 },
|
||||
...patch,
|
||||
});
|
||||
|
||||
const buildGeneral = (id: number, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
@@ -113,6 +114,7 @@ const buildHarness = async (
|
||||
itemModules?: ItemModule[];
|
||||
configConst?: Record<string, unknown>;
|
||||
additionalOccupiedCounts?: Map<string, number>;
|
||||
neutralCityCount?: number;
|
||||
} = {}
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
@@ -143,8 +145,27 @@ const buildHarness = async (
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })],
|
||||
cities: Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
|
||||
nations: [buildNation(nationLevel)],
|
||||
cities: [
|
||||
...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
|
||||
...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) =>
|
||||
buildCity(cityCount + index + 1, 0)
|
||||
),
|
||||
],
|
||||
nations: [
|
||||
...(options.neutralCityCount
|
||||
? [
|
||||
buildNation(0, {
|
||||
id: 0,
|
||||
name: '재야',
|
||||
capitalCityId: null,
|
||||
chiefGeneralId: null,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
buildNation(nationLevel),
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [event],
|
||||
@@ -236,6 +257,15 @@ describe('UpdateNationLevel monthly action', () => {
|
||||
expect(world.peekDirtyState().logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('never promotes the Core-only neutral nation sentinel from neutral cities', async () => {
|
||||
const { world, handler } = await buildHarness(0, 0, { neutralCityCount: 21 });
|
||||
|
||||
await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event);
|
||||
|
||||
expect(world.getNationById(0)).toMatchObject({ level: 0, gold: 0, rice: 0 });
|
||||
expect(world.peekDirtyState().logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets both rename permissions only on promotion to emperor', async () => {
|
||||
const { world, handler } = await buildHarness(6, 21);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createMonthlyNationCountHandler,
|
||||
createMonthlyNationStatsHandler,
|
||||
createMonthlyWarSettingHandler,
|
||||
roundLegacyNationPowerValue,
|
||||
} from '../src/turn/monthlyNationStatsHandler.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
@@ -107,6 +108,10 @@ const buildNation = (
|
||||
});
|
||||
|
||||
describe('monthly nation statistics boundary', () => {
|
||||
it('stabilizes MariaDB decimal half boundaries before rounding', () => {
|
||||
expect(roundLegacyNationPowerValue(342.49999999999994)).toBe(343);
|
||||
});
|
||||
|
||||
it('matches the fixed legacy power, maxima, war-setting count, and final general cache', async () => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation, NationTraitModule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createProcessSemiAnnualHandler } from '../src/turn/monthlySemiAnnualAction.js';
|
||||
import {
|
||||
createProcessSemiAnnualHandler,
|
||||
storeLegacySemiAnnualTrust,
|
||||
} from '../src/turn/monthlySemiAnnualAction.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const buildCity = (id: number, patch: Partial<City> = {}): City => ({
|
||||
@@ -151,6 +154,11 @@ const environment = {
|
||||
};
|
||||
|
||||
describe('ProcessSemiAnnual monthly action', () => {
|
||||
it('stores the adjusted trust at the MariaDB FLOAT boundary', () => {
|
||||
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).toBe(Math.fround(98.30675));
|
||||
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).not.toBe(98.30675);
|
||||
});
|
||||
|
||||
it('preserves the global popIncrease order, neutral double decay, supplied filtering, and nation trait', async () => {
|
||||
const { world, handler } = buildHarness({
|
||||
configConst: { basePopIncreaseAmount: 15_000 },
|
||||
@@ -241,6 +249,17 @@ describe('ProcessSemiAnnual monthly action', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds decimal half boundaries like MariaDB ROUND', async () => {
|
||||
const { world, handler } = buildHarness({
|
||||
cities: [buildCity(1, { defence: 2_000, wall: 2_000 })],
|
||||
nations: [buildNation(1, { meta: { rate: 15 } })],
|
||||
});
|
||||
|
||||
await handler(['gold'], environment, event);
|
||||
|
||||
expect(world.getCityById(1)).toMatchObject({ defence: 2_030, wall: 2_030 });
|
||||
});
|
||||
|
||||
it('applies strict legacy resource thresholds to generals and nations for either resource', async () => {
|
||||
const amounts = [1_000, 1_001, 10_000, 10_001, 100_000, 100_001];
|
||||
const { world, handler } = buildHarness({
|
||||
|
||||
@@ -120,7 +120,7 @@ describeDb('scenario database seed', () => {
|
||||
expect(worldState?.config).toMatchObject({ tournamentTrig: true });
|
||||
expect(generalCount).toBeGreaterThan(0);
|
||||
const seededGeneral = await prisma.general.findFirst();
|
||||
expect(seededGeneral?.startAge).toBe(seededGeneral?.age);
|
||||
expect(seededGeneral?.startAge).toBe(20);
|
||||
expect(seededGeneral?.meta).toEqual(
|
||||
expect.objectContaining({
|
||||
specage: expect.any(Number),
|
||||
@@ -239,6 +239,7 @@ describeDb('scenario database seed', () => {
|
||||
expect(config.tournamentTrig).toBe(false);
|
||||
|
||||
const meta = (worldState.meta ?? {}) as Record<string, unknown>;
|
||||
expect(meta.develcost).toBe((worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2);
|
||||
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
||||
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
||||
expect(autorunOptions.develop).toBe(true);
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('monthly tournament auto start', () => {
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 2,
|
||||
termSeconds: 600,
|
||||
termSeconds: 10,
|
||||
nextAt: '2026-07-25T00:10:00.000Z',
|
||||
});
|
||||
expect(world.getState().meta.tournamentPattern).toEqual([0, 1, 2]);
|
||||
@@ -218,8 +218,8 @@ describe('monthly tournament auto start', () => {
|
||||
const second = await run();
|
||||
expect(second).toEqual(first);
|
||||
expect(first).toEqual({
|
||||
tournamentState: expect.objectContaining({ type: 1 }),
|
||||
remainingPattern: [2, 0, 0, 3],
|
||||
tournamentState: expect.objectContaining({ type: 0, bettingId: 1 }),
|
||||
remainingPattern: [1, 3, 2, 0],
|
||||
});
|
||||
} finally {
|
||||
random.mockRestore();
|
||||
|
||||
@@ -280,12 +280,11 @@ describe('TurnDaemonLifecycle', () => {
|
||||
await loop;
|
||||
});
|
||||
|
||||
it('runs scheduled turn based on queue front and checkpoint context', async () => {
|
||||
it('catches up through the observed clock with queue and checkpoint context', async () => {
|
||||
const turnTermMinutes = 10;
|
||||
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
|
||||
const generalTurnQueue = [addMinutes(lastTurnTime, 5), addMinutes(lastTurnTime, 20)];
|
||||
const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes);
|
||||
const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime());
|
||||
const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime();
|
||||
const checkpoint = {
|
||||
turnTime: lastTurnTime.toISOString(),
|
||||
generalId: 101,
|
||||
@@ -358,12 +357,11 @@ describe('TurnDaemonLifecycle', () => {
|
||||
await loop;
|
||||
});
|
||||
|
||||
it('runs scheduled turn when tick boundary arrives before queue front', async () => {
|
||||
it('catches up through the observed clock when tick boundary precedes the queue front', async () => {
|
||||
const turnTermMinutes = 10;
|
||||
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
|
||||
const generalTurnQueue = [addMinutes(lastTurnTime, 15), addMinutes(lastTurnTime, 30)];
|
||||
const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes);
|
||||
const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime());
|
||||
const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime();
|
||||
const checkpoint = {
|
||||
turnTime: lastTurnTime.toISOString(),
|
||||
generalId: 102,
|
||||
|
||||
@@ -150,13 +150,23 @@ describe('InMemoryTurnProcessor ordering', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await processor.run(addMinutes(baseTime, 30), {
|
||||
const budget = {
|
||||
budgetMs: 1000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
};
|
||||
|
||||
expect(executed).toEqual([2, 3, 1]);
|
||||
const boundaryResult = await processor.run(addMinutes(baseTime, 10), budget);
|
||||
expect(boundaryResult.processedTurns).toBe(1);
|
||||
expect(executed).toEqual([]);
|
||||
|
||||
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
|
||||
expect(tiedGeneralResult.processedTurns).toBe(0);
|
||||
expect(executed).toEqual([2, 3]);
|
||||
|
||||
await processor.run(addMinutes(baseTime, 30), budget);
|
||||
|
||||
expect(executed).toEqual([2, 3, 1, 2, 3]);
|
||||
expect(world.getNextGeneralId()).toBe(4);
|
||||
expect(world.getNextGeneralId()).toBe(5);
|
||||
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
|
||||
|
||||
Reference in New Issue
Block a user