fix command-specific failure log parity

This commit is contained in:
2026-07-26 20:59:19 +00:00
parent 7676a3eee3
commit 3d5fff3687
12 changed files with 161 additions and 10 deletions
@@ -907,7 +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;
const failedDefinition = definition;
definition = fallbackDefinition;
actionArgs = definition.parseArgs({}) ?? {};
actionKey = definition.key;
@@ -915,7 +915,10 @@ 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} ${failedCommandName} 실패.`, meta));
const failureText =
failedDefinition.formatConstraintFailure?.(reason, constraintCtx, actionArgs, view) ??
`${reason} ${failedDefinition.name} 실패.`;
logs.push(createActionLog(failureText, meta));
}
if (!usedFallback && (kind === 'general' || currentNation)) {
const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth);
+7 -1
View File
@@ -1,4 +1,4 @@
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from './engine.js';
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
@@ -16,6 +16,12 @@ export interface GeneralActionDefinition<
// 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다.
buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
buildConstraints(ctx: ConstraintContext, args: Args): Constraint[];
formatConstraintFailure?(
reason: string,
ctx: ConstraintContext,
args: Args,
view: StateView
): string | null;
// NationCommand::addTermStack()/setNextAvailable() 호환 실행 메타데이터.
getPreReqTurn?(context: Context, args: Args): number;
getPostReqTurn?(context: Context, args: Args): number;
@@ -0,0 +1,31 @@
import { JosaUtil } from '@sammo-ts/common';
import type { City, General } from '@sammo-ts/logic/domain/entities.js';
import type { StateView } from '@sammo-ts/logic/constraints/types.js';
export const formatDestCityConstraintFailure = (
reason: string,
commandName: string,
destCityId: number,
view: StateView,
relation: 'direction' | 'location'
): string | null => {
const city = view.get({ kind: 'destCity', id: destCityId }) as City | null;
if (!city) {
return null;
}
const particle = relation === 'direction' ? JosaUtil.pick(city.name, '로') : '에';
return `${reason} <G><b>${city.name}</b></>${particle} ${commandName} 실패.`;
};
export const formatDestGeneralConstraintFailure = (
reason: string,
commandName: string,
destGeneralId: number,
view: StateView
): string | null => {
const general = view.get({ kind: 'destGeneral', id: destGeneralId }) as General | null;
if (!general) {
return null;
}
return `${reason} <Y>${general.name}</> ${commandName} 실패.`;
};
@@ -1,5 +1,5 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import { notSameDestCity, nearCity, reqGeneralGold, reqGeneralRice } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
@@ -18,6 +18,7 @@ import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
export interface ForcedMoveResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -173,6 +174,15 @@ export class ActionDefinition<
];
}
formatConstraintFailure(
reason: string,
_ctx: ConstraintContext,
args: ForcedMoveArgs,
view: StateView
): string | null {
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'direction');
}
resolve(context: ForcedMoveResolveContext<TriggerState>, args: ForcedMoveArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
@@ -1,5 +1,5 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import {
notSameDestCity,
nearCity,
@@ -23,6 +23,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
export interface MoveResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -152,6 +153,15 @@ export class ActionDefinition<
];
}
formatConstraintFailure(
reason: string,
_ctx: ConstraintContext,
args: MoveArgs,
view: StateView
): string | null {
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'direction');
}
resolve(context: MoveResolveContext<TriggerState>, args: MoveArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
@@ -1,5 +1,5 @@
import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import {
notOccupiedDestCity,
reqGeneralGold,
@@ -22,6 +22,7 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac
import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
export interface SpyResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -316,6 +317,15 @@ export class ActionDefinition<
return [notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
}
formatConstraintFailure(
reason: string,
_ctx: ConstraintContext,
args: SpyArgs,
view: StateView
): string | null {
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location');
}
resolve(context: GeneralActionResolveContext<TriggerState>, args: SpyArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
@@ -36,6 +36,7 @@ import { increaseMetaNumber, simpleSerialize } from '@sammo-ts/logic/war/utils.j
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
import {
buildWarAftermathConfig,
buildWarConfig,
@@ -304,6 +305,15 @@ export class ActionDefinition<
];
}
formatConstraintFailure(
reason: string,
_ctx: ConstraintContext,
args: DispatchArgs,
view: StateView
): string | null {
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'direction');
}
resolve(context: DispatchResolveContext<TriggerState>, args: DispatchArgs): GeneralActionOutcome<TriggerState> {
void args;
const attackerCity = context.city;
@@ -1,6 +1,6 @@
import type { RandomGenerator } from '@sammo-ts/common';
import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import {
disallowDiplomacyBetweenStatus,
notBeNeutral,
@@ -35,6 +35,7 @@ import { clamp } from 'es-toolkit';
import { parseArgsWithSchema } from '../parseArgs.js';
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
export interface FireAttackEnvironment {
develCost: number;
@@ -436,6 +437,15 @@ export class ActionDefinition<
];
}
formatConstraintFailure(
reason: string,
_ctx: ConstraintContext,
args: FireAttackArgs,
view: StateView
): string | null {
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location');
}
resolve(context: FireAttackResolveContext<TriggerState>, args: FireAttackArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
@@ -1,5 +1,5 @@
import type { City, General, GeneralMeta, GeneralTriggerState, TriggerValue } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import {
beChief,
existsDestGeneral,
@@ -26,6 +26,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
import { formatDestGeneralConstraintFailure } from '../constraintFailure.js';
const ARGS_SCHEMA = z.object({
destGeneralId: z.preprocess(normalizeLegacyIntegerArg, z.number().int()),
@@ -176,6 +177,15 @@ export class ActionDefinition<
];
}
formatConstraintFailure(
reason: string,
_ctx: ConstraintContext,
args: AssignmentArgs,
view: StateView
): string | null {
return formatDestGeneralConstraintFailure(reason, this.name, args.destGeneralId, view);
}
resolve(context: AssignmentResolveContext<TriggerState>, args: AssignmentArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
+1 -1
View File
@@ -121,7 +121,7 @@ export const occupiedDestCity = (): Constraint => ({
if (destCity.nationId === baseNationId) {
return allow();
}
return { kind: 'deny', reason: '아국이 아닙니다.' };
return { kind: 'deny', reason: '대상 도시가 아국이 아닙니다.' };
},
});
@@ -3681,6 +3681,54 @@ const constraintCases: GeneralConstraintCase[] = [
},
];
const targetFailureLogCases: Array<{
action: 'che_이동' | 'che_강행' | 'che_출병' | 'che_첩보' | 'che_화계';
args: Record<string, unknown>;
fixturePatches?: FixturePatches;
}> = [
{ action: 'che_이동', args: { destCityID: 3 } },
{ action: 'che_강행', args: { destCityID: 3 } },
{
action: 'che_출병',
args: { destCityID: 3 },
fixturePatches: { world: { startYear: 180, year: 185 } },
},
{ action: 'che_첩보', args: { destCityID: 3 } },
{ action: 'che_화계', args: { destCityID: 3 } },
];
integration('general command target-specific constraint failure log parity', () => {
it.each(targetFailureLogCases)(
'$action preserves the legacy destination name and particle',
async ({ action, args, fixturePatches }) => {
const request = buildRequest(action, args, {}, fixturePatches);
request.setup!.world!.hiddenSeed = `general-target-failure-log-${action}`;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: false });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: '휴식',
usedFallback: true,
});
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
);
});
integration('general command full-constraint fallback matrix', () => {
it.each(constraintCases)(
'$name: $action falls back exactly like legacy',
@@ -1341,7 +1341,10 @@ integration('nation personnel command boundary parity', () => {
})
).toEqual([]);
if (!completed) {
if (originalCommandFailure) {
if (
originalCommandFailure ||
(action === 'che_발령' && args.destGeneralID !== 9)
) {
expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);