merge: match nation resource amount boundaries

This commit is contained in:
2026-07-26 05:50:31 +00:00
8 changed files with 178 additions and 13 deletions
@@ -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
@@ -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;
@@ -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_증여';
@@ -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_헌납';
@@ -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<typeof ARGS_SCHEMA>;
@@ -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,
};
}
@@ -144,10 +144,10 @@ export class ActionResolver<
createLogEffect(
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${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('본인입니다')];
}
@@ -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<string, unknown>;
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<string, unknown>
);
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<string, unknown>;
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<string, unknown>
);
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
);
});