fix resource transfer failure log parity

This commit is contained in:
2026-07-26 20:47:07 +00:00
parent a1dacda59e
commit 7676a3eee3
4 changed files with 106 additions and 2 deletions
@@ -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);
@@ -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);
});
+1 -1
View File
@@ -583,7 +583,7 @@ export const friendlyDestGeneral = (): Constraint => ({
if (destGeneral.nationId === general.nationId) {
return allow();
}
return { kind: 'deny', reason: '아군이 아닙니다.' };
return { kind: 'deny', reason: '아국 장수가 아닙니다.' };
},
});
@@ -3497,6 +3497,108 @@ integration('general command gift resource and target boundaries', () => {
}, 120_000);
});
interface ResourceTransferBoundaryCase {
name: string;
action: 'che_증여' | 'che_헌납' | 'che_군량매매';
args: Record<string, unknown>;
actorPatch?: Record<string, unknown>;
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<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([]);
expect(semanticLogSignatures(core.after.logs)).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);
},
120_000
);
});
type GeneralConstraintCase = {
name: string;
action: string;