fix: preserve nation and recruit AI boundaries

This commit is contained in:
2026-08-05 15:21:45 +00:00
parent 2ee63f154f
commit f4ee51251f
6 changed files with 98 additions and 52 deletions
@@ -5,8 +5,8 @@ import {
isCrewTypeAvailable, isCrewTypeAvailable,
} from '@sammo-ts/logic/world/unitSet.js'; } from '@sammo-ts/logic/world/unitSet.js';
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic'; import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js'; import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
@@ -106,19 +106,20 @@ export const do징병 = (ai: GeneralAI) => {
} }
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes); const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
let armTypeDraw: number | null = null; let armTypeDraw: number | null = null;
const armType = forcedArmType > 0 const armType =
? forcedArmType forcedArmType > 0
: traceEnabled ? forcedArmType
? (() => { : traceEnabled
armTypeDraw = ai.rng.nextFloat1(); ? (() => {
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0); armTypeDraw = ai.rng.nextFloat1();
for (const [candidate, weight] of armTypeWeights) { let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
if (cursor <= weight) return candidate; for (const [candidate, weight] of armTypeWeights) {
cursor -= Math.max(0, weight); if (cursor <= weight) return candidate;
} cursor -= Math.max(0, weight);
return armTypeWeights.at(-1)![0]; }
})() return armTypeWeights.at(-1)![0];
: ai.rng.choiceUsingWeightPair(armTypeWeights); })()
: ai.rng.choiceUsingWeightPair(armTypeWeights);
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights }); trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
const candidates = (ai.unitSet?.crewTypes ?? []) const candidates = (ai.unitSet?.crewTypes ?? [])
@@ -170,39 +171,31 @@ export const do징병 = (ai: GeneralAI) => {
const crewTypeId = picked.id; const crewTypeId = picked.id;
let crewAmount = crewAmountBase; let crewAmount = crewAmountBase;
const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
// Ref asks the concrete che_징병 command for getCost() before deciding // Ref asks the concrete che_징병 command for getCost() before deciding
// whether to halve the requested crew. That path includes personality, // whether to halve the requested crew. In particular, that command caps
// traits, items, and the final integer rounding; using the raw unit price // the charge at the actually refillable amount when the selected type is
// makes che_출세 (+20% cost) recruit a full stack incorrectly. // already equipped, then applies traits/items and legacy rounding.
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []); const recruitContext = {
const goldCost = Math.round( general: ai.general,
actionPipeline.onCalcDomestic( nation,
{ ...(ai.worldRef
general: ai.general, ? {
nation, worldView: {
...(ai.worldRef listGenerals: () => ai.worldRef!.listGenerals(),
? { listGeneralsByCity: (cityId: number) =>
worldView: { ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listGenerals: () => ai.worldRef!.listGenerals(), listNations: () => ai.worldRef!.listNations(),
listGeneralsByCity: (cityId: number) => },
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), }
listNations: () => ai.worldRef!.listNations(), : {}),
}, time: {
} year: ai.world.currentYear,
: {}), month: ai.world.currentMonth,
time: { startYear: ai.startYear,
year: ai.world.currentYear, },
month: ai.world.currentMonth, };
startYear: ai.startYear, const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
}, const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold;
},
'징병',
'cost',
rawGoldCost,
{ armType: picked.armType }
)
);
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)); const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)); const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1)); const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
+1 -1
View File
@@ -277,7 +277,7 @@ const normalizeGeneralMetaDatabaseIntegers = (meta: TurnGeneral['meta']): TurnGe
// Keeping fractional action results in memory until the monthly flush changes // Keeping fractional action results in memory until the monthly flush changes
// later aggregation (notably nation power), even if the eventual DB rows look // later aggregation (notably nation power), even if the eventual DB rows look
// identical after they are rounded. // identical after they are rounded.
const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({ export const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({
...general, ...general,
nationId: toLegacyDatabaseInt(general.nationId), nationId: toLegacyDatabaseInt(general.nationId),
cityId: toLegacyDatabaseInt(general.cityId), cityId: toLegacyDatabaseInt(general.cityId),
@@ -44,6 +44,7 @@ import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } fr
import type { ConstraintContext, StateView } from '@sammo-ts/logic'; import type { ConstraintContext, StateView } from '@sammo-ts/logic';
import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js'; import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js';
import { normalizeGeneralDatabaseIntegers } from './inMemoryWorld.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js'; import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js';
import type { ReservedTurnEntry } from './reservedTurnStore.js'; import type { ReservedTurnEntry } from './reservedTurnStore.js';
@@ -1691,6 +1692,12 @@ export const createReservedTurnHandler = async (options: {
nationAiState = ai.getDebugState(); nationAiState = ai.getDebugState();
} }
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false); const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
// Ref persists a completed nation command before it chooses and
// executes the general command for the same turn. Preserve that
// MariaDB INT boundary so fractional rewards cannot leak into the
// following command or its AI refresh.
currentGeneral = normalizeGeneralDatabaseIntegers(currentGeneral);
worldOverlay?.syncGeneral(currentGeneral);
if ( if (
worldView && worldView &&
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
@@ -629,6 +629,35 @@ describe('legacy NPC AI final-decision parity', () => {
}); });
}); });
it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => {
const ai = makeAi({
dipState: 2,
general: {
gold: 1_030,
rice: 970,
crew: 334,
crewTypeId: 1,
meta: {
killturn: 100,
fullLeadership: 70,
rank_killcrew: 1_000,
rank_deathcrew: 100,
},
},
generalActionModules: singleActionModuleStack({
eventHandlers: {},
onCalcDomestic: (_context, turnType, varType, value) =>
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
}),
rng: makeRng([], [0, 0]),
});
// Ref prices only the 6,666 refillable soldiers: 800 gold, below the
// 820-gold reserve. It therefore keeps the full rice requirement and
// rejects recruitment, instead of halving both crew and rice cost.
expect(do징병(ai)).toBeNull();
});
it.each([ it.each([
[0, 0], [0, 0],
[0, 2000], [0, 2000],
@@ -25,6 +25,7 @@ import type { NationTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import { z } from 'zod'; import { z } from 'zod';
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
const ARGS_SCHEMA = z.object({ const ARGS_SCHEMA = z.object({
destCityID: z.preprocess(normalizeLegacyIntegerArg, z.number()), destCityID: z.preprocess(normalizeLegacyIntegerArg, z.number()),
@@ -109,8 +110,11 @@ export class ActionDefinition<
public readonly key = 'che_천도'; public readonly key = 'che_천도';
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true; public readonly countsAsInheritanceActiveAction = true;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(private readonly env: TurnCommandEnv) {} constructor(private readonly env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
parseArgs(raw: unknown): MoveCapitalArgs | null { parseArgs(raw: unknown): MoveCapitalArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw); return parseArgsWithSchema(ARGS_SCHEMA, raw);
@@ -256,8 +260,9 @@ export class ActionDefinition<
}), }),
]; ];
general.experience += 5 * (dist * 2 + 1); const reward = 5 * (dist * 2 + 1);
general.dedication += 5 * (dist * 2 + 1); general.experience += this.pipeline.onCalcStat(context, 'experience', reward);
general.dedication += this.pipeline.onCalcStat(context, 'dedication', reward);
return { effects }; return { effects };
} }
@@ -343,12 +343,22 @@ describe('Nation Actions', () => {
}); });
describe('che_천도 (Move Capital)', () => { describe('che_천도 (Move Capital)', () => {
it('changes nation capital city', () => { it('changes nation capital city and applies general reward modules', () => {
const nation = buildNation(1); const nation = buildNation(1);
const city1 = buildCity(1, 1); const city1 = buildCity(1, 1);
const city2 = buildCity(2, 1); const city2 = buildCity(2, 1);
const general = buildGeneral(1, 1, 1); const general = buildGeneral(1, 1, 1);
const env = { develCost: 100, baseGold: 100, baseRice: 100 }; const env = {
develCost: 100,
baseGold: 100,
baseRice: 100,
generalActionModules: [
{
onCalcStat: (_context: unknown, statName: string, value: number) =>
statName === 'experience' ? value * 0.9 : value,
},
],
};
const definition = new MoveCapitalAction(env as any); const definition = new MoveCapitalAction(env as any);
const context = { const context = {
@@ -374,6 +384,8 @@ describe('Nation Actions', () => {
patch: expect.objectContaining({ capitalCityId: 2 }), patch: expect.objectContaining({ capitalCityId: 2 }),
}) })
); );
expect(general.experience).toBe(113.5);
expect(general.dedication).toBe(115);
}); });
}); });