From cef6a90452e7e10d20866fea0e9b4849d54b0db4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 18:15:32 +0000 Subject: [PATCH] fix: align scenario 2400 long-run parity --- .../src/scenario/scenarioSeeder.ts | 27 ++-- .../src/turn/ai/generalAi/constraint.ts | 4 + .../ai/generalAi/general/economyActions.ts | 64 ++++----- .../src/turn/ai/generalAi/nation/rewards.ts | 54 +++++--- app/game-engine/src/turn/inMemoryWorld.ts | 20 ++- app/game-engine/src/turn/incomeHandler.ts | 7 +- .../src/turn/joinCreateGeneralService.ts | 16 +-- .../src/turn/monthlySpecialityBetrayAction.ts | 25 ++-- .../src/turn/reservedTurnHandler.ts | 15 ++- .../test/generalAiConstraintEnv.test.ts | 30 +++++ .../generalAiLegacyDecisionParity.test.ts | 63 +++++++-- .../generalTurnLegacyCompatibility.test.ts | 24 ++++ .../test/joinCreateGeneralService.test.ts | 6 +- .../test/monthlyCoreEventAction.test.ts | 6 +- .../test/monthlySemiAnnualAction.test.ts | 5 +- .../monthlySpecialityBetrayAction.test.ts | 125 ++++++++++++------ app/game-engine/test/scenarioSeeder.test.ts | 5 +- .../test/scenarioSeederClock.test.ts | 20 +++ packages/common/src/time/GameClock.ts | 6 +- packages/common/test/gameClock.test.ts | 13 ++ .../src/actions/turn/general/che_물자조달.ts | 16 ++- .../src/actions/turn/general/che_상업투자.ts | 9 +- .../src/actions/turn/general/che_인재탐색.ts | 21 +-- .../logic/src/actions/turn/general/index.ts | 2 + packages/logic/src/war/utils.ts | 6 +- packages/logic/src/world/bootstrap.ts | 4 + .../scenarios/general_commands_new.test.ts | 22 ++- packages/logic/test/warUtils.test.ts | 2 + packages/logic/test/worldBootstrap.test.ts | 9 +- tools/seed-parity/create-user-generals.mjs | 79 +++++++++++ 30 files changed, 521 insertions(+), 184 deletions(-) create mode 100644 app/game-engine/test/generalAiConstraintEnv.test.ts create mode 100644 app/game-engine/test/scenarioSeederClock.test.ts create mode 100644 tools/seed-parity/create-user-generals.mjs diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 7cad179..908878b 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -82,6 +82,15 @@ export interface ScenarioSeedResult { const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; +export const calculateInitialTurnTick = ( + clock: GameClock, + baseTick: number, + initialTurnOffsetMicros: number +): number => { + const offsetTicks = Math.floor((initialTurnOffsetMicros * clock.ticksPerSecond) / 1_000_000); + return clock.addTicks(baseTick, offsetTicks); +}; + const formatDateTime = (date: Date): string => { const pad = (value: number): string => String(value).padStart(2, '0'); return [ @@ -277,6 +286,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom initYear: startState.currentYear, initMonth: startState.currentMonth, genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))), + // Ref ResetHelper keeps the active-user expiry horizon in game_env. + // User commands refresh to this value unless they are running in AI mode. + killturn: install?.npcMode === 1 ? Math.trunc(4800 / turnTermMinutes / 3) : 4800 / turnTermMinutes, // Ref seeds game_env.develcost before the first general turn. The // monthly pre-handler recalculates the same value at each boundary. develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, @@ -539,15 +551,12 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom ) ), turnTick: BigInt( - initialClock.dateToTick( - new Date( - now.getTime() + - Math.floor( - (typeof general.meta.initialTurnOffsetMicros === 'number' - ? general.meta.initialTurnOffsetMicros - : 0) / 1_000 - ) - ) + calculateInitialTurnTick( + initialClock, + initialClockTick, + typeof general.meta.initialTurnOffsetMicros === 'number' + ? general.meta.initialTurnOffsetMicros + : 0 ) ), age: resolveGeneralAge(startState.currentYear, general.birthYear), diff --git a/app/game-engine/src/turn/ai/generalAi/constraint.ts b/app/game-engine/src/turn/ai/generalAi/constraint.ts index e1d0144..d1899d8 100644 --- a/app/game-engine/src/turn/ai/generalAi/constraint.ts +++ b/app/game-engine/src/turn/ai/generalAi/constraint.ts @@ -27,6 +27,10 @@ export const resolveConstraintEnv = ( month: world.currentMonth, startYear, relYear, + // Ref asks each concrete command for its full constraints while the AI + // is still choosing a command. Cost-bearing commands therefore need + // the current yearly game_env.develcost at this boundary as well. + develCost: env.develCost, openingPartYear: env.openingPartYear, minAvailableRecruitPop: env.minAvailableRecruitPop, ...(Number.isFinite(killturn) ? { killturn } : {}), diff --git a/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts b/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts index 601d3f6..798460e 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts @@ -1,18 +1,19 @@ -import { GeneralActionPipeline } from '@sammo-ts/logic'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; +import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js'; import type { GeneralAI } from '../core.js'; import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js'; export const do금쌀구매 = (ai: GeneralAI) => { - const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id)); + const traceEnabled = [ + ...(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []), + ...(process.env.GUI_PARITY_CORE_TRACE_GENERAL_IDS?.split(',') ?? []), + ].includes(String(ai.general.id)); const trace = (stage: string, values: Record = {}) => { if (!traceEnabled) { return; } - process.stderr.write( - `AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n` - ); + process.stderr.write(`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`); }; const city = ai.city; if (!city) { @@ -55,35 +56,26 @@ export const do금쌀구매 = (ai: GeneralAI) => { const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0); const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership); const crewAmount = fullLeadership * 100; - const rawGoldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0; - const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []); - const goldCost = Math.round( - actionPipeline.onCalcDomestic( - { - general: ai.general, - nation: ai.nation ?? undefined, - ...(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: crewType?.armType ?? 0 } - ) * (ai.generalPolicy.can('모병') ? 2 : 1) - ); + const recruitContext = { + general: ai.general, + nation: ai.nation ?? undefined, + ...(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 = crewType + ? recruitment.getCost(recruitContext, crewType.id, crewAmount, crewType).gold * + (ai.generalPolicy.can('모병') ? 2 : 1) + : 0; const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0; trace('recruit-cost', { crewTypeId: crewType?.id ?? null, @@ -145,7 +137,9 @@ export const do금쌀구매 = (ai: GeneralAI) => { ai.aiConst.maxResourceActionAmount ); if (amount >= ai.nationPolicy.minimumResourceActionAmount) { - return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매'); + const result = ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매'); + trace('sell', { amount, minimumResourceActionAmount: ai.nationPolicy.minimumResourceActionAmount, result }); + return result; } } 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 f75d0e5..e55aaa8 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -51,12 +51,7 @@ const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => { return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat)); }; -const getCrewGoldCost = ( - ai: GeneralAI, - general: TurnGeneral, - baseMultiplier: number, - finalMultiplier = 1 -): number => { +const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: number, finalMultiplier = 1): number => { const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId); const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0); // Ref evaluates costWithTech() first, including its `/ 100`, and then @@ -64,16 +59,15 @@ const getCrewGoldCost = ( // Keeping that operation order is observable at exact resource boundaries // (for example 3036 versus 3036.0000000000005). return ( - ((((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * 100 * - baseMultiplier) * + (((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * + 100 * + baseMultiplier * finalMultiplier ); }; -const sortedByResource = (generals: Record, resource: ResourceName, descending = false) => - Object.values(generals).sort((lhs, rhs) => - descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource] - ); +const sortByResource = (generals: TurnGeneral[], resource: ResourceName, descending = false) => + generals.sort((lhs, rhs) => (descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource])); const canUseGeneral = (general: TurnGeneral): boolean => readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`) > 5; @@ -88,9 +82,10 @@ export const do유저장긴급포상 = (ai: GeneralAI) => { ['gold', ai.nationPolicy.reqHumanWarUrgentGold], ['rice', ai.nationPolicy.reqHumanWarUrgentRice], ]; + const userWarGenerals = Object.values(ai.userWarGenerals); for (const [resKey, minimum] of resourceMap) { - const generals = sortedByResource(ai.userWarGenerals, resKey); + const generals = sortByResource(userWarGenerals, resKey); for (const [index, general] of generals.entries()) { if (general[resKey] >= minimum) { break; @@ -112,7 +107,10 @@ export const do유저장긴급포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]); + candidates.push([ + { destGeneralId: general.id, amount, isGold: resKey === 'gold' }, + generals.length - index, + ]); } } @@ -139,12 +137,13 @@ export const do유저장포상 = (ai: GeneralAI) => { ai.nationPolicy.reqHumanDevelRice, ], ]; + const userGenerals = Object.values(ai.userGenerals); for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) { if (nation[resKey] < nationMinimum) { continue; } - const generals = sortedByResource(ai.userGenerals, resKey); + const generals = sortByResource(userGenerals, resKey); for (const [index, general] of generals.entries()) { if (general[resKey] >= warMinimum) { break; @@ -171,7 +170,10 @@ export const do유저장포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]); + candidates.push([ + { destGeneralId: general.id, amount, isGold: resKey === 'gold' }, + generals.length - index, + ]); } } @@ -188,12 +190,13 @@ export const doNPC긴급포상 = (ai: GeneralAI) => { ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2], ]; + const npcWarGenerals = Object.values(ai.npcWarGenerals); for (const [resKey, nationMinimum, minimum] of resourceMap) { if (nation[resKey] < nationMinimum) { continue; } - const generals = sortedByResource(ai.npcWarGenerals, resKey); + const generals = sortByResource(npcWarGenerals, resKey); for (const [index, general] of generals.entries()) { if (general[resKey] >= minimum) { break; @@ -215,7 +218,10 @@ export const doNPC긴급포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]); + candidates.push([ + { destGeneralId: general.id, amount, isGold: resKey === 'gold' }, + generals.length - index, + ]); } } @@ -232,13 +238,15 @@ export const doNPC포상 = (ai: GeneralAI) => { ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], ]; + const npcWarGenerals = Object.values(ai.npcWarGenerals); + const npcCivilGenerals = Object.values(ai.npcCivilGenerals); for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) { if (nation[resKey] < nationMinimum) { continue; } - const warGenerals = sortedByResource(ai.npcWarGenerals, resKey); - const civilGenerals = sortedByResource(ai.npcCivilGenerals, resKey); + const warGenerals = sortByResource(npcWarGenerals, resKey); + const civilGenerals = sortByResource(npcCivilGenerals, resKey); const weightBase = Math.max(warGenerals.length, civilGenerals.length); for (const [index, general] of warGenerals.entries()) { if (general[resKey] >= warMinimum) { @@ -308,9 +316,11 @@ export const doNPC몰수 = (ai: GeneralAI) => { ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], ]; + const npcWarGenerals = Object.values(ai.npcWarGenerals); + const npcCivilGenerals = Object.values(ai.npcCivilGenerals); for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) { - for (const general of sortedByResource(ai.npcCivilGenerals, resKey, true)) { + for (const general of sortByResource(npcCivilGenerals, resKey, true)) { if (general[resKey] <= civilMinimum * 1.5) { break; } @@ -326,7 +336,7 @@ export const doNPC몰수 = (ai: GeneralAI) => { continue; } const takeSmallAmount = nation[resKey] >= nationMinimum; - for (const general of sortedByResource(ai.npcWarGenerals, resKey, true)) { + for (const general of sortByResource(npcWarGenerals, resKey, true)) { if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) { break; } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 85b3306..ff321cd 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -402,6 +402,7 @@ export class InMemoryTurnWorld { private readonly dirtyTroopIds = new Set(); private readonly dirtyDiplomacyKeys = new Set(); private readonly createdGeneralIds = new Set(); + private nextLegacyGeneralScanOrder = 0; private readonly createdNationIds = new Set(); private readonly createdTroopIds = new Set(); private readonly createdDiplomacyKeys = new Set(); @@ -476,7 +477,16 @@ export class InMemoryTurnWorld { const normalized = this.normalizeGeneralClock( normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime) ); - const ensured = ensureGeneralKillturn(normalized, worldKillturn); + const existingOrder = normalized.meta.legacyScanOrder; + const scanOrder = + typeof existingOrder === 'number' && Number.isFinite(existingOrder) + ? existingOrder + : this.nextLegacyGeneralScanOrder; + this.nextLegacyGeneralScanOrder = Math.max(this.nextLegacyGeneralScanOrder, scanOrder + 1); + const ensured = ensureGeneralKillturn( + { ...normalized, meta: { ...normalized.meta, legacyScanOrder: scanOrder } }, + worldKillturn + ); this.generals.set(general.id, ensured); } for (const city of snapshot.cities) { @@ -876,7 +886,13 @@ export class InMemoryTurnWorld { const normalized = this.normalizeGeneralClock( normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime) ); - const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn)); + const scanOrder = this.nextLegacyGeneralScanOrder++; + const ensured = normalizeGeneralDatabaseIntegers( + ensureGeneralKillturn( + { ...normalized, meta: { ...normalized.meta, legacyScanOrder: scanOrder } }, + worldKillturn + ) + ); this.generals.set(general.id, ensured); this.dirtyGeneralIds.add(general.id); this.createdGeneralIds.add(general.id); diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index 33205ce..92a02e5 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -8,6 +8,7 @@ import { getOutcome, getRiceIncome, getWallIncome, + readLegacyCityTrust, type CityIncomeSource, type Nation, type NationIncomeContext, @@ -44,9 +45,13 @@ const resolveOfficerCity = (meta: Record): number => { return asNumber(meta.officer_city, 0); }; +export const resolveLegacyIncomeCityTrust = (trust: number): number => readLegacyCityTrust(trust); + const resolveCityTrust = (meta: Record): number => { const trust = asNumber(meta.trust, 50); - return trust; + // Income is calculated in PHP after PDO exposes MariaDB FLOAT using a + // six-significant-digit decimal representation. + return resolveLegacyIncomeCityTrust(trust); }; const toIncomeCity = (city: ReturnType[number]): CityIncomeSource => ({ diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 82e3773..494aaa2 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -157,19 +157,11 @@ const readHiddenSeed = (worldState: WorldStateRow): string | number => { return fail('INTERNAL_SERVER_ERROR', '장수 생성 비밀 seed가 설정되지 않았습니다.'); }; -const formatLegacySeedTime = (value: Date): string => { - const pad = (part: number): string => String(part).padStart(2, '0'); - const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS); - return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad( - koreaTime.getUTCDate() - )} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`; -}; - export const buildJoinCreateGeneralSeed = ( hiddenSeed: string | number, ownerIdentity: string | number, - acceptedAt: Date -): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, formatLegacySeedTime(acceptedAt)); + acceptedTick: number +): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, acceptedTick); const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise => { await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`); @@ -611,7 +603,9 @@ export const createGeneralFromJoin = async (options: { const hiddenSeed = readHiddenSeed(worldState); const rng = new RandUtil( - new LiteHashDRBG(buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, acceptedAt)) + new LiteHashDRBG( + buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt)) + ) ); const worldMeta = asRecord(worldState.meta); const currentGenius = Math.max( diff --git a/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts b/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts index 830ed0f..bc51018 100644 --- a/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts +++ b/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts @@ -55,12 +55,8 @@ const readRuntimeNumber = (world: InMemoryTurnWorld, key: string, fallback: numb return typeof value === 'number' && Number.isFinite(value) ? value : fallback; }; -const buildSpecialityAge = ( - retirementYear: number, - age: number, - relativeYear: number, - divisor: number -): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; +const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number => + Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; const resolveSpecialityAge = ( general: TurnGeneral, @@ -124,9 +120,7 @@ const resolveTrait = (modules: readonly TraitModule[], key: string, label: strin export const createAssignGeneralSpecialityHandler = (options: { getWorld: () => InMemoryTurnWorld | null; }): MonthlyEventActionHandler => { - let modulePromise: - | Promise<{ domesticModules: TraitModule[]; warModules: TraitModule[] }> - | undefined; + let modulePromise: Promise<{ domesticModules: TraitModule[]; warModules: TraitModule[] }> | undefined; const loadModules = () => { modulePromise ??= Promise.all([ loadDomesticTraitModules([...LEGACY_DOMESTIC_SELECTION_KEYS]), @@ -143,7 +137,12 @@ export const createAssignGeneralSpecialityHandler = (options: { const { domesticModules, warModules } = await loadModules(); const rng = new RandUtil( new LiteHashDRBG( - simpleSerialize(resolveHiddenSeed(world), 'assignGeneralSpeciality', environment.year, environment.month) + simpleSerialize( + resolveHiddenSeed(world), + 'assignGeneralSpeciality', + environment.year, + environment.month + ) ) ); const defaultDomestic = normalizeCode(world.getScenarioConfig().const.defaultSpecialDomestic); @@ -152,7 +151,11 @@ export const createAssignGeneralSpecialityHandler = (options: { const scenarioStat = world.getScenarioConfig().stat; // ref SQL에 ORDER BY가 없으므로 loader가 보존한 DB scan 순서를 두 // domestic/war pass에서 그대로 재사용한다. - const generals = world.listGenerals(); + const generals = world.listGenerals().sort((left, right) => { + const leftOrder = readFiniteNumber(left.meta, ['legacyScanOrder']) ?? left.id; + const rightOrder = readFiniteNumber(right.meta, ['legacyScanOrder']) ?? right.id; + return leftOrder - rightOrder; + }); for (const general of generals) { if ( diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index ae75706..a8feea7 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -116,7 +116,7 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_전투태세', ]); -const applyLegacyGeneralProgression = ( +export const applyLegacyGeneralProgression = ( general: TurnGeneral, previousGeneral: TurnGeneral, actionKey: string, @@ -140,7 +140,13 @@ const applyLegacyGeneralProgression = ( // 등급을 강제 재계산한다. 반대로 은퇴의 rebirth()와 선양의 // multiplyVar('experience')는 수치를 줄이면서도 기존 등급을 그대로 둔다. const forceRefreshLevel = actionKey === 'che_하야'; - const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양'; + // Battle units update levels before finishBattle() rounds the legacy INT + // columns. che_출병 must retain that pre-round result just like Ref. + const preserveLevel = + actionKey === 'che_은퇴' || + actionKey === 'che_선양' || + actionKey === 'che_출병' || + actionKey === 'che_물자조달'; if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) { const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0); const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel); @@ -1048,7 +1054,10 @@ export const createReservedTurnHandler = async (options: { } const actionContext = specificContext ?? baseContext; if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) { - const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral }; + const tracedContext = actionContext as ActionContextBase & { + destCity?: City; + destGeneral?: TurnGeneral; + }; process.stdout.write( `AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n` ); diff --git a/app/game-engine/test/generalAiConstraintEnv.test.ts b/app/game-engine/test/generalAiConstraintEnv.test.ts new file mode 100644 index 0000000..0e218a5 --- /dev/null +++ b/app/game-engine/test/generalAiConstraintEnv.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import type { TurnCommandEnv } from '@sammo-ts/logic'; + +import { resolveConstraintEnv } from '../src/turn/ai/generalAi/constraint.js'; + +describe('general AI constraint environment', () => { + it('passes the current development cost into candidate validation', () => { + const env = resolveConstraintEnv( + { + id: 1, + currentYear: 182, + currentMonth: 5, + tickSeconds: 600, + lastTurnTime: new Date('2026-08-02T05:47:00.000Z'), + meta: { develcost: 24 }, + }, + { + title: 'test', + startYear: 180, + life: null, + fiction: 1, + history: [], + ignoreDefaultEvents: false, + }, + { develCost: 24, openingPartYear: 3, minAvailableRecruitPop: 30_000 } as TurnCommandEnv + ); + + expect(env).toMatchObject({ currentYear: 182, currentMonth: 5, develCost: 24 }); + }); +}); diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 7078536..43c4401 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -521,13 +521,11 @@ describe('legacy NPC AI final-decision parity', () => { 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, - } - ), + generalActionModules: singleActionModuleStack({ + eventHandlers: {}, + onCalcDomestic: (_context, turnType, varType, value) => + turnType === '징병' && varType === 'cost' ? value * 1.2 : value, + }), rng: makeRng([], [0, 0]), }); @@ -698,6 +696,17 @@ describe('legacy NPC AI final-decision parity', () => { expect(do금쌀구매(ai)).toBeNull(); }); + it('uses only the additional same-type crew when estimating the recruit gold reserve', () => { + const ai = makeAi({ + general: { gold: 500, rice: 3000, crew: 6900, crewTypeId: 1 }, + disabledPolicyActions: ['상인무시'], + }); + + // A full 7,000-person estimate would make this branch sell rice. Ref's + // recruitment calculator prices only the remaining 100 people. + 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_인재탐색'); @@ -936,13 +945,11 @@ describe('legacy NPC AI final-decision parity', () => { dipState: 4, rng, generals: [baseGeneral(), specialist], - generalActionModules: singleActionModuleStack( - { - eventHandlers: {}, - onCalcDomestic: (context, turnType, varType, value) => - context.general.id === 2 && turnType === '징집인구' && varType === 'score' ? 0 : value, - } - ), + 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 = { @@ -994,4 +1001,32 @@ describe('legacy NPC AI final-decision parity', () => { ai.npcWarGenerals = { 2: warGeneral }; expect(doNPC몰수(ai)?.action).toBe('che_몰수'); }); + + it('carries the Ref gold sort order into equal-rice NPC seizure candidates', () => { + const rng = makeRng(); + const ai = makeAi({ nation: { gold: 1_000, rice: 1_000 }, rng }); + ai.nationPolicy.reqNationGold = 10_000; + ai.nationPolicy.reqNationRice = 10_000; + ai.nationPolicy.reqNpcWarGold = 1_000; + ai.nationPolicy.reqNpcWarRice = 1_000; + const candidate = (id: number, gold: number) => ({ + ...baseGeneral(), + id, + gold, + rice: 5_000, + meta: { killturn: 100, fullLeadership: 70 }, + }); + ai.npcCivilGenerals = {}; + ai.npcWarGenerals = { + 77: candidate(77, 4_000), + 534: candidate(534, 5_000), + }; + + expect(doNPC몰수(ai)?.action).toBe('che_몰수'); + const riceCandidates = (rng.weightedPairs[0] ?? []) + .map(([args]) => args as { isGold: boolean; destGeneralId: number }) + .filter((args) => !args.isGold) + .map((args) => args.destGeneralId); + expect(riceCandidates).toEqual([534, 77]); + }); }); diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index 3ad8b70..4c8da17 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; 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'; +import { applyLegacyGeneralProgression } from '../src/turn/reservedTurnHandler.js'; const start = new Date('0200-01-01T00:00:00.000Z'); const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; @@ -152,6 +153,29 @@ const makeState = (): TurnWorldState => ({ }); describe('legacy general-turn execution contract', () => { + it('preserves the battle-computed level across legacy INT rounding', () => { + const previous = makeGeneral({ + experience: 6_700, + dedication: 5_800, + meta: { killturn: 24, explevel: 25, dedlevel: 8 }, + }); + const roundedAfterBattle = makeGeneral({ + experience: 6_760, + dedication: 5_871, + meta: { killturn: 24, explevel: 25, dedlevel: 8 }, + }); + + const resolved = applyLegacyGeneralProgression( + roundedAfterBattle, + previous, + 'che_출병', + { maxStatLevel: 255, maxDedicationLevel: 30 } as never, + [] + ); + + expect(resolved.meta).toMatchObject({ explevel: 25, dedlevel: 8 }); + }); + it('quantizes integer general columns at each in-memory DB mutation boundary', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot(makeGeneral()), diff --git a/app/game-engine/test/joinCreateGeneralService.test.ts b/app/game-engine/test/joinCreateGeneralService.test.ts index c71e887..fe5a581 100644 --- a/app/game-engine/test/joinCreateGeneralService.test.ts +++ b/app/game-engine/test/joinCreateGeneralService.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { buildJoinCreateGeneralSeed, cutJoinTurnTime } from '../src/turn/joinCreateGeneralService.js'; describe('generic join legacy time contracts', () => { - it('builds the Ref MakeGeneral seed from the Seoul whole-second timestamp', () => { - expect(buildJoinCreateGeneralSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe( - 'str(4,seed)|str(11,MakeGeneral)|int(42)|str(19,2026-07-31 08:59:58)' + it('builds the Ref MakeGeneral seed from the logical game tick', () => { + expect(buildJoinCreateGeneralSeed('seed', 42, 72_000_000)).toBe( + 'str(4,seed)|str(11,MakeGeneral)|int(42)|int(72000000)' ); }); diff --git a/app/game-engine/test/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts index 72df212..bb59336 100644 --- a/app/game-engine/test/monthlyCoreEventAction.test.ts +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type Nation } from '@sammo-ts/logic'; -import { createIncomeHandler } from '../src/turn/incomeHandler.js'; +import { createIncomeHandler, resolveLegacyIncomeCityTrust } from '../src/turn/incomeHandler.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js'; import { @@ -146,6 +146,10 @@ const buildWorld = ( }; describe('core monthly event actions at the real month boundary', () => { + it('reads income trust through the PHP six-significant-digit FLOAT representation', () => { + expect(resolveLegacyIncomeCityTrust(98.12674)).toBe(98.1267); + }); + it('preserves notice format, NewYear month log, age/belong, and officer lock reset', async () => { const world = buildWorld( [ diff --git a/app/game-engine/test/monthlySemiAnnualAction.test.ts b/app/game-engine/test/monthlySemiAnnualAction.test.ts index 1131550..ecadbd0 100644 --- a/app/game-engine/test/monthlySemiAnnualAction.test.ts +++ b/app/game-engine/test/monthlySemiAnnualAction.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { City, Nation, NationTraitModule } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { - createProcessSemiAnnualHandler, - storeLegacySemiAnnualTrust, -} 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 => ({ diff --git a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts index 04b5d4e..51a90f8 100644 --- a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts +++ b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts @@ -83,41 +83,41 @@ const buildWorld = (hiddenSeed = 'monthly-speciality-fixture') => { environment: { mapName: 'test', unitSet: 'default' }, }; const domesticGeneral = buildGeneral({ - id: 1, - name: '내정대상', - nationId: 1, - stats: [40, 45, 80], - specialDomestic: null, - specialWar: 'che_신산', - meta: { specage: 30, specage2: 99, prev_types_special: ['che_경작'] }, - }); + id: 1, + name: '내정대상', + nationId: 1, + stats: [40, 45, 80], + specialDomestic: null, + specialWar: 'che_신산', + meta: { specage: 30, specage2: 99, prev_types_special: ['che_경작'] }, + }); const warGeneral = buildGeneral({ - id: 2, - name: '전투대상', - nationId: 1, - stats: [80, 75, 40], - specialDomestic: 'che_인덕', - specialWar: null, - meta: { - specage: 99, - specage2: 30, - prev_types_special2: ['che_돌격'], - dex1: 200, - dex2: 10, - dex3: 10, - dex4: 10, - dex5: 10, - }, - }); + id: 2, + name: '전투대상', + nationId: 1, + stats: [80, 75, 40], + specialDomestic: 'che_인덕', + specialWar: null, + meta: { + specage: 99, + specage2: 30, + prev_types_special2: ['che_돌격'], + dex1: 200, + dex2: 10, + dex3: 10, + dex4: 10, + dex5: 10, + }, + }); const inheritedGeneral = buildGeneral({ - id: 3, - name: '계승대상', - nationId: 2, - stats: [50, 50, 50], - specialDomestic: 'che_경작', - specialWar: null, - meta: { specage: 99, specage2: 30, inheritSpecificSpecialWar: 'che_의술', marker: 3 }, - }); + id: 3, + name: '계승대상', + nationId: 2, + stats: [50, 50, 50], + specialDomestic: 'che_경작', + specialWar: null, + meta: { specage: 99, specage2: 30, inheritSpecificSpecialWar: 'che_의술', marker: 3 }, + }); // The isolated Aria fixture scans eligible war rows as 3, 2 because the // legacy query has no ORDER BY. Preserve that input order in this trace. const generals = [domesticGeneral, inheritedGeneral, warGeneral]; @@ -179,11 +179,7 @@ describe('monthly speciality and betrayal actions', () => { it('does nothing before the three-year opening period ends', async () => { const world = buildWorld(); - await createAssignGeneralSpecialityHandler({ getWorld: () => world })( - [], - { ...environment, year: 192 }, - event - ); + await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event); expect(world.peekDirtyState().generals).toEqual([]); expect(world.peekDirtyState().logs).toEqual([]); }); @@ -203,6 +199,59 @@ describe('monthly speciality and betrayal actions', () => { expect(world.getGeneralById(1)?.role.specialWar).not.toBeNull(); }); + it('persists creation scan order for speciality RNG across a reload', async () => { + const world = buildWorld(); + const laterId = buildGeneral({ + id: 5, + name: '먼저생성', + nationId: 0, + stats: [55, 55, 55], + specialDomestic: null, + specialWar: 'che_신산', + meta: { specage: 30, specage2: 99 }, + }); + const earlierId = buildGeneral({ + id: 4, + name: '나중생성', + nationId: 0, + stats: [55, 55, 55], + specialDomestic: null, + specialWar: 'che_신산', + meta: { specage: 30, specage2: 99 }, + }); + expect(world.addGeneral(laterId)).toBe(true); + expect(world.addGeneral(earlierId)).toBe(true); + + const persisted = world.listGenerals().sort((left, right) => left.id - right.id); + expect(persisted.find((general) => general.id === 5)?.meta.legacyScanOrder).toBeLessThan( + persisted.find((general) => general.id === 4)?.meta.legacyScanOrder as number + ); + + const reloaded = new InMemoryTurnWorld( + world.getState(), + { + scenarioConfig: world.getScenarioConfig(), + map: { id: 'test', name: 'test', cities: [] }, + generals: persisted, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); + await createAssignGeneralSpecialityHandler({ getWorld: () => reloaded })([], environment, event); + + expect( + reloaded + .peekDirtyState() + .logs.filter((log) => log.category === LogCategory.HISTORY) + .map((log) => log.generalId) + ).toEqual([1, 5, 4, 3, 2]); + }); + it('applies the two default scenario betrayal steps only to values within each threshold', async () => { const world = buildWorld(); world.updateGeneral(1, { meta: { ...world.getGeneralById(1)!.meta, betray: 0 } }); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 348c304..ec0d7f9 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -239,7 +239,10 @@ describeDb('scenario database seed', () => { expect(config.tournamentTrig).toBe(false); const meta = (worldState.meta ?? {}) as Record; - expect(meta.develcost).toBe((worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2); + expect(meta.develcost).toBe( + (worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2 + ); + expect(meta.killturn).toBe(80); const autorun = (meta.autorun_user ?? {}) as Record; const autorunOptions = (autorun.options ?? {}) as Record; expect(autorunOptions.develop).toBe(true); diff --git a/app/game-engine/test/scenarioSeederClock.test.ts b/app/game-engine/test/scenarioSeederClock.test.ts new file mode 100644 index 0000000..528cab1 --- /dev/null +++ b/app/game-engine/test/scenarioSeederClock.test.ts @@ -0,0 +1,20 @@ +import { GameClock } from '@sammo-ts/common'; +import { describe, expect, test } from 'vitest'; +import { calculateInitialTurnTick } from '../src/scenario/scenarioSeeder.js'; + +describe('scenario seeder general turn tick', () => { + test('preserves Ref-compatible sub-millisecond RNG precision', () => { + const now = new Date('2026-08-02T00:03:44.000Z'); + const clock = new GameClock({ + baseTime: new Date('2026-08-02T01:00:00.000Z'), + tick: 0, + mode: 'manual', + wallAnchor: now, + turnSeconds: 600, + }); + const baseTick = clock.dateToTick(now); + + expect(calculateInitialTurnTick(clock, baseTick, 235_265_319)).toBe(baseTick + 14_115_919); + expect(clock.dateToTick(new Date(now.getTime() + 235_265))).toBe(baseTick + 14_115_900); + }); +}); diff --git a/packages/common/src/time/GameClock.ts b/packages/common/src/time/GameClock.ts index 4c566fe..adf3c79 100644 --- a/packages/common/src/time/GameClock.ts +++ b/packages/common/src/time/GameClock.ts @@ -19,9 +19,9 @@ const requireSafeTick = (tick: number): number => { }; const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => { - const wholeSeconds = Math.trunc(tick / ticksPerSecond); + const wholeSeconds = Math.floor(tick / ticksPerSecond); const remainingTicks = tick - wholeSeconds * ticksPerSecond; - const milliseconds = wholeSeconds * 1_000 + Math.round((remainingTicks * 1_000) / ticksPerSecond); + const milliseconds = wholeSeconds * 1_000 + Math.floor((remainingTicks * 1_000) / ticksPerSecond); if (!Number.isSafeInteger(milliseconds)) { throw new Error(`Game tick offset is outside the safe millisecond range: ${milliseconds}`); } @@ -119,7 +119,7 @@ export class GameClock { const wholeSeconds = Math.trunc(milliseconds / 1_000); const remainingMilliseconds = milliseconds - wholeSeconds * 1_000; return requireSafeTick( - wholeSeconds * this.ticksPerSecond + Math.round((remainingMilliseconds * this.ticksPerSecond) / 1_000) + wholeSeconds * this.ticksPerSecond + Math.trunc((remainingMilliseconds * this.ticksPerSecond) / 1_000) ); } } diff --git a/packages/common/test/gameClock.test.ts b/packages/common/test/gameClock.test.ts index bb48757..fb6f507 100644 --- a/packages/common/test/gameClock.test.ts +++ b/packages/common/test/gameClock.test.ts @@ -61,4 +61,17 @@ describe('GameClock', () => { expect(Number.isNaN(projected.getTime())).toBe(false); expect(clock.dateToTick(projected)).toBe(tick); }); + + it('truncates sub-millisecond projections like Ref GameClock', () => { + const clock = new GameClock({ + baseTime, + tick: 0, + mode: 'manual', + wallAnchor: baseTime, + turnSeconds: 600, + }); + + expect(clock.tickToDate(14_115_919).toISOString()).toBe('2042-01-01T00:03:55.265Z'); + expect(clock.tickToDate(-1).toISOString()).toBe('2041-12-31T23:59:59.999Z'); + }); }); diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts index ee022f0..c959ec0 100644 --- a/packages/logic/src/actions/turn/general/che_물자조달.ts +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -27,6 +27,14 @@ const ACTION_KEY = 'che_물자조달'; export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta); +export const resolveLegacyExperienceLevel = (experience: number): number => + Math.max( + 0, + Math.min(255, experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10))) + ); +export const resolveLegacyDedicationLevel = (dedication: number): number => + Math.max(0, Math.min(30, Math.ceil(Math.sqrt(dedication) / 10))); + export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { @@ -107,8 +115,10 @@ export class ActionResolver< // the delta separately changes cancellation cases such as // 4554 + (45 * 0.7 / 3): the delta is 10.499999999999998, while the // accumulated binary value is exactly 4564.5 and persists as 4565. - const nextExp = roundLegacyAccumulatedInteger(general.experience, exp); - const nextDed = roundLegacyAccumulatedInteger(general.dedication, ded); + const rawNextExp = general.experience + exp; + const rawNextDed = general.dedication + ded; + const nextExp = Math.round(rawNextExp); + const nextDed = Math.round(rawNextDed); let appliedScore = score; if (context.city && [1, 3].includes(context.city.frontState)) { @@ -167,6 +177,8 @@ export class ActionResolver< dedication: nextDed, meta: { ...general.meta, + explevel: resolveLegacyExperienceLevel(rawNextExp), + dedlevel: resolveLegacyDedicationLevel(rawNextDed), [statKey]: (typeof general.meta[statKey] === 'number' ? (general.meta[statKey] as number) : 0) + 1, }, diff --git a/packages/logic/src/actions/turn/general/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts index e5d8948..1ce6426 100644 --- a/packages/logic/src/actions/turn/general/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -87,6 +87,8 @@ export interface CommerceInvestmentResult { export interface CommerceInvestmentArgs {} const DEFAULT_TRUST = 50; +export const resolveLegacyDomesticTrust = (trust: number | null | undefined, fallback = DEFAULT_TRUST): number => + Math.max(trust ?? fallback, DEFAULT_TRUST); const DEFAULT_FRONT_STATES = [1, 3]; const DEFAULT_CONFIG: InvestmentConfig = { key: 'che_상업투자', @@ -188,7 +190,7 @@ export class CommandResolver, rng: RandomGenerator): number { - const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST; + const trust = resolveLegacyDomesticTrust(getMetaNumber(context.city.meta, 'trust'), this.env.defaultTrust); const injuryMultiplier = (100 - context.general.injury) / 100; const rawStats = { @@ -220,8 +222,9 @@ export class CommandResolver, rng: RandomGenerator): CommerceInvestmentResult { const { gold: costGold, rice: costRice } = this.getCost(context); - const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST; - let score = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER); + const trust = resolveLegacyDomesticTrust(getMetaNumber(context.city.meta, 'trust'), this.env.defaultTrust); + const calculatedBaseScore = this.calcBaseScore(context, rng); + let score = clamp(calculatedBaseScore, 1, Number.MAX_SAFE_INTEGER); const ratio = this.env.getCriticalRatio?.(context, this.config.statKey) ?? diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 6714e5a..63bea70 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -105,11 +105,8 @@ export const normalizeLegacyGeneratedDex = ( ): [number, number, number, number, number] => values.map((value) => Math.trunc(value)) as [number, number, number, number, number]; -export const resolveLegacySpecialityAge = ( - retirementYear: number, - age: number, - divisor: number -): number => Math.round((retirementYear - age) / divisor) + age; +export const resolveLegacySpecialityAge = (retirementYear: number, age: number, divisor: number): number => + Math.round((retirementYear - age) / divisor) + age; const addMetaValue = ( meta: Record, @@ -450,20 +447,14 @@ export class ActionResolver< const meta: GeneralMeta = { killturn, npcType: NPC_TYPE, + explevel: 0, + dedlevel: 1, crewTypeId: this.env.defaultCrewTypeId, affinity, birthYear, deathYear, - specage: resolveLegacySpecialityAge( - context.retirementYear, - age, - 12 - ), - specage2: resolveLegacySpecialityAge( - context.retirementYear, - age, - 6 - ), + specage: resolveLegacySpecialityAge(context.retirementYear, age, 12), + specage2: resolveLegacySpecialityAge(context.retirementYear, age, 6), dex1: dex[0], dex2: dex[1], dex3: dex[2], diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 5a158af..1adbb0d 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -168,3 +168,5 @@ export const loadGeneralTurnCommandSpecs = async ( } return specs; }; + +export { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js'; diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index 32bd876..35f5ac4 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -21,7 +21,11 @@ export const round = (value: number): number => { return Math.round(value); } - const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)); + // PHP 8.3's round() uses a wider half-boundary fuzz than one JavaScript + // ulp at four-digit battle totals. Accumulating several fractional battle + // rewards can land about three ulps below .5 (for example + // 8719.4999999999945), which PHP still rounds upward. + const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)) * 4; return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5); }; diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index a0820ad..d469546 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -519,6 +519,8 @@ const buildGeneralSeeds = ( source: contextLabel, deathMonth, initialTurnOffsetMicros, + explevel: 0, + dedlevel: 1, // Ref's GeneralBuilder derives speciality ages from the scenario // opening year even when installation stores a pre-opening age. specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12), @@ -588,6 +590,8 @@ const buildGeneralSeeds = ( ), deathMonth, npcType, + explevel: 0, + dedlevel: 1, crewTypeId: defaultCrewTypeId, specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12), specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6), diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index 467c805..9caa166 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -6,6 +6,8 @@ import type { WorldSnapshot } from '../../src/world/types.js'; import { commandSpec as procureSpec, roundLegacyAccumulatedInteger, + resolveLegacyDedicationLevel, + resolveLegacyExperienceLevel, } from '../../src/actions/turn/general/che_물자조달.js'; import { commandSpec as donateSpec } from '../../src/actions/turn/general/che_헌납.js'; import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js'; @@ -37,11 +39,9 @@ import { readLegacyStoredTech, toLegacyStoredTech, } from '../../src/actions/turn/general/che_기술연구.js'; -import { - readLegacyCityTrust, - storeLegacyCityTrust, -} from '../../src/actions/turn/general/legacyCityTrust.js'; +import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js'; import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js'; +import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js'; describe('General Commands New Scenario', () => { it('truncates generated NPC dex like GeneralBuilder integer arguments', () => { @@ -55,6 +55,14 @@ describe('General Commands New Scenario', () => { expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565); }); + it('calculates procurement levels before MariaDB rounds the INT columns', () => { + expect(Math.round(5_759.5)).toBe(5_760); + expect(resolveLegacyExperienceLevel(5_759.5)).toBe(23); + expect(Math.round(6_249.5)).toBe(6_250); + expect(resolveLegacyExperienceLevel(6_249.5)).toBe(24); + expect(resolveLegacyDedicationLevel(6_400.4)).toBe(9); + }); + it('persists generated NPC speciality ages from the legacy creation date', () => { expect(resolveLegacySpecialityAge(80, 22, 12)).toBe(27); expect(resolveLegacySpecialityAge(80, 22, 6)).toBe(32); @@ -86,6 +94,12 @@ describe('General Commands New Scenario', () => { expect(roundLegacyRecruitCost(cavalryCost)).toBe(886); }); + it('uses the legacy minimum trust for domestic calculations', () => { + expect(resolveLegacyDomesticTrust(44.5321)).toBe(50); + expect(resolveLegacyDomesticTrust(80)).toBe(80); + expect(resolveLegacyDomesticTrust(null)).toBe(50); + }); + // 1. Setup Environment const systemEnv: TurnCommandEnv = { develCost: 100, diff --git a/packages/logic/test/warUtils.test.ts b/packages/logic/test/warUtils.test.ts index 4ec6298..cf14256 100644 --- a/packages/logic/test/warUtils.test.ts +++ b/packages/logic/test/warUtils.test.ts @@ -6,6 +6,8 @@ describe('legacy war rounding', () => { it('matches PHP round() at drifted positive and negative half boundaries', () => { expect(round(4159.499999999999)).toBe(4160); expect(round(-4159.499999999999)).toBe(-4160); + expect(round(8719.4999999999945)).toBe(8720); + expect(round(-8719.4999999999945)).toBe(-8720); }); it('keeps values meaningfully below a half boundary on the lower integer', () => { diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts index b774566..3641c8a 100644 --- a/packages/logic/test/worldBootstrap.test.ts +++ b/packages/logic/test/worldBootstrap.test.ts @@ -175,9 +175,16 @@ describe('scenario bootstrap', () => { specialDomestic: 'che_event_돌격', specialWar: null, }); - expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 }); + expect(result.snapshot.generals[0]?.meta).toMatchObject({ + explevel: 0, + dedlevel: 1, + specage: 25, + specage2: 30, + }); expect(result.seed.generals[0]?.meta).toMatchObject({ deathMonth: expect.any(Number), + explevel: 0, + dedlevel: 1, specage: 25, specage2: 30, }); diff --git a/tools/seed-parity/create-user-generals.mjs b/tools/seed-parity/create-user-generals.mjs new file mode 100644 index 0000000..32512f5 --- /dev/null +++ b/tools/seed-parity/create-user-generals.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +import { DatabaseTurnDaemonTransport } from '../../app/game-api/dist/index.js'; +import { + createTurnDaemonRuntime, + resolveDatabaseUrl, +} from '../../app/game-engine/dist/index.js'; +import { createGamePostgresConnector } from '../../packages/infra/dist/index.js'; + +const userCount = Number.parseInt(process.env.SEED_PARITY_USER_COUNT ?? '2', 10); +if (!Number.isInteger(userCount) || userCount < 1 || userCount > 10) { + throw new Error('SEED_PARITY_USER_COUNT must be an integer between 1 and 10'); +} + +const databaseUrl = await resolveDatabaseUrl(); +const profile = process.env.PROFILE ?? 'hwe'; +const connector = createGamePostgresConnector({ url: databaseUrl }); +await connector.connect(); + +const runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: `${profile}-seed-parity-user-setup`, + gameClockMode: 'manual', +}); +const daemon = new DatabaseTurnDaemonTransport(connector.prisma, 30_000); +// Manual mode intentionally advances scheduled turns without wall-clock waits. +// Pause before the loop starts so this setup command cannot consume month 1. +runtime.lifecycle.pause('seed parity user setup'); +const daemonLoop = runtime.lifecycle.start(); + +try { + await daemon.requestStatus(30_000); + for (let number = 1; number <= userCount; number += 1) { + const userId = `gc2400-user-${String(number).padStart(2, '0')}`; + const ownerDisplayName = `올스타${String(number).padStart(2, '0')}`; + const generalName = `비교유저${String(number).padStart(2, '0')}`; + const result = await daemon.requestCommand( + { + type: 'joinCreateGeneral', + userId, + ownerDisplayName, + seedOwnerIdentity: number + 1, + name: generalName, + leadership: 100, + strength: 105, + intel: 105, + pic: false, + character: 'che_안전', + profileId: profile, + }, + 30_000 + ); + if (result?.type !== 'joinCreateGeneral' || !result.ok) { + throw new Error(`Failed to create ${userId}: ${JSON.stringify(result)}`); + } + + // Match the one explicit legacy appointment reservation. The remaining + // slots stay as Rest so the scenario's autorun-user policy owns them. + await connector.prisma.$transaction([ + connector.prisma.generalTurn.update({ + where: { generalId_turnIdx: { generalId: result.generalId, turnIdx: 0 } }, + data: { actionCode: 'che_랜덤임관', arg: {} }, + }), + connector.prisma.generalTurnRevision.update({ + where: { generalId: result.generalId }, + data: { revision: { increment: 1 } }, + }), + ]); + console.log(`${userId} created ${generalName} as general ${result.generalId}`); + } +} finally { + await runtime.lifecycle.stop('seed parity user setup complete'); + await daemonLoop; + await runtime.close(); + await connector.disconnect(); +}