From b9e45c6e7631a8da4cadaf23340641920dc0ebda Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 09:40:14 +0000 Subject: [PATCH] fix(parity): preserve legacy monthly turn ordering --- app/game-engine/src/turn/ai/generalAi/core.ts | 17 ++- .../turn/ai/generalAi/general/npcActions.ts | 6 +- .../nation/assignments/troopAssignments.ts | 7 +- .../src/turn/ai/generalAi/nation/rewards.ts | 30 +++++ app/game-engine/src/turn/databaseHooks.ts | 21 ++- .../src/turn/inMemoryTurnProcessor.ts | 1 + app/game-engine/src/turn/inMemoryWorld.ts | 22 +++ .../src/turn/monthlyCreateManyNpcAction.ts | 13 +- .../src/turn/monthlyRaiseNpcNationAction.ts | 12 +- .../src/turn/monthlyRegisterNpcAction.ts | 12 +- .../src/turn/reservedTurnHandler.ts | 11 +- app/game-engine/src/turn/worldLoader.ts | 4 +- .../generalAiLegacyDecisionParity.test.ts | 126 ++++++++++++++++++ .../generalTurnLegacyCompatibility.test.ts | 62 +++++++++ ...logBoundaryPersistence.integration.test.ts | 12 +- .../test/monthlyCreateManyNpcAction.test.ts | 3 + .../test/monthlyRaiseNpcNationAction.test.ts | 4 + .../test/monthlyRegisterNpcAction.test.ts | 3 + app/game-engine/test/turnOrder.test.ts | 8 +- packages/common/src/turnDaemon/types.ts | 1 + .../logic/src/actions/turn/actionContext.ts | 1 + .../src/actions/turn/general/che_인재탐색.ts | 14 +- .../src/actions/turn/nation/che_의병모집.ts | 14 +- packages/logic/src/world/bootstrap.ts | 27 +++- packages/logic/test/worldBootstrap.test.ts | 12 +- .../reference-main-records.mjs | 8 +- 26 files changed, 393 insertions(+), 58 deletions(-) diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 0640725b..6dd86361 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -858,7 +858,22 @@ export class GeneralAI { continue; } - if (candidate.stats.leadership >= this.nationPolicy.minNpcWarLeadership) { + const fullLeadership = this.commandEnv.generalActionModules + ? resolveLegacyAiStatsWithModules( + candidate, + this.nation, + this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max, + this.commandEnv.generalActionModules, + this.worldRef, + this.world, + this.startYear + ).fullLeadership + : resolveLegacyAiStats( + candidate, + this.nation, + this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max + ).fullLeadership; + if (fullLeadership >= this.nationPolicy.minNpcWarLeadership) { npcWarGenerals[candidate.id] = candidate; } else { npcCivilGenerals[candidate.id] = candidate; diff --git a/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts b/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts index eff312ea..ffb348df 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/npcActions.ts @@ -41,7 +41,11 @@ export const doNPC헌납 = (ai: GeneralAI) => { genRes >= ai.aiConst.minNationalRice / 2 ) { const amount = genRes < ai.aiConst.minNationalRice ? genRes : genRes / 2; - args.push([{ isGold: false, amount }, amount]); + // Ref passes the literal string "rice" here. che_헌납::argTest() + // rejects that candidate because isGold is not boolean; preserving + // the malformed weighted candidate also preserves the RNG draw and + // lets the priority loop continue when it is selected. + args.push([{ isGold: 'rice', amount }, amount]); } if (genRes < reqRes * 1.5) { continue; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts index d2a14dd2..183b6b7d 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts @@ -128,11 +128,14 @@ export const do부대후방발령 = (ai: GeneralAI) => { return null; } + // Ref consumes the troop-leader draw before the destination-city draw. + // Both selections share the nation command RNG, so reversing them can + // assign the same two outcomes to different leaders and cities. + const leader = ai.rng.choice(troopCandidates); const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); if (!Number.isFinite(destCityId)) { return null; } - const leader = ai.rng.choice(troopCandidates); return buildAssignmentCandidate(ai, leader.id, destCityId, '부대후방발령'); }; @@ -162,10 +165,10 @@ export const do부대구출발령 = (ai: GeneralAI) => { return null; } + const leader = ai.rng.choice(troopCandidates); const destCityId = pickRandomCityId(ai, ai.frontCities); if (destCityId === null) { return null; } - const leader = ai.rng.choice(troopCandidates); return buildAssignmentCandidate(ai, leader.id, destCityId, '부대구출발령'); }; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts index e55aaa86..dff8e545 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -1,4 +1,5 @@ import type { GeneralAI } from '../core.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import type { TurnGeneral } from '../../../types.js'; import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js'; @@ -45,6 +46,35 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num }; const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => { + const modules = ai.commandEnv.generalActionModules; + if (modules && modules.length > 0) { + const pipeline = new GeneralActionPipeline(modules); + const adjusted = pipeline.onCalcStat( + { + general, + nation: ai.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, + }, + }, + 'leadership', + general.stats.leadership + ); + const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max; + return Math.trunc(Math.max(0, Math.min(Number(adjusted), maxStat))); + } const nationLevel = ai.nation?.level ?? 0; const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0; const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index a9edc63f..e982be26 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -48,7 +48,9 @@ export interface DatabaseTurnHooks { } const uniqueSortedIds = (values: Iterable): number[] => - [...new Set(values)].filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => left - right); + [...new Set(values)] + .filter((value) => Number.isSafeInteger(value) && value > 0) + .sort((left, right) => left - right); export type ReadModelSignatures = { content: string; @@ -153,7 +155,8 @@ const changedProjectionIds = ( baseline: ReadonlyMap, final: ReadonlyMap, projection: keyof ReadModelSignatures -): number[] => uniqueSortedIds(candidateIds.filter((id) => baseline.get(id)?.[projection] !== final.get(id)?.[projection])); +): number[] => + uniqueSortedIds(candidateIds.filter((id) => baseline.get(id)?.[projection] !== final.get(id)?.[projection])); const buildFinalSignatures = ( entities: readonly Entity[], @@ -219,10 +222,7 @@ export const summarizeRealtimeReadModelChanges = ( ...changes.deletedNations, ...changes.deletedNationSnapshots.map((snapshot) => snapshot.nation.id), ]); - const finalGenerals = buildFinalSignatures( - [...changes.generals, ...changes.createdGenerals], - generalSignatures - ); + const finalGenerals = buildFinalSignatures([...changes.generals, ...changes.createdGenerals], generalSignatures); const finalCities = buildFinalSignatures(changes.cities, citySignatures); const finalNations = buildFinalSignatures([...changes.nations, ...changes.createdNations], nationSignatures); const generalIds = baseline @@ -237,9 +237,7 @@ export const summarizeRealtimeReadModelChanges = ( const mapGeneralIds = baseline ? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'map') : generalIds; - const mapCityIds = baseline - ? changedProjectionIds(cityCandidates, baseline.cities, finalCities, 'map') - : cityIds; + const mapCityIds = baseline ? changedProjectionIds(cityCandidates, baseline.cities, finalCities, 'map') : cityIds; const mapNationIds = baseline ? changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'map') : nationIds; @@ -760,13 +758,14 @@ const buildGeneralCreate = ( const buildCityUpdate = ( city: ReturnType['cities'][number] ): TurnEngineCityUpdateInput => { - const meta = { + const meta: Record = { ...(city.meta as Record), state: city.state, }; const trust = readMetaNumber(meta, 'trust'); const trade = readMetaNumber(meta, 'trade'); const region = readMetaNumber(meta, 'region'); + const { trust: _projectedTrust, trade: _projectedTrade, region: _projectedRegion, ...persistedMeta } = meta; const data: TurnEngineCityUpdateInput = { name: city.name, @@ -787,7 +786,7 @@ const buildCityUpdate = ( wall: city.wall, wallMax: city.wallMax, ...(city.conflict ? { conflict: asJson(city.conflict) } : {}), - meta: asJson(meta), + meta: asJson(persistedMeta), }; if (trust !== null) { diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index 1437bbfd..2539d707 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -100,6 +100,7 @@ export class InMemoryTurnProcessor implements TurnProcessor { processedGenerals += 1; nextCheckpoint = { turnTime: executedAt.toISOString(), + turnTick: general.turnTick, generalId: general.id, year: this.world.getState().currentYear, month: this.world.getState().currentMonth, diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 95c3b3a9..72a90e01 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -190,6 +190,12 @@ export interface InMemoryTurnWorldInspection { } const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => { + if (left.turnTick !== undefined && right.turnTick !== undefined) { + const tickDiff = left.turnTick - right.turnTick; + if (tickDiff !== 0) { + return tickDiff; + } + } const timeDiff = left.turnTime.getTime() - right.turnTime.getTime(); if (timeDiff !== 0) { return timeDiff; @@ -201,6 +207,18 @@ const shouldProcessByCheckpoint = (general: TurnGeneral, checkpoint?: TurnCheckp if (!checkpoint) { return true; } + if (general.turnTick !== undefined && checkpoint.turnTick !== undefined) { + if (general.turnTick < checkpoint.turnTick) { + return false; + } + if (general.turnTick > checkpoint.turnTick) { + return true; + } + if (checkpoint.generalId === undefined) { + return false; + } + return general.id > checkpoint.generalId; + } const generalTime = general.turnTime.getTime(); const checkpointTime = new Date(checkpoint.turnTime).getTime(); if (generalTime < checkpointTime) { @@ -1211,10 +1229,14 @@ export class InMemoryTurnWorld { listDueGenerals(targetTime: Date, checkpoint?: TurnCheckpoint): TurnGeneral[] { const targetMs = targetTime.getTime(); + const targetTick = this.getGameClock().dateToTick(targetTime); const due = Array.from(this.generals.values()).filter((general) => { if (!shouldProcessByCheckpoint(general, checkpoint)) { return false; } + if (general.turnTick !== undefined) { + return general.turnTick <= targetTick; + } return general.turnTime.getTime() <= targetMs; }); due.sort(compareTurnOrder); diff --git a/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts b/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts index 571d7a1a..c538e16c 100644 --- a/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts +++ b/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts @@ -1,4 +1,4 @@ -import { JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; import { LogCategory, LogFormat, LogScope, type TurnCommandEnv } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; @@ -137,10 +137,12 @@ const buildNpc = (options: { } const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1); const turnFraction = rng.nextRangeInt(0, 999_999); - // core DB는 millisecond precision이므로 레거시 microsecond 값을 내림해 - // 저장한다. 먼 과거 연도에서 IEEE-754 덧셈이 반올림하지 않도록 먼저 - // 정수화한다. - const turnTime = new Date(environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)); + const ticksPerSecond = GAME_TICKS_PER_TURN / world.getState().tickSeconds; + const turnTick = + world.dateToGameTick(environment.turnTime) + + turnSecond * ticksPerSecond + + Math.floor((turnFraction * ticksPerSecond) / 1_000_000); + const turnTime = world.gameTickToDate(turnTick); const killturn = (deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1; const id = world.getNextGeneralId(); const general: TurnGeneral = { @@ -181,6 +183,7 @@ const buildNpc = (options: { }, lastTurn: { command: '휴식' }, turnTime, + turnTick, recentWarTime: null, meta: { killturn, diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts index fdd5fdba..5e24209e 100644 --- a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -1,4 +1,4 @@ -import { LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; import { LogCategory, LogFormat, @@ -145,9 +145,12 @@ const createNpcGeneral = (options: { } const turnSecond = rng.nextRangeInt(0, turnMinutes * 60 - 1); const turnFraction = rng.nextRangeInt(0, 999_999); - const turnTime = new Date( - environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000) - ); + const ticksPerSecond = GAME_TICKS_PER_TURN / world.getState().tickSeconds; + const turnTick = + world.dateToGameTick(environment.turnTime) + + turnSecond * ticksPerSecond + + Math.floor((turnFraction * ticksPerSecond) / 1_000_000); + const turnTime = world.gameTickToDate(turnTick); const killturn = options.killturn ?? (options.deadYear - environment.year) * 12 + @@ -188,6 +191,7 @@ const createNpcGeneral = (options: { triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, lastTurn: { command: '휴식' }, turnTime, + turnTick, recentWarTime: null, meta: { killturn, diff --git a/app/game-engine/src/turn/monthlyRegisterNpcAction.ts b/app/game-engine/src/turn/monthlyRegisterNpcAction.ts index f19f0982..eeae892b 100644 --- a/app/game-engine/src/turn/monthlyRegisterNpcAction.ts +++ b/app/game-engine/src/turn/monthlyRegisterNpcAction.ts @@ -1,4 +1,4 @@ -import { JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; import { DOMESTIC_TRAIT_KEYS, LogCategory, @@ -307,9 +307,12 @@ export const createRegisterNpcHandler = (options: { } const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1); const turnFraction = rng.nextRangeInt(0, 999_999); - const turnTime = new Date( - environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000) - ); + const ticksPerSecond = GAME_TICKS_PER_TURN / world.getState().tickSeconds; + const turnTick = + world.dateToGameTick(environment.turnTime) + + turnSecond * ticksPerSecond + + Math.floor((turnFraction * ticksPerSecond) / 1_000_000); + const turnTime = world.gameTickToDate(turnTick); const killturn = (parsed.deathYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + @@ -358,6 +361,7 @@ export const createRegisterNpcHandler = (options: { }, lastTurn: { command: '휴식' }, turnTime, + turnTick, recentWarTime: null, meta: { killturn, diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 2acca6da..d7d23a0b 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -44,7 +44,6 @@ 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'; @@ -1692,11 +1691,11 @@ 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); + // Ref persists the nation command here, but LazyVarUpdater only + // clears its dirty flags: it does not replace the same PHP + // General object's fractional values with the MariaDB INT row. + // The following general command therefore observes and adds to + // those fractions before the turn's final persistence boundary. worldOverlay?.syncGeneral(currentGeneral); if ( worldView && diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 533b50f7..ad73c36a 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -278,7 +278,9 @@ const mapGeneralRow = ( }; const mapCityRow = (row: TurnEngineCityRow): City => { - const meta = asTriggerRecord(row.meta); + // trust/trade/region are projected columns. Old flushes also copied them + // into JSON meta; never let a stale duplicate override a nullable column. + const { trust: _storedTrust, trade: _storedTrade, region: _storedRegion, ...meta } = asTriggerRecord(row.meta); const state = typeof meta.state === 'number' && Number.isFinite(meta.state) ? Math.floor(meta.state) : 0; return { id: row.id, diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 2c7a5bb7..95743cf4 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -13,6 +13,7 @@ import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.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 { doNPC헌납 } from '../src/turn/ai/generalAi/general/npcActions.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'; @@ -22,6 +23,10 @@ import { doNPC전방발령, doNPC후방발령, } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js'; +import { + do부대구출발령, + do부대후방발령, +} from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js'; type Candidate = { action: string; @@ -398,6 +403,24 @@ const makeAi = ( * selection and RNG-sensitive gates, not TypeScript implementation details. */ describe('legacy NPC AI final-decision parity', () => { + it('rejects the malformed Ref low-rice donation candidate and continues the priority loop', () => { + const rng = makeRng([false], [0]); + const ai = makeAi({ + general: { rice: 2_200, gold: 0 }, + nation: { rice: 400, gold: 20_000 }, + genType: 4, + rng, + }); + ai.nationPolicy.reqNpcWarRice = 1_000; + ai.buildGeneralCandidate = ((_action: string, args: Record) => + typeof args.isGold === 'boolean' + ? { action: 'che_헌납', args, reason: 'NPC헌납' } + : null) as GeneralAI['buildGeneralCandidate']; + + expect(doNPC헌납(ai)).toBeNull(); + expect(rng.weightedPairs).toEqual([[[{ isGold: 'rice', amount: 1_100 }, 1_100]]]); + }); + 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, { @@ -1054,6 +1077,67 @@ describe('legacy NPC AI final-decision parity', () => { expect(rng.weightedPairs[0]).toHaveLength(1); }); + it('uses target action modules for the full leadership in NPC reward costs', () => { + const ai = makeAi({ + nation: { rice: 100_000 }, + generalActionModules: singleActionModuleStack({ + eventHandlers: {}, + onCalcStat: (_context, statName, value) => + statName === 'leadership' ? Number(value) + 30 : value, + }), + }); + ai.maxResourceActionAmount = 100_000; + const crewType = ai.unitSet?.crewTypes?.[0]; + if (!crewType) throw new Error('missing test crew type'); + crewType.cost = 100; + ai.npcWarGenerals = { + 2: { + ...baseGeneral(), + id: 2, + rice: 0, + crewTypeId: crewType.id, + meta: { killturn: 100 }, + }, + }; + ai.npcCivilGenerals = {}; + + expect(doNPC포상(ai)).toMatchObject({ + action: 'che_포상', + args: { destGeneralId: 2, isGold: false, amount: 88_000 }, + }); + }); + + it('uses target action modules when classifying NPC war generals', () => { + const specialist = { + ...baseGeneral(), + id: 2, + stats: { ...baseGeneral().stats, leadership: 32 }, + meta: { killturn: 100 }, + }; + const base = makeAi({ + generals: [baseGeneral(), specialist], + generalActionModules: singleActionModuleStack({ + eventHandlers: {}, + onCalcStat: (context, statName, value) => + context.general.id === 2 && statName === 'leadership' ? Number(value) + 10 : value, + }), + }); + const ai = Object.assign(Object.create(GeneralAI.prototype), base, { + categorizedCities: false, + categorizedGenerals: false, + nationCities: {}, + frontCities: {}, + supplyCities: {}, + backupCities: {}, + }) as GeneralAI; + ai.nationPolicy.minNpcWarLeadership = 40; + + ai.categorizeNationGeneral(); + + expect(ai.npcWarGenerals[2]?.id).toBe(2); + expect(ai.npcCivilGenerals[2]).toBeUndefined(); + }); + it('excludes no-population recruitment specialists before NPC rear assignment draws RNG', () => { const rng = makeRng([], [0, 0]); const specialist = { @@ -1128,6 +1212,48 @@ describe('legacy NPC AI final-decision parity', () => { }); }); + it('draws a rear-assignment troop leader before its destination city', () => { + const rng = makeRng([], [1, 0]); + const first = { ...baseGeneral(), id: 979, cityId: 2 }; + const second = { ...baseGeneral(), id: 980, cityId: 2 }; + const ai = makeAi({ rng }); + ai.troopLeaders = { 979: first, 980: second }; + ai.nationPolicy.supportForce = [979, 980]; + ai.frontCities = { 1: { ...baseCity(), frontState: 3, dev: 1, important: 1 } }; + ai.supplyCities = { + 2: { ...baseCity(), id: 2, population: 10_000, dev: 1, important: 1 }, + 3: { ...baseCity(), id: 3, dev: 1, important: 1 }, + }; + ai.backupCities = { 3: ai.supplyCities[3]! }; + + expect(do부대후방발령(ai)).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 980, destCityId: 3 }, + }); + expect(rng.choices).toEqual([]); + }); + + it('draws a rescue-assignment troop leader before its destination city', () => { + const rng = makeRng([], [1, 0]); + const first = { ...baseGeneral(), id: 979, cityId: 99 }; + const second = { ...baseGeneral(), id: 980, cityId: 99 }; + const ai = makeAi({ rng }); + ai.troopLeaders = { 979: first, 980: second }; + ai.nationPolicy.supportForce = []; + ai.nationPolicy.combatForce = {}; + ai.frontCities = { + 20: { ...baseCity(), id: 20, frontState: 3, dev: 1, important: 1 }, + 21: { ...baseCity(), id: 21, frontState: 3, dev: 1, important: 1 }, + }; + ai.supplyCities = {}; + + expect(do부대구출발령(ai)).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 980, destCityId: 20 }, + }); + expect(rng.choices).toEqual([]); + }); + 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 = { diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index 4c8da17a..84a15ba2 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { ConstantRNG, RandUtil } from '@sammo-ts/common'; import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; @@ -201,6 +202,67 @@ describe('legacy general-turn execution contract', () => { }); }); + it('keeps fractional nation rewards in the same general object until the following command is persisted', async () => { + const twoCityMap = { + ...map, + cities: [ + { ...map.cities[0]!, connections: [2] }, + { ...map.cities[0]!, id: 2, name: '두번째성', position: { x: 1, y: 0 }, connections: [1] }, + ], + }; + const general = makeGeneral({ + officerLevel: 12, + stats: { leadership: 80, strength: 70, intelligence: 52 }, + role: { + personality: 'che_출세', + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + }); + const snapshot = makeSnapshot(general); + snapshot.map = twoCityMap; + snapshot.cities.push({ + ...snapshot.cities[0]!, + id: 2, + name: '두번째성', + }); + snapshot.nations[0] = { + ...snapshot.nations[0]!, + chiefGeneralId: 1, + meta: { + gennum: 1, + tech: 0, + capset: 0, + turn_last_12: { + command: '천도', + arg: { destCityID: 2 }, + term: 2, + seq: 0, + }, + }, + }; + const harness = await createTurnTestHarness({ + snapshot, + state: makeState(), + schedule, + map: twoCityMap, + commandRngFactory: () => new RandUtil(new ConstantRNG(0)), + }); + harness.reservedTurnStore.getNationTurns(1, 12)[0] = { + action: 'che_천도', + args: { destCityID: 2 }, + }; + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_상업투자', args: {} }; + + await harness.runOneTick(); + + // 천도 15 * 1.1 = 16.5, 상업 투자 6 * 0.7 * 1.1 = 4.62. + // Ref keeps 21.12 in the PHP object and rounds it once at persistence. + expect(harness.world.getGeneralById(1)?.experience).toBe(21); + expect(harness.world.getNationById(1)?.capitalCityId).toBe(2); + }); + it('applies inherited domestic stat progression after farming', async () => { const general = makeGeneral({ meta: { killturn: 24, intel_exp: 29 } }); const harness = await createTurnTestHarness({ diff --git a/app/game-engine/test/monthlyCatalogBoundaryPersistence.integration.test.ts b/app/game-engine/test/monthlyCatalogBoundaryPersistence.integration.test.ts index 66c6d180..ef97d1e3 100644 --- a/app/game-engine/test/monthlyCatalogBoundaryPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyCatalogBoundaryPersistence.integration.test.ts @@ -68,14 +68,16 @@ integration('monthly catalog boundary persistence', () => { security: 1_000, securityMax: 2_000, trust: 50, - trade: 100, + trade: null, defence: 1_000, defenceMax: 2_000, wall: 1_000, wallMax: 2_000, region: 1, conflict: {}, - meta: { state: 31, term: 1, officer_set: 1 }, + // Projected columns are deliberately stale in JSON. The + // dedicated nullable columns remain authoritative. + meta: { state: 31, term: 1, officer_set: 1, trust: 1, trade: 0, region: 99 }, }, { id: 2, @@ -248,6 +250,8 @@ integration('monthly catalog boundary persistence', () => { enableLeaseHeartbeat: false, }); try { + expect(runtime.world.getCityById(1)?.meta).toMatchObject({ trust: 50, region: 1 }); + expect(runtime.world.getCityById(1)?.meta).not.toHaveProperty('trade'); await runtime.world.advanceMonth(new Date('2026-07-25T02:00:00.000Z')); await runtime.hooks?.flushChanges?.({ lastTurnTime: runtime.world.getState().lastTurnTime.toISOString(), @@ -271,6 +275,10 @@ integration('monthly catalog boundary persistence', () => { { id: 1, age: 31, gold: 1_400, meta: expect.objectContaining({ belong: 4, makelimit: 1 }) }, { id: 2, age: 26, gold: 500, meta: expect.objectContaining({ belong: 4, makelimit: 0 }) }, ]); + const persistedCityMeta = (await db.city.findUniqueOrThrow({ where: { id: 1 } })).meta; + expect(persistedCityMeta).not.toHaveProperty('trust'); + expect(persistedCityMeta).not.toHaveProperty('trade'); + expect(persistedCityMeta).not.toHaveProperty('region'); expect( await db.city.findMany({ orderBy: { id: 'asc' }, diff --git a/app/game-engine/test/monthlyCreateManyNpcAction.test.ts b/app/game-engine/test/monthlyCreateManyNpcAction.test.ts index 83b6c054..5cd7cdb1 100644 --- a/app/game-engine/test/monthlyCreateManyNpcAction.test.ts +++ b/app/game-engine/test/monthlyCreateManyNpcAction.test.ts @@ -194,6 +194,9 @@ describe('CreateManyNPC monthly action', () => { "turnTime": "0200-05-01T00:05:45.821Z", } `); + expect(created.turnTick).toBeTypeOf('number'); + expect(created.turnTick! - world.dateToGameTick(created.turnTime)).toBeGreaterThan(0); + expect(created.turnTick! - world.dateToGameTick(created.turnTime)).toBeLessThan(60); expect(reservedTurns.getGeneralTurns(created.id)).toHaveLength(30); expect(reservedTurns.peekDirtyState()).toEqual({ generalIds: [], diff --git a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts index a3724c87..340cd915 100644 --- a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts +++ b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts @@ -210,6 +210,10 @@ describe('RaiseNPCNation monthly action', () => { const dirty = world.peekDirtyState(); expect(dirty.createdNations).toHaveLength(1); expect(dirty.createdGenerals).toHaveLength(1); + const created = dirty.createdGenerals[0]!; + expect(created.turnTick).toBeTypeOf('number'); + expect(created.turnTick! - world.dateToGameTick(created.turnTime)).toBeGreaterThan(0); + expect(created.turnTick! - world.dateToGameTick(created.turnTime)).toBeLessThan(60); expect(dirty.createdNations[0]).toMatchInlineSnapshot(` { "capitalCityId": 4, diff --git a/app/game-engine/test/monthlyRegisterNpcAction.test.ts b/app/game-engine/test/monthlyRegisterNpcAction.test.ts index 8813f864..64c575a4 100644 --- a/app/game-engine/test/monthlyRegisterNpcAction.test.ts +++ b/app/game-engine/test/monthlyRegisterNpcAction.test.ts @@ -141,6 +141,9 @@ describe('RegNPC and RegNeutralNPC monthly actions', () => { ); const created = world.peekDirtyState().createdGenerals[0]!; + expect(created.turnTick).toBeTypeOf('number'); + expect(created.turnTick! - world.dateToGameTick(created.turnTime)).toBeGreaterThan(0); + expect(created.turnTick! - world.dateToGameTick(created.turnTime)).toBeLessThan(60); expect(created).toMatchObject({ name: 'ⓝ등장장수', nationId: 1, diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index 7d48e981..47fa8f74 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -37,7 +37,7 @@ const buildGeneral = (id: number, turnTime: Date): TurnGeneral => ({ }); describe('InMemoryTurnProcessor ordering', () => { - it('executes generals by turnTime then id, not insertion order', async () => { + it('executes generals by logical turn tick then id, not projected millisecond or insertion order', async () => { const baseTime = new Date('0189-01-01T00:00:00Z'); const generals: TurnGeneral[] = [ @@ -169,17 +169,18 @@ describe('InMemoryTurnProcessor ordering', () => { const boundaryResult = await processor.run(addMinutes(baseTime, 10), budget); expect(boundaryResult.processedTurns).toBe(1); expect(executed).toEqual([]); + expect(world.listDueGenerals(addMinutes(baseTime, 10)).map((general) => general.id)).toEqual([3]); const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget); expect(tiedGeneralResult.processedTurns).toBe(0); - expect(executed).toEqual([2, 3]); + expect(executed).toEqual([3, 2]); 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); - expect(executed).toEqual([2, 3, 1, 2, 3]); + expect(executed).toEqual([3, 2, 1, 3, 2]); expect(world.getNextGeneralId()).toBe(4); expect(world.getNextGeneralId()).toBe(5); expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 }); @@ -187,6 +188,7 @@ describe('InMemoryTurnProcessor ordering', () => { const overdue = world.getGeneralById(1); expect(overdue).toBeDefined(); overdue!.turnTime = addMinutes(baseTime, 5); + overdue!.turnTick = undefined; const overdueResult = await processor.run(addMinutes(baseTime, 5), budget); expect(overdueResult.processedGenerals).toBe(1); expect(executed.at(-1)).toBe(1); diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index b82f2432..d7730d73 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -10,6 +10,7 @@ export interface TurnRunBudget { export interface TurnCheckpoint { turnTime: string; + turnTick?: number; generalId?: number; year: number; month: number; diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index 11ec1174..5b5b4e22 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -36,6 +36,7 @@ export interface ActionContextWorldState { currentMonth: number; tickSeconds: number; lastTurnTime?: Date; + lastTurnTick?: number; meta?: Record; } diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 63bea703..a603ebba 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -1,4 +1,4 @@ -import type { RandomGenerator } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, JosaUtil, type RandomGenerator } from '@sammo-ts/common'; import type { City, General, @@ -19,7 +19,6 @@ import type { import { createGeneralAddEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { buildRecruitmentGeneral } from './recruitment.js'; -import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { buildWorldSummary } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -59,6 +58,8 @@ export interface TalentScoutResolveContext< createGeneralId: () => number; turnTermMinutes: number; turnTimeBase: Date; + turnTimeBaseTick?: number; + ticksPerSecond: number; } export interface TalentScoutEnvironment { @@ -439,6 +440,12 @@ export class ActionResolver< const cityId = resolveSpawnCityId(context, context.rng, this.env); const turnSecond = randomRangeInt(context.rng, 0, context.turnTermMinutes * 60 - 1); const turnFraction = randomRangeInt(context.rng, 0, 999_999); + const turnTick = + context.turnTimeBaseTick === undefined + ? undefined + : context.turnTimeBaseTick + + turnSecond * context.ticksPerSecond + + Math.floor((turnFraction * context.ticksPerSecond) / 1_000_000); const turnTime = new Date( context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000) ); @@ -489,6 +496,7 @@ export class ActionResolver< meta, }), turnTime, + ...(turnTick === undefined ? {} : { turnTick }), bornYear: birthYear, deadYear: deathYear, affinity, @@ -602,6 +610,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ // GeneralBuilder::build() derives a new NPC turn from gameStor.turntime, // not from the scout's own reserved-turn timestamp. turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime, + turnTimeBaseTick: options.world.lastTurnTick, + ticksPerSecond: GAME_TICKS_PER_TURN / options.world.tickSeconds, }); export const commandSpec: GeneralTurnCommandSpec = { diff --git a/packages/logic/src/actions/turn/nation/che_의병모집.ts b/packages/logic/src/actions/turn/nation/che_의병모집.ts index 1f3b41a3..4228849c 100644 --- a/packages/logic/src/actions/turn/nation/che_의병모집.ts +++ b/packages/logic/src/actions/turn/nation/che_의병모집.ts @@ -1,4 +1,4 @@ -import type { RandomGenerator } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, JosaUtil, type RandomGenerator } from '@sammo-ts/common'; import type { General, GeneralMeta, @@ -25,7 +25,6 @@ import type { import { createGeneralAddEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { buildRecruitmentGeneral } from '@sammo-ts/logic/actions/turn/general/recruitment.js'; -import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { buildAverageNationGeneralCount, @@ -64,6 +63,8 @@ export interface VolunteerRecruitResolveContext< createGeneralId: () => number; turnTermSeconds: number; turnTimeBase: Date; + turnTimeBaseTick?: number; + ticksPerSecond: number; } export interface VolunteerRecruitEnvironment { @@ -398,6 +399,12 @@ export class ActionResolver< candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']); const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1); const turnFraction = randomRangeInt(context.rng, 0, 999_999); + const turnTick = + context.turnTimeBaseTick === undefined + ? undefined + : context.turnTimeBaseTick + + turnSecond * context.ticksPerSecond + + Math.floor((turnFraction * context.ticksPerSecond) / 1_000_000); const turnTime = new Date( context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000) ); @@ -444,6 +451,7 @@ export class ActionResolver< meta, }), turnTime, + ...(turnTick === undefined ? {} : { turnTick }), }; effects.push(createGeneralAddEffect(newGeneral)); } @@ -520,6 +528,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { createGeneralId: options.createGeneralId, turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)), turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime, + turnTimeBaseTick: options.world.lastTurnTick, + ticksPerSecond: GAME_TICKS_PER_TURN / options.world.tickSeconds, }; }; diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index 5b749967..115850f8 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -143,20 +143,35 @@ const canonicalizeWarTrait = (raw: string | null | undefined): string | null => }; // Scenario rows expose one legacy speciality column. GeneralBuilder tries the -// domestic catalogue first, then the war catalogue, and persists the resolved -// class code. Preserve unknown values in the domestic slot so custom scenario -// packs retain their prior data even when their module is not installed here. +// war catalogue first, then the domestic catalogue, and persists the resolved +// class code. An explicit war slot keeps the single speciality in the domestic +// slot. Preserve unknown values in the domestic slot so custom scenario packs +// retain their prior data even when their module is not installed here. const resolveScenarioTraits = ( special: string | null, explicitWar: string | null | undefined ): { specialDomestic: string | null; specialWar: string | null } => { const domestic = canonicalizeDomesticTrait(special); - const inferredWar = domestic === null ? canonicalizeWarTrait(special) : null; + const inferredWar = canonicalizeWarTrait(special); const retainedDomestic = special && special !== 'None' ? special : null; const retainedWar = explicitWar && explicitWar !== 'None' ? explicitWar : null; + const resolvedExplicitWar = canonicalizeWarTrait(explicitWar) ?? retainedWar; + + if (resolvedExplicitWar !== null) { + return { + specialDomestic: domestic ?? retainedDomestic, + specialWar: resolvedExplicitWar, + }; + } + if (inferredWar !== null) { + return { + specialDomestic: null, + specialWar: inferredWar, + }; + } return { - specialDomestic: domestic ?? (inferredWar === null ? retainedDomestic : null), - specialWar: canonicalizeWarTrait(explicitWar) ?? inferredWar ?? retainedWar, + specialDomestic: domestic ?? retainedDomestic, + specialWar: null, }; }; diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts index e803f7e8..ff9cecd4 100644 --- a/packages/logic/test/worldBootstrap.test.ts +++ b/packages/logic/test/worldBootstrap.test.ts @@ -192,17 +192,17 @@ describe('scenario bootstrap', () => { expect(result.snapshot.generals[0]?.role.specialWar).toBeNull(); expect(result.snapshot.generals[1]?.role).toMatchObject({ personality: 'che_출세', - specialDomestic: 'che_event_의술', - specialWar: null, + specialDomestic: null, + specialWar: 'che_의술', }); expect(result.seed.generals[1]).toMatchObject({ - special: 'che_event_의술', - specialWar: null, + special: null, + specialWar: 'che_의술', }); expect(result.snapshot.generals[2]?.role).toMatchObject({ personality: 'che_패권', - specialDomestic: 'che_event_돌격', - specialWar: null, + specialDomestic: null, + specialWar: 'che_돌격', }); expect(result.snapshot.generals[0]?.meta).toMatchObject({ explevel: 0, diff --git a/tools/frontend-legacy-parity/reference-main-records.mjs b/tools/frontend-legacy-parity/reference-main-records.mjs index 99f67d2f..cc61f0ad 100644 --- a/tools/frontend-legacy-parity/reference-main-records.mjs +++ b/tools/frontend-legacy-parity/reference-main-records.mjs @@ -28,8 +28,12 @@ try { const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { data: { username, password: passwordHash }, }); - if (!response.ok()) { - throw new Error(`reference login failed: HTTP ${response.status()}`); + const loginPayload = await response.json().catch(() => null); + if (!response.ok() || loginPayload?.result !== true) { + const reason = String(loginPayload?.reason ?? 'invalid JSON response') + .split(/\r?\n/u, 1)[0] + .slice(0, 300); + throw new Error(`reference login failed: HTTP ${response.status()} ${reason}`); } for (const viewport of [