diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index b4172b0..f91041d 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), + generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0), + generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500), maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0), }; }; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index bbc7a0a..b3d5691 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -35,6 +35,8 @@ const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10; const DEFAULT_MAX_TECH_LEVEL = 12; const DEFAULT_BASE_GOLD = 0; const DEFAULT_BASE_RICE = 2000; +const DEFAULT_GENERAL_MINIMUM_GOLD = 0; +const DEFAULT_GENERAL_MINIMUM_RICE = 500; const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000; const normalizeCode = (value: string | null | undefined): string | null => { @@ -132,6 +134,8 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE), + generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD), + generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE), maxResourceActionAmount: resolveNumber( constValues, ['maxResourceActionAmount'], diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index ac900c8..17f02a8 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -33,6 +33,9 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한 존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를 고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를 비교한다. +자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과 +100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개, +증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다. 필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지 않아 동적 호환 판정에서 제외한다. 나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 52d3154..f7d66b6 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -214,6 +214,11 @@ Four missing-target cases cover nonexistent general IDs for gift and employment plus nonexistent city IDs for spying and movement. Both engines reject the requested command, execute rest without command RNG, and produce the same semantic state delta. +Thirteen resource argument and balance cases cover 100-unit rounding and +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. 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/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 4e12491..01c8ebc 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -51,6 +51,8 @@ export interface TurnCommandEnv { initialAllowedTechLevel?: number; baseGold: number; baseRice: number; + generalMinimumGold?: number; + generalMinimumRice?: number; maxResourceActionAmount: number; itemCatalog?: Record; generalActionModules?: Array; diff --git a/packages/logic/src/actions/turn/general/che_군량매매.ts b/packages/logic/src/actions/turn/general/che_군량매매.ts index 5bce071..957d7f2 100644 --- a/packages/logic/src/actions/turn/general/che_군량매매.ts +++ b/packages/logic/src/actions/turn/general/che_군량매매.ts @@ -16,6 +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'; export interface TradeEnvironment { exchangeFee?: number; @@ -46,8 +47,10 @@ export class ActionDefinition< if (!parsed || !Number.isFinite(parsed.amount)) { return null; } - const maxAmount = this.env.maxResourceActionAmount ?? 10_000; - const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount)); + const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount ?? 10_000); + if (amount === null) { + return null; + } return { buyRice: parsed.buyRice, amount }; } diff --git a/packages/logic/src/actions/turn/general/che_증여.ts b/packages/logic/src/actions/turn/general/che_증여.ts index 32eac34..2e3d6fe 100644 --- a/packages/logic/src/actions/turn/general/che_증여.ts +++ b/packages/logic/src/actions/turn/general/che_증여.ts @@ -1,6 +1,7 @@ import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { + denyWithReason, existsDestGeneral, friendlyDestGeneral, notBeNeutral, @@ -19,15 +20,13 @@ 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'; const ACTION_NAME = '증여'; const ACTION_KEY = 'che_증여'; 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().int().positive(), }); export type GiftArgs = z.infer; @@ -51,10 +50,13 @@ export class ActionDefinition< if (!parsed) { return null; } - const maxAmount = this.env.maxResourceActionAmount > 0 ? this.env.maxResourceActionAmount : 10000; + const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount); + if (amount === null) { + return null; + } return { ...parsed, - amount: Math.max(100, Math.min(parsed.amount, maxAmount)), + amount, }; } @@ -62,9 +64,12 @@ export class ActionDefinition< return [notBeNeutral(), occupiedCity(), suppliedCity()]; } - buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] { - const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; - const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; + buildConstraints(ctx: ConstraintContext, args: GiftArgs): Constraint[] { + if (ctx.actorId === args.destGeneralID) { + return [denyWithReason('본인입니다')]; + } + const minGold = this.env.generalMinimumGold ?? 0; + const minRice = this.env.generalMinimumRice ?? 500; return [ notBeNeutral(), @@ -83,8 +88,8 @@ export class ActionDefinition< throw new Error('증여 대상 장수가 없습니다.'); } - const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; - const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; + const minGold = this.env.generalMinimumGold ?? 0; + const minRice = this.env.generalMinimumRice ?? 500; const resKey = args.isGold ? 'gold' : 'rice'; const resName = args.isGold ? '금' : '쌀'; diff --git a/packages/logic/src/actions/turn/general/che_헌납.ts b/packages/logic/src/actions/turn/general/che_헌납.ts index 1ae132d..8f7849d 100644 --- a/packages/logic/src/actions/turn/general/che_헌납.ts +++ b/packages/logic/src/actions/turn/general/che_헌납.ts @@ -21,6 +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'; const ACTION_NAME = '헌납'; const ACTION_KEY = 'che_헌납'; @@ -96,12 +97,23 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor() { + constructor(private readonly env: TurnCommandEnv) { this.resolver = new ActionResolver(); } parseArgs(raw: unknown): DonateArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); + const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!parsed) { + return null; + } + const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount); + if (amount === null) { + return null; + } + return { + ...parsed, + amount, + }; } buildMinConstraints(_ctx: ConstraintContext, _args: DonateArgs): Constraint[] { @@ -109,10 +121,12 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] { + const minGold = this.env.generalMinimumGold ?? 0; + const minRice = this.env.generalMinimumRice ?? 500; if (args.isGold) { - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => args.amount)]; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => minGold)]; } - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => args.amount)]; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => minRice)]; } resolve(context: GeneralActionResolveContext, args: DonateArgs): GeneralActionOutcome { @@ -128,5 +142,5 @@ export const commandSpec: GeneralTurnCommandSpec = { reqArg: true, availabilityArgs: { isGold: true, amount: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/resourceAmount.ts b/packages/logic/src/actions/turn/general/resourceAmount.ts new file mode 100644 index 0000000..9742a82 --- /dev/null +++ b/packages/logic/src/actions/turn/general/resourceAmount.ts @@ -0,0 +1,14 @@ +const DEFAULT_MIN_RESOURCE_ACTION_AMOUNT = 100; +const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10_000; +const RESOURCE_ACTION_AMOUNT_UNIT = 100; + +export const normalizeResourceActionAmount = (amount: number, configuredMaxAmount: number): number | null => { + if (!Number.isFinite(amount)) { + return null; + } + + const maxAmount = configuredMaxAmount > 0 ? configuredMaxAmount : DEFAULT_MAX_RESOURCE_ACTION_AMOUNT; + const roundedAmount = Math.round(amount / RESOURCE_ACTION_AMOUNT_UNIT) * RESOURCE_ACTION_AMOUNT_UNIT; + + return Math.max(DEFAULT_MIN_RESOURCE_ACTION_AMOUNT, Math.min(roundedAmount, maxAmount)); +}; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 0db84a0..8035dce 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -348,6 +348,8 @@ const buildWorldInput = ( maxGeneral: 500, baseGold: 0, baseRice: 2_000, + generalMinimumGold: 0, + generalMinimumRice: 500, maxResourceActionAmount: 10_000, maxTechLevel: 12, maxLevel: 255, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 9e507f5..3ebcdf6 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1109,6 +1109,194 @@ integration('general command missing-target fallback matrix', () => { ); }); +const resourceAmountCases: Array<{ + name: string; + action: string; + args: Record; + expectedAmount: number; +}> = [ + { + name: 'gift rounds a half unit up', + action: 'che_증여', + args: { isGold: true, amount: 150, destGeneralID: 3 }, + expectedAmount: 200, + }, + { + name: 'gift clamps below the minimum', + action: 'che_증여', + args: { isGold: true, amount: 1, destGeneralID: 3 }, + expectedAmount: 100, + }, + { + name: 'gift clamps above the maximum', + action: 'che_증여', + args: { isGold: true, amount: 10_050, destGeneralID: 3 }, + expectedAmount: 10_000, + }, + { + name: 'donation rounds a half unit up', + action: 'che_헌납', + args: { isGold: true, amount: 150 }, + expectedAmount: 200, + }, + { + name: 'donation clamps below the minimum', + action: 'che_헌납', + args: { isGold: true, amount: 1 }, + expectedAmount: 100, + }, + { + name: 'donation clamps above the maximum', + action: 'che_헌납', + args: { isGold: true, amount: 10_050 }, + expectedAmount: 10_000, + }, + { + name: 'trade rounds a half unit up', + action: 'che_군량매매', + args: { buyRice: true, amount: 150 }, + expectedAmount: 200, + }, + { + name: 'trade clamps below the minimum', + action: 'che_군량매매', + args: { buyRice: true, amount: 1 }, + expectedAmount: 100, + }, + { + name: 'trade clamps above the maximum', + action: 'che_군량매매', + args: { buyRice: true, amount: 10_050 }, + expectedAmount: 10_000, + }, +]; + +integration('general command resource amount normalization matrix', () => { + it.each(resourceAmountCases)( + '$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 referenceActor = reference.after.generals.find((entry) => entry.id === 1); + const coreActor = core.after.generals.find((entry) => entry.id === 1); + const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record } | null | undefined; + const coreLastTurn = coreActor?.lastTurn as { arg?: Record } | null | undefined; + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount }); + expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + +integration('general command donation resource boundaries', () => { + it('donates the available resource when the normalized request exceeds the current amount', async () => { + const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_헌납', + actionKey: 'che_헌납', + usedFallback: false, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('falls back when current rice is below the legacy minimum even for a small request', async () => { + const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_헌납', + actionKey: '휴식', + usedFallback: true, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); +}); + +integration('general command gift resource and target boundaries', () => { + it('keeps the legacy minimum rice reserve while gifting the available amount', async () => { + const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_증여', + actionKey: 'che_증여', + usedFallback: false, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('rejects gifting to the actor and falls back without command RNG', async () => { + const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_증여', + actionKey: '휴식', + usedFallback: true, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); +}); + type GeneralConstraintCase = { name: string; action: string;