diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index d4d7b1d..41e9c8b 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -907,6 +907,7 @@ export const createReservedTurnHandler = async (options: { const constraints = definition.buildConstraints(constraintCtx, actionArgs); const result = evaluateConstraints(constraints, constraintCtx, view); if (result.kind !== 'allow') { + const failedCommandName = definition.name; definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; actionKey = definition.key; @@ -914,7 +915,7 @@ export const createReservedTurnHandler = async (options: { const reason = result.kind === 'deny' ? result.reason : '조건을 확인할 수 없습니다.'; blockedReason = reason; const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined; - logs.push(createActionLog(reason, meta)); + logs.push(createActionLog(`${reason} ${failedCommandName} 실패.`, meta)); } if (!usedFallback && (kind === 'general' || currentNation)) { const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth); diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index 69c114e..62bd001 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -618,6 +618,7 @@ describe('Reserved Turn Execution Integration', () => { const dirty = world.consumeDirtyState(); expect(world.getGeneralById(1)!.cityId).toBe(1); const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.')); + expect(denyLog?.text).toContain('이동 실패.'); expect(denyLog?.meta?.constraintName).toBe('notSameDestCity'); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); }); diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index 4240861..a6302ed 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -583,7 +583,7 @@ export const friendlyDestGeneral = (): Constraint => ({ if (destGeneral.nationId === general.nationId) { return allow(); } - return { kind: 'deny', reason: '아군이 아닙니다.' }; + return { kind: 'deny', reason: '아국 장수가 아닙니다.' }; }, }); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 4c158aa..d439acd 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -3497,6 +3497,108 @@ integration('general command gift resource and target boundaries', () => { }, 120_000); }); +interface ResourceTransferBoundaryCase { + name: string; + action: 'che_증여' | 'che_헌납' | 'che_군량매매'; + args: Record; + actorPatch?: Record; + fixturePatches?: FixturePatches; + completed: boolean; +} + +const resourceTransferBoundaryCases: ResourceTransferBoundaryCase[] = [ + { + name: 'gift completes with a zero transferable gold amount at the exact minimum', + action: 'che_증여', + args: { isGold: true, amount: 100, destGeneralID: 3 }, + actorPatch: { gold: 0 }, + completed: true, + }, + { + name: 'gift rejects a general from another nation', + action: 'che_증여', + args: { isGold: true, amount: 100, destGeneralID: 2 }, + completed: false, + }, + { + name: 'donation at the exact rice minimum may spend below that minimum', + action: 'che_헌납', + args: { isGold: false, amount: 100 }, + actorPatch: { rice: 500, crew: 0 }, + completed: true, + }, + { + name: 'donation completes with zero gold at the zero minimum', + action: 'che_헌납', + args: { isGold: true, amount: 100 }, + actorPatch: { gold: 0 }, + completed: true, + }, + { + name: 'buying rice with one gold preserves the fee and integer persistence boundary', + action: 'che_군량매매', + args: { buyRice: true, amount: 100 }, + actorPatch: { gold: 1, rice: 0 }, + completed: true, + }, + { + name: 'selling one rice preserves the fee and integer persistence boundary', + action: 'che_군량매매', + args: { buyRice: false, amount: 100 }, + actorPatch: { gold: 0, rice: 1, crew: 0 }, + completed: true, + }, + { + name: 'trade rate 137 preserves resource, tax, stat experience, and logs', + action: 'che_군량매매', + args: { buyRice: false, amount: 1_000 }, + fixturePatches: { cities: { 3: { trade: 137 } } }, + completed: true, + }, + { + name: 'a neutral general can trade in a neutral trader city without nation tax', + action: 'che_군량매매', + args: { buyRice: false, amount: 1_000 }, + actorPatch: { nationId: 0, officerLevel: 0 }, + fixturePatches: { cities: { 3: { nationId: 0 } } }, + completed: true, + }, +]; + +integration('general resource transfer target, fee, state, and log parity', () => { + it.each(resourceTransferBoundaryCases)( + '$name', + async ({ action, args, actorPatch, fixturePatches, completed }) => { + const request = buildRequest(action, args, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = `general-resource-transfer-${action}-${String(args.isGold ?? args.buyRice)}`; + 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([]); + + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + }, + 120_000 + ); +}); + type GeneralConstraintCase = { name: string; action: string;