fix(turn): match nation resource boundaries
This commit is contained in:
@@ -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.
|
available and minimum resources; and gift reserve and self-target rejection.
|
||||||
They compare normalized last-turn arguments, RNG, fallback, and semantic state
|
They compare normalized last-turn arguments, RNG, fallback, and semantic state
|
||||||
deltas.
|
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
|
Requests that omit the required argument object remain unverified because the
|
||||||
reference runner did not terminate within the bounded comparison run.
|
reference runner did not terminate within the bounded comparison run.
|
||||||
This is not yet a claim that every command-specific
|
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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
export interface TradeEnvironment {
|
export interface TradeEnvironment {
|
||||||
exchangeFee?: number;
|
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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '증여';
|
const ACTION_NAME = '증여';
|
||||||
const ACTION_KEY = 'che_증여';
|
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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '헌납';
|
const ACTION_NAME = '헌납';
|
||||||
const ACTION_KEY = 'che_헌납';
|
const ACTION_KEY = 'che_헌납';
|
||||||
|
|||||||
@@ -24,13 +24,11 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo
|
|||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
isGold: z.boolean(),
|
isGold: z.boolean(),
|
||||||
amount: z.preprocess(
|
amount: z.number(),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
destGeneralID: z.number(),
|
destGeneralID: z.number(),
|
||||||
});
|
});
|
||||||
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
@@ -56,9 +54,13 @@ export class ActionDefinition<
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const amount = normalizeResourceActionAmount(data.amount, this.env.maxResourceActionAmount);
|
||||||
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000),
|
amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,10 +144,10 @@ export class ActionResolver<
|
|||||||
createLogEffect(
|
createLogEffect(
|
||||||
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`,
|
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`,
|
||||||
{
|
{
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ export class ActionDefinition<
|
|||||||
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
|
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.destGeneralId === ctx.actorId) {
|
if (args.destGeneralId === ctx.actorId) {
|
||||||
return [denyWithReason('본인입니다')];
|
return [denyWithReason('본인입니다')];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
|||||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
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 = [
|
const ignoredLifecyclePaths = [
|
||||||
/^generalTurns/,
|
/^generalTurns/,
|
||||||
/^nationTurns/,
|
/^nationTurns/,
|
||||||
@@ -469,3 +471,159 @@ integration('nation command success matrix', () => {
|
|||||||
120_000
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user