diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 41e9c8b..07b8f07 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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); diff --git a/packages/logic/src/actions/definition.ts b/packages/logic/src/actions/definition.ts index 582bc27..caa39ed 100644 --- a/packages/logic/src/actions/definition.ts +++ b/packages/logic/src/actions/definition.ts @@ -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; diff --git a/packages/logic/src/actions/turn/constraintFailure.ts b/packages/logic/src/actions/turn/constraintFailure.ts new file mode 100644 index 0000000..fa4707f --- /dev/null +++ b/packages/logic/src/actions/turn/constraintFailure.ts @@ -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} ${city.name}${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} ${general.name} ${commandName} 실패.`; +}; diff --git a/packages/logic/src/actions/turn/general/che_강행.ts b/packages/logic/src/actions/turn/general/che_강행.ts index 623f7b5..d5a1010 100644 --- a/packages/logic/src/actions/turn/general/che_강행.ts +++ b/packages/logic/src/actions/turn/general/che_강행.ts @@ -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, args: ForcedMoveArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } diff --git a/packages/logic/src/actions/turn/general/che_이동.ts b/packages/logic/src/actions/turn/general/che_이동.ts index 5edb75d..d43b353 100644 --- a/packages/logic/src/actions/turn/general/che_이동.ts +++ b/packages/logic/src/actions/turn/general/che_이동.ts @@ -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, args: MoveArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } diff --git a/packages/logic/src/actions/turn/general/che_첩보.ts b/packages/logic/src/actions/turn/general/che_첩보.ts index 708cfcd..ccd8c7b 100644 --- a/packages/logic/src/actions/turn/general/che_첩보.ts +++ b/packages/logic/src/actions/turn/general/che_첩보.ts @@ -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, args: SpyArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index be13733..3cc4f26 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -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, args: DispatchArgs): GeneralActionOutcome { void args; const attackerCity = context.city; diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 60aa2ba..c19f112 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -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, args: FireAttackArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } diff --git a/packages/logic/src/actions/turn/nation/che_발령.ts b/packages/logic/src/actions/turn/nation/che_발령.ts index d347962..d5ef6e5 100644 --- a/packages/logic/src/actions/turn/nation/che_발령.ts +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -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, args: AssignmentArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index cfe2ec9..dc2ec73 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -121,7 +121,7 @@ export const occupiedDestCity = (): Constraint => ({ if (destCity.nationId === baseNationId) { 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 d439acd..e666456 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -3681,6 +3681,54 @@ const constraintCases: GeneralConstraintCase[] = [ }, ]; +const targetFailureLogCases: Array<{ + action: 'che_이동' | 'che_강행' | 'che_출병' | 'che_첩보' | 'che_화계'; + args: Record; + 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 + ); + 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', diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index c1219d0..2e04141 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -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)) );