From a1dacda59ef45e4eccef7ff5ef1eff739248c787 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 20:34:36 +0000 Subject: [PATCH] fix core domestic command persistence parity --- .../src/actions/turn/general/che_기술연구.ts | 16 ++- .../src/actions/turn/general/che_상업투자.ts | 14 +- .../src/actions/turn/general/che_정착장려.ts | 14 +- .../src/actions/turn/general/che_주민선정.ts | 16 ++- .../src/turn-differential/canonical.ts | 1 + .../src/turn-differential/coreCommandTrace.ts | 1 + .../turn-differential/referenceSnapshot.ts | 27 +++- ...rnCommandGeneralMatrix.integration.test.ts | 128 +++++++++++++++--- 8 files changed, 173 insertions(+), 44 deletions(-) diff --git a/packages/logic/src/actions/turn/general/che_기술연구.ts b/packages/logic/src/actions/turn/general/che_기술연구.ts index fb21d19..3d707ca 100644 --- a/packages/logic/src/actions/turn/general/che_기술연구.ts +++ b/packages/logic/src/actions/turn/general/che_기술연구.ts @@ -23,6 +23,7 @@ import { CommandResolver, type DomesticActionContext, type InvestmentConfig, + updateDomesticCriticalMeta, } from './che_상업투자.js'; import { JosaUtil } from '@sammo-ts/common'; import { clamp } from 'es-toolkit'; @@ -54,7 +55,7 @@ const readTech = (nation: Nation): number => { }; // 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다. -const toLegacyStoredTech = (value: number): number => Number(value.toPrecision(6)); +const toLegacyStoredTech = (value: number): number => Number(Math.fround(value).toPrecision(6)); export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -116,11 +117,14 @@ export class ActionDefinition< context.general.experience += result.exp; context.general.dedication += result.dedication; const intelExp = typeof context.general.meta.intel_exp === 'number' ? context.general.meta.intel_exp : 0; - context.general.meta = { - ...context.general.meta, - intel_exp: intelExp + 1, - max_domestic_critical: result.pick === 'success' ? result.score : 0, - }; + context.general.meta = updateDomesticCriticalMeta( + { + ...context.general.meta, + intel_exp: intelExp + 1, + }, + result.pick, + result.score + ); const scoreText = Math.round(result.score).toLocaleString(); const josaUl = JosaUtil.pick(ACTION_NAME, '을'); diff --git a/packages/logic/src/actions/turn/general/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts index 07c4b0e..489966f 100644 --- a/packages/logic/src/actions/turn/general/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -125,6 +125,15 @@ const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMe return { ...meta, [key]: current + delta }; }; +export const updateDomesticCriticalMeta = ( + meta: GeneralMeta, + pick: DomesticCriticalPick, + score: number +): GeneralMeta => ({ + ...meta, + max_domestic_critical: pick === 'success' ? (getMetaNumber(meta, 'max_domestic_critical') ?? 0) + score / 2 : 0, +}); + export const buildDomesticContextFromView = ( ctx: ConstraintContext, view: StateView @@ -360,10 +369,7 @@ export class ActionResolver< general.dedication += result.dedication; const metaWithStatExp = addMetaNumber(general.meta, this.config.statExpKey, 1); - general.meta = - result.pick === 'success' - ? { ...metaWithStatExp, max_domestic_critical: result.score } - : { ...metaWithStatExp, max_domestic_critical: 0 }; + general.meta = updateDomesticCriticalMeta(metaWithStatExp, result.pick, result.score); const scoreText = Math.round(result.score).toLocaleString(); const josaUl = JosaUtil.pick(this.config.name, '을'); diff --git a/packages/logic/src/actions/turn/general/che_정착장려.ts b/packages/logic/src/actions/turn/general/che_정착장려.ts index da55e21..bbf8f25 100644 --- a/packages/logic/src/actions/turn/general/che_정착장려.ts +++ b/packages/logic/src/actions/turn/general/che_정착장려.ts @@ -20,6 +20,7 @@ import { CommandResolver, type DomesticActionContext, type InvestmentConfig, + updateDomesticCriticalMeta, } from './che_상업투자.js'; import { JosaUtil } from '@sammo-ts/common'; import { clamp } from 'es-toolkit'; @@ -95,11 +96,14 @@ export class ActionDefinition< context.general.dedication += result.dedication; const leadershipExp = typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0; - context.general.meta = { - ...context.general.meta, - leadership_exp: leadershipExp + 1, - max_domestic_critical: result.pick === 'success' ? result.score : 0, - }; + context.general.meta = updateDomesticCriticalMeta( + { + ...context.general.meta, + leadership_exp: leadershipExp + 1, + }, + result.pick, + result.score + ); const scoreText = populationGain.toLocaleString(); const josaUl = JosaUtil.pick(ACTION_NAME, '을'); diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts index 4a3f77a..3473edf 100644 --- a/packages/logic/src/actions/turn/general/che_주민선정.ts +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -19,6 +19,7 @@ import { CommandResolver, type DomesticActionContext, type InvestmentConfig, + updateDomesticCriticalMeta, } from './che_상업투자.js'; import { JosaUtil } from '@sammo-ts/common'; import { clamp } from 'es-toolkit'; @@ -48,7 +49,7 @@ const readTrust = (city: City): number => { // 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로 // 재조회된다. 같은 턴의 후속 명령도 그 저장 경계를 보도록 정규화한다. -const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6)); +const toLegacyStoredTrust = (value: number): number => Number(Math.fround(value).toPrecision(6)); const remainCityTrust = (): Constraint => ({ name: 'remainCityTrust', @@ -117,11 +118,14 @@ export class ActionDefinition< context.general.dedication += result.dedication; const leadershipExp = typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0; - context.general.meta = { - ...context.general.meta, - leadership_exp: leadershipExp + 1, - max_domestic_critical: result.pick === 'success' ? result.score : 0, - }; + context.general.meta = updateDomesticCriticalMeta( + { + ...context.general.meta, + leadership_exp: leadershipExp + 1, + }, + result.pick, + result.score + ); const scoreText = trustDelta.toFixed(1); const josaUl = JosaUtil.pick(ACTION_NAME, '을'); diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index 6f897c8..6cd5b03 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -127,6 +127,7 @@ export const projectCoreDatabaseSnapshot = (rows: { belong: readNumber(row, 'belong', readNumber(meta, 'belong')), permission: readString(row, 'permission') ?? readString(meta, 'permission') ?? 'normal', maxBelong: readNumber(meta, 'max_belong'), + maxDomesticCritical: readNumber(meta, 'max_domestic_critical'), betray: row.betray, personality: row.personality ?? null, specialDomestic: row.specialDomestic ?? null, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index f95aa21..ad8e8bf 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -548,6 +548,7 @@ const projectWorld = ( belong: readNumber(general.meta, 'belong'), permission: readString(general.meta, 'permission', 'normal'), maxBelong: readNumber(general.meta, 'max_belong'), + maxDomesticCritical: readNumber(general.meta, 'max_domestic_critical'), betray: readNumber(general.meta, 'betray'), personality: general.role.personality, specialDomestic: general.role.specialDomestic, diff --git a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts index 206b99a..5087bbc 100644 --- a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts @@ -4,6 +4,27 @@ import path from 'node:path'; import type { CanonicalTurnCommandTrace, CanonicalTurnSnapshot, TurnSnapshotSelector } from './canonical.js'; +const withProjectedGeneralMeta = (snapshot: CanonicalTurnSnapshot): CanonicalTurnSnapshot => ({ + ...snapshot, + generals: snapshot.generals.map((general) => { + const meta = + typeof general.meta === 'object' && general.meta !== null && !Array.isArray(general.meta) + ? (general.meta as Record) + : {}; + const value = meta.max_domestic_critical; + return { + ...general, + maxDomesticCritical: typeof value === 'number' && Number.isFinite(value) ? value : 0, + }; + }), +}); + +const withProjectedTraceMeta = (trace: CanonicalTurnCommandTrace): CanonicalTurnCommandTrace => ({ + ...trace, + before: withProjectedGeneralMeta(trace.before), + after: withProjectedGeneralMeta(trace.after), +}); + export const findTurnDifferentialWorkspaceRoot = (start: string): string | null => { let current = path.resolve(start); while (true) { @@ -45,7 +66,7 @@ export const readReferenceDatabaseSnapshot = ( stdio: ['pipe', 'pipe', 'pipe'], } ); - return JSON.parse(stdout) as CanonicalTurnSnapshot; + return withProjectedGeneralMeta(JSON.parse(stdout) as CanonicalTurnSnapshot); }; export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath: string): CanonicalTurnCommandTrace => { @@ -60,7 +81,7 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath: encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); - return JSON.parse(stdout) as CanonicalTurnCommandTrace; + return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace); }; export const runReferenceTurnCommandTraceRequest = ( @@ -79,5 +100,5 @@ export const runReferenceTurnCommandTraceRequest = ( TURN_DIFFERENTIAL_STACK_DIR: stackDirectory, }, }); - return JSON.parse(stdout) as CanonicalTurnCommandTrace; + return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace); }; diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index e458d18..4c158aa 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1281,6 +1281,112 @@ integration('general domestic command boundary, value, and log parity', () => { ); }); +interface CoreDomesticBoundaryCase { + name: string; + action: 'che_농지개간' | 'che_상업투자' | 'che_주민선정' | 'che_정착장려' | 'che_기술연구'; + actorPatch?: Record; + fixturePatches?: FixturePatches; + hiddenSeed: string; + completed: boolean; +} + +const coreDomesticBoundaryCases: CoreDomesticBoundaryCase[] = [ + { + name: 'agriculture rejects a city at its exact capacity', + action: 'che_농지개간', + fixturePatches: { cities: { 3: { agriculture: 1_000, agricultureMax: 1_000 } } }, + hiddenSeed: 'general-core-domestic-agriculture-capacity', + completed: false, + }, + { + name: 'agriculture preserves the early capital front adjustment', + action: 'che_농지개간', + fixturePatches: { + world: { year: 183 }, + cities: { 3: { frontState: 1, agriculture: 1_000, agricultureMax: 2_000 } }, + }, + hiddenSeed: 'turn-command-general-matrix-v1', + completed: true, + }, + { + name: 'commerce preserves the non-capital front debuff and integer city storage', + action: 'che_상업투자', + fixturePatches: { + nations: { 1: { capitalCityId: 70 } }, + cities: { 3: { frontState: 1, commerce: 1_000, commerceMax: 2_000 } }, + }, + hiddenSeed: 'turn-command-general-matrix-v1', + completed: true, + }, + { + name: 'resident selection preserves fractional FLOAT storage and accumulates critical score by half', + action: 'che_주민선정', + actorPatch: { meta: { max_domestic_critical: 10 } }, + fixturePatches: { + cities: { 3: { trust: 10 } }, + }, + hiddenSeed: 'turn-command-general-matrix-v1', + completed: true, + }, + { + name: 'settlement critical success uses the shared half-score accumulator', + action: 'che_정착장려', + actorPatch: { meta: { max_domestic_critical: 10 } }, + hiddenSeed: 'turn-command-general-matrix-v1', + completed: true, + }, + { + name: 'technology critical success uses the shared half-score accumulator', + action: 'che_기술연구', + actorPatch: { meta: { max_domestic_critical: 10 } }, + hiddenSeed: 'turn-command-general-matrix-v1', + completed: true, + }, + { + name: 'commerce failure resets the current domestic critical accumulator', + action: 'che_상업투자', + actorPatch: { meta: { max_domestic_critical: 10 } }, + hiddenSeed: 'general-failure-che_상업투자-2', + completed: true, + }, +]; + +integration('core domestic command critical, front, and storage boundary parity', () => { + it.each(coreDomesticBoundaryCases)( + '$name', + async ({ action, actorPatch, fixturePatches, hiddenSeed, completed }) => { + const request = buildRequest(action, undefined, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = hiddenSeed; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: completed ? action : '휴식', + usedFallback: !completed, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (completed) { + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + interface MilitaryPreparationBoundaryCase { name: string; action: 'che_모병' | 'che_징병' | 'che_장비매매' | 'che_소집해제' | 'cr_맹훈련'; @@ -2365,16 +2471,7 @@ const generalStatusTransitionBoundaryCases: GeneralStatusTransitionBoundaryCase[ integration('general resignation, retirement, and abdication boundary, state, and log parity', () => { it.each(generalStatusTransitionBoundaryCases)( '$name', - async ({ - name, - action, - args, - actorPatch, - fixturePatches, - completed, - expectedActionKey, - compareLogs, - }) => { + async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => { const request = buildRequest(action, args, actorPatch, fixturePatches); request.setup!.world!.hiddenSeed = `general-status-transition-${name}`; request.observe!.includeGlobalHistoryLogs = true; @@ -2550,16 +2647,7 @@ const generalFoundingRebellionBoundaryCases: GeneralFoundingRebellionBoundaryCas integration('general uprising, rebellion, and founding boundary, state, RNG, and log parity', () => { it.each(generalFoundingRebellionBoundaryCases)( '$name', - async ({ - name, - action, - args, - actorPatch, - fixturePatches, - completed, - expectedActionKey, - compareLogs, - }) => { + async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => { const request = buildRequest(action, args, actorPatch, fixturePatches); request.setup!.world!.hiddenSeed = `general-founding-rebellion-${name}`; if (action === 'che_거병') {