From f4ee51251fb09e4a3303a5dfddb38e0517d0abf4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 5 Aug 2026 15:21:45 +0000 Subject: [PATCH] fix: preserve nation and recruit AI boundaries --- .../ai/generalAi/general/recruitActions.ts | 85 +++++++++---------- app/game-engine/src/turn/inMemoryWorld.ts | 2 +- .../src/turn/reservedTurnHandler.ts | 7 ++ .../generalAiLegacyDecisionParity.test.ts | 29 +++++++ .../logic/src/actions/turn/nation/che_천도.ts | 11 ++- .../logic/test/actions/turn/nation.test.ts | 16 +++- 6 files changed, 98 insertions(+), 52 deletions(-) diff --git a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts index e70a682..a451faa 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts @@ -5,8 +5,8 @@ import { isCrewTypeAvailable, } from '@sammo-ts/logic/world/unitSet.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 { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import type { GeneralAI } from '../core.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); let armTypeDraw: number | null = null; - const armType = forcedArmType > 0 - ? forcedArmType - : traceEnabled - ? (() => { - armTypeDraw = ai.rng.nextFloat1(); - let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0); - for (const [candidate, weight] of armTypeWeights) { - if (cursor <= weight) return candidate; - cursor -= Math.max(0, weight); - } - return armTypeWeights.at(-1)![0]; - })() - : ai.rng.choiceUsingWeightPair(armTypeWeights); + const armType = + forcedArmType > 0 + ? forcedArmType + : traceEnabled + ? (() => { + armTypeDraw = ai.rng.nextFloat1(); + let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0); + for (const [candidate, weight] of armTypeWeights) { + if (cursor <= weight) return candidate; + cursor -= Math.max(0, weight); + } + return armTypeWeights.at(-1)![0]; + })() + : ai.rng.choiceUsingWeightPair(armTypeWeights); trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights }); const candidates = (ai.unitSet?.crewTypes ?? []) @@ -170,39 +171,31 @@ export const do징병 = (ai: GeneralAI) => { const crewTypeId = picked.id; let crewAmount = crewAmountBase; - const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100; // Ref asks the concrete che_징병 command for getCost() before deciding - // whether to halve the requested crew. That path includes personality, - // traits, items, and the final integer rounding; using the raw unit price - // makes che_출세 (+20% cost) recruit a full stack incorrectly. - const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []); - const goldCost = Math.round( - actionPipeline.onCalcDomestic( - { - general: ai.general, - nation, - ...(ai.worldRef - ? { - worldView: { - listGenerals: () => ai.worldRef!.listGenerals(), - listGeneralsByCity: (cityId: number) => - ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), - listNations: () => ai.worldRef!.listNations(), - }, - } - : {}), - time: { - year: ai.world.currentYear, - month: ai.world.currentMonth, - startYear: ai.startYear, - }, - }, - '징병', - 'cost', - rawGoldCost, - { armType: picked.armType } - ) - ); + // whether to halve the requested crew. In particular, that command caps + // the charge at the actually refillable amount when the selected type is + // already equipped, then applies traits/items and legacy rounding. + const recruitContext = { + general: ai.general, + nation, + ...(ai.worldRef + ? { + worldView: { + listGenerals: () => ai.worldRef!.listGenerals(), + listGeneralsByCity: (cityId: number) => + ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => ai.worldRef!.listNations(), + }, + } + : {}), + time: { + 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; const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)); const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)); const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1)); diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 305ec6b..95c3b3a 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -277,7 +277,7 @@ const normalizeGeneralMetaDatabaseIntegers = (meta: TurnGeneral['meta']): TurnGe // Keeping fractional action results in memory until the monthly flush changes // later aggregation (notably nation power), even if the eventual DB rows look // identical after they are rounded. -const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({ +export const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({ ...general, nationId: toLegacyDatabaseInt(general.nationId), cityId: toLegacyDatabaseInt(general.cityId), diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 55325b0..2acca6d 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -44,6 +44,7 @@ import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } fr import type { ConstraintContext, StateView } from '@sammo-ts/logic'; import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js'; +import { normalizeGeneralDatabaseIntegers } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js'; import type { ReservedTurnEntry } from './reservedTurnStore.js'; @@ -1691,6 +1692,12 @@ export const createReservedTurnHandler = async (options: { nationAiState = ai.getDebugState(); } 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 ( worldView && (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 71dc550..2c7a5bb 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -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([ [0, 0], [0, 2000], diff --git a/packages/logic/src/actions/turn/nation/che_천도.ts b/packages/logic/src/actions/turn/nation/che_천도.ts index 919a480..2382d37 100644 --- a/packages/logic/src/actions/turn/nation/che_천도.ts +++ b/packages/logic/src/actions/turn/nation/che_천도.ts @@ -25,6 +25,7 @@ import type { NationTurnCommandSpec } from './index.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import { z } from 'zod'; import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; const ARGS_SCHEMA = z.object({ destCityID: z.preprocess(normalizeLegacyIntegerArg, z.number()), @@ -109,8 +110,11 @@ export class ActionDefinition< public readonly key = 'che_천도'; public readonly name = ACTION_NAME; public readonly countsAsInheritanceActiveAction = true; + private readonly pipeline: GeneralActionPipeline; - constructor(private readonly env: TurnCommandEnv) {} + constructor(private readonly env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } parseArgs(raw: unknown): MoveCapitalArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -256,8 +260,9 @@ export class ActionDefinition< }), ]; - general.experience += 5 * (dist * 2 + 1); - general.dedication += 5 * (dist * 2 + 1); + const reward = 5 * (dist * 2 + 1); + general.experience += this.pipeline.onCalcStat(context, 'experience', reward); + general.dedication += this.pipeline.onCalcStat(context, 'dedication', reward); return { effects }; } diff --git a/packages/logic/test/actions/turn/nation.test.ts b/packages/logic/test/actions/turn/nation.test.ts index f7aaafa..f6872ab 100644 --- a/packages/logic/test/actions/turn/nation.test.ts +++ b/packages/logic/test/actions/turn/nation.test.ts @@ -343,12 +343,22 @@ describe('Nation Actions', () => { }); describe('che_천도 (Move Capital)', () => { - it('changes nation capital city', () => { + it('changes nation capital city and applies general reward modules', () => { const nation = buildNation(1); const city1 = buildCity(1, 1); const city2 = buildCity(2, 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 context = { @@ -374,6 +384,8 @@ describe('Nation Actions', () => { patch: expect.objectContaining({ capitalCityId: 2 }), }) ); + expect(general.experience).toBe(113.5); + expect(general.dedication).toBe(115); }); });