From e78a0583876a62ea8f628cf26d232fae6dd01c5b Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:50:20 +0000 Subject: [PATCH] fix(turn): match nation resource boundaries --- .../turn-state-differential-testing.md | 5 + .../src/actions/turn/general/che_군량매매.ts | 2 +- .../src/actions/turn/general/che_증여.ts | 2 +- .../src/actions/turn/general/che_헌납.ts | 2 +- .../logic/src/actions/turn/nation/che_몰수.ts | 12 +- .../logic/src/actions/turn/nation/che_포상.ts | 10 +- .../turn/{general => }/resourceAmount.ts | 0 ...urnCommandNationMatrix.integration.test.ts | 158 ++++++++++++++++++ 8 files changed, 178 insertions(+), 13 deletions(-) rename packages/logic/src/actions/turn/{general => }/resourceAmount.ts (100%) diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index f7d66b6..7155947 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -219,6 +219,11 @@ minimum/maximum clamps for gift, donation, and rice trade; donation against available and minimum resources; and gift reserve and self-target rejection. They compare normalized last-turn arguments, RNG, fallback, and semantic state deltas. +Eleven nation resource cases cover 100-unit rounding and minimum/maximum +clamps for award and seizure; award against available gold, the base-rice +reserve, and self-target rejection; and seizure against target holdings and +self-target rejection. They compare exact target resource deltas, RNG, +fallback, and semantic state deltas. Requests that omit the required argument object remain unverified because the reference runner did not terminate within the bounded comparison run. This is not yet a claim that every command-specific diff --git a/packages/logic/src/actions/turn/general/che_군량매매.ts b/packages/logic/src/actions/turn/general/che_군량매매.ts index 957d7f2..da32d96 100644 --- a/packages/logic/src/actions/turn/general/che_군량매매.ts +++ b/packages/logic/src/actions/turn/general/che_군량매매.ts @@ -16,7 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { normalizeResourceActionAmount } from './resourceAmount.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; export interface TradeEnvironment { exchangeFee?: number; diff --git a/packages/logic/src/actions/turn/general/che_증여.ts b/packages/logic/src/actions/turn/general/che_증여.ts index 2e3d6fe..c820952 100644 --- a/packages/logic/src/actions/turn/general/che_증여.ts +++ b/packages/logic/src/actions/turn/general/che_증여.ts @@ -20,7 +20,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { normalizeResourceActionAmount } from './resourceAmount.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; const ACTION_NAME = '증여'; const ACTION_KEY = 'che_증여'; diff --git a/packages/logic/src/actions/turn/general/che_헌납.ts b/packages/logic/src/actions/turn/general/che_헌납.ts index 8f7849d..cdf59a0 100644 --- a/packages/logic/src/actions/turn/general/che_헌납.ts +++ b/packages/logic/src/actions/turn/general/che_헌납.ts @@ -21,7 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { normalizeResourceActionAmount } from './resourceAmount.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; const ACTION_NAME = '헌납'; const ACTION_KEY = 'che_헌납'; diff --git a/packages/logic/src/actions/turn/nation/che_몰수.ts b/packages/logic/src/actions/turn/nation/che_몰수.ts index 221c1bd..55e186e 100644 --- a/packages/logic/src/actions/turn/nation/che_몰수.ts +++ b/packages/logic/src/actions/turn/nation/che_몰수.ts @@ -24,13 +24,11 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import { clamp } from 'es-toolkit'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; const ARGS_SCHEMA = z.object({ isGold: z.boolean(), - amount: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value), - z.number().int().positive() - ), + amount: z.number(), destGeneralID: z.number(), }); export type SeizureArgs = z.infer; @@ -56,9 +54,13 @@ export class ActionDefinition< if (!data) { return null; } + const amount = normalizeResourceActionAmount(data.amount, this.env.maxResourceActionAmount); + if (amount === null) { + return null; + } return { ...data, - amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000), + amount, }; } diff --git a/packages/logic/src/actions/turn/nation/che_포상.ts b/packages/logic/src/actions/turn/nation/che_포상.ts index 6ae67e0..bbce9e7 100644 --- a/packages/logic/src/actions/turn/nation/che_포상.ts +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -144,10 +144,10 @@ export class ActionResolver< createLogEffect( `${context.destGeneral.name}에게 ${label} ${amountText}${amountJosa} 수여했습니다.`, { - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.MONTH, - } + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } ) ); @@ -199,7 +199,7 @@ export class ActionDefinition< requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId }); } - if (ctx.destGeneralId === ctx.actorId) { + if (args.destGeneralId === ctx.actorId) { return [denyWithReason('본인입니다')]; } diff --git a/packages/logic/src/actions/turn/general/resourceAmount.ts b/packages/logic/src/actions/turn/resourceAmount.ts similarity index 100% rename from packages/logic/src/actions/turn/general/resourceAmount.ts rename to packages/logic/src/actions/turn/resourceAmount.ts diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 228226e..e2566a1 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -11,6 +11,8 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); +const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0); + const ignoredLifecyclePaths = [ /^generalTurns/, /^nationTurns/, @@ -469,3 +471,159 @@ integration('nation command success matrix', () => { 120_000 ); }); + +const nationResourceAmountCases: Array<{ + name: string; + action: 'che_포상' | 'che_몰수'; + args: Record; + expectedAmount: number; +}> = [ + { + name: 'award rounds a half unit up', + action: 'che_포상', + args: { isGold: true, amount: 150, destGeneralID: 3 }, + expectedAmount: 200, + }, + { + name: 'award clamps below the minimum', + action: 'che_포상', + args: { isGold: true, amount: 1, destGeneralID: 3 }, + expectedAmount: 100, + }, + { + name: 'award clamps above the maximum', + action: 'che_포상', + args: { isGold: true, amount: 10_050, destGeneralID: 3 }, + expectedAmount: 10_000, + }, + { + name: 'seizure rounds a half unit up', + action: 'che_몰수', + args: { isGold: true, amount: 150, destGeneralID: 3 }, + expectedAmount: 200, + }, + { + name: 'seizure clamps below the minimum', + action: 'che_몰수', + args: { isGold: true, amount: 1, destGeneralID: 3 }, + expectedAmount: 100, + }, + { + name: 'seizure clamps above the maximum', + action: 'che_몰수', + args: { isGold: true, amount: 10_050, destGeneralID: 3 }, + expectedAmount: 10_000, + }, +]; + +integration('nation command resource amount normalization matrix', () => { + it.each(nationResourceAmountCases)( + '$name matches legacy rounding and clamp semantics', + async ({ action, args, expectedAmount }) => { + const request = buildRequest(action, args); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3); + const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3); + const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3); + const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3); + const referenceAmount = + action === 'che_포상' + ? readGold(referenceTargetAfter) - readGold(referenceTargetBefore) + : readGold(referenceTargetBefore) - readGold(referenceTargetAfter); + const coreAmount = + action === 'che_포상' + ? readGold(coreTargetAfter) - readGold(coreTargetBefore) + : readGold(coreTargetBefore) - readGold(coreTargetAfter); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(referenceAmount).toBe(expectedAmount); + expect(coreAmount).toBe(expectedAmount); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + +const nationResourceBoundaryCases: Array<{ + name: string; + action: 'che_포상' | 'che_몰수'; + args: Record; + fixturePatches?: FixturePatches; + completed: boolean; +}> = [ + { + name: 'award is limited to the available nation gold', + action: 'che_포상', + args: { isGold: true, amount: 10_000, destGeneralID: 3 }, + fixturePatches: { nations: { 1: { gold: 5_000 } } }, + completed: true, + }, + { + name: 'award keeps the legacy base rice reserve', + action: 'che_포상', + args: { isGold: false, amount: 10_000, destGeneralID: 3 }, + fixturePatches: { nations: { 1: { rice: 2_100 } } }, + completed: true, + }, + { + name: 'award rejects the actor as its target', + action: 'che_포상', + args: { isGold: true, amount: 100, destGeneralID: 1 }, + completed: false, + }, + { + name: 'seizure is limited to the target general gold', + action: 'che_몰수', + args: { isGold: true, amount: 1_000, destGeneralID: 3 }, + fixturePatches: { generals: { 3: { gold: 50 } } }, + completed: true, + }, + { + name: 'seizure rejects the actor as its target', + action: 'che_몰수', + args: { isGold: true, amount: 100, destGeneralID: 1 }, + completed: false, + }, +]; + +integration('nation command resource balance and target boundaries', () => { + it.each(nationResourceBoundaryCases)( + '$name matches legacy completion, RNG, and state delta', + async ({ action, args, fixturePatches, completed }) => { + const request = buildRequest(action, args, fixturePatches); + 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([]); + }, + 120_000 + ); +});