From 0f9fa69e1f851744cd4a455881facf79455c1257 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 19:49:40 +0000 Subject: [PATCH] fix NPC active command execution parity --- .../src/actions/turn/general/che_NPC능동.ts | 43 ++++++++---- .../scenarios/general/che_NPC능동.test.ts | 65 +++++++++++++++---- ...rnCommandGeneralMatrix.integration.test.ts | 63 ++++++++++++++++++ 3 files changed, 146 insertions(+), 25 deletions(-) diff --git a/packages/logic/src/actions/turn/general/che_NPC능동.ts b/packages/logic/src/actions/turn/general/che_NPC능동.ts index 7844b5b..d033a28 100644 --- a/packages/logic/src/actions/turn/general/che_NPC능동.ts +++ b/packages/logic/src/actions/turn/general/che_NPC능동.ts @@ -14,8 +14,9 @@ import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { GeneralTurnCommandSpec } from './index.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; -import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; import { mustBeNPC } from '@sammo-ts/logic/constraints/presets.js'; +import { existsDestCity } from '@sammo-ts/logic/constraints/city.js'; export type NPCSelfResolveContext = GeneralActionResolveContext & { @@ -24,12 +25,31 @@ export type NPCSelfResolveContext { + if (typeof raw !== 'object' || raw === null || !('destCityId' in raw)) { + return raw; + } + const rawDestCityId = raw.destCityId; + const destCityId = normalizeLegacyIntegerArg(rawDestCityId); + if (typeof destCityId !== 'number') { + return raw; + } + // PHP coerces the CityConst lookup to an integer, while the original value + // reaches the MariaDB integer column. Preserve that split for fractional input. + const storedDestCityId = Math.round(Number(rawDestCityId)); + return storedDestCityId === destCityId + ? { ...raw, destCityId } + : { ...raw, destCityId, storedDestCityId }; + }, + z.discriminatedUnion('optionText', [ + z.object({ + optionText: z.literal('순간이동'), + destCityId: z.number().int(), + storedDestCityId: z.number().int().optional(), + }), + ]) +); export type NPCSelfArgs = z.infer; export class ActionResolver< @@ -40,10 +60,6 @@ export class ActionResolver< resolve(context: NPCSelfResolveContext, args: NPCSelfArgs): GeneralActionOutcome { const general = context.general; const effects: GeneralActionEffect[] = []; - if (general.npcState < 2) { - throw new Error('NPC가 아닙니다.'); - } - if (args.optionText === '순간이동') { if (args.destCityId === undefined) { // Should be caught by constraints/validation, but safe guard @@ -51,6 +67,7 @@ export class ActionResolver< } const destCityId = args.destCityId; + const storedDestCityId = args.storedDestCityId ?? destCityId; let destCityName = `도시(${destCityId})`; if (context.map) { const c = context.map.cities.find((ct) => ct.id === destCityId); @@ -72,7 +89,7 @@ export class ActionResolver< createGeneralPatchEffect( { ...general, - cityId: destCityId, + cityId: storedDestCityId, // Legacy doesn't show cost/dedication/exp change in 'che_NPC능동.php' run() method for '순간이동' // It only does setVar('city', destCityID) and Logging and LastTurn. }, @@ -107,7 +124,7 @@ export class ActionDefinition< buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] { void _ctx; void args; - return []; + return [existsDestCity()]; } resolve(context: NPCSelfResolveContext, args: NPCSelfArgs): GeneralActionOutcome { diff --git a/packages/logic/test/scenarios/general/che_NPC능동.test.ts b/packages/logic/test/scenarios/general/che_NPC능동.test.ts index ce3fc3d..beec0e6 100644 --- a/packages/logic/test/scenarios/general/che_NPC능동.test.ts +++ b/packages/logic/test/scenarios/general/che_NPC능동.test.ts @@ -77,6 +77,7 @@ function createViewState(world: InMemoryWorld, year: number = 200, env: TurnComm if (req.kind === 'general') return world.getGeneral(req.id) !== undefined; if (req.kind === 'nation') return world.getNation(req.id) !== undefined; if (req.kind === 'city') return world.snapshot.cities.some((c) => c.id === req.id); + if (req.kind === 'destCity') return world.snapshot.cities.some((c) => c.id === req.id); if (req.kind === 'generalList') return true; if (req.kind === 'nationList') return true; if (req.kind === 'env') return true; @@ -86,6 +87,7 @@ function createViewState(world: InMemoryWorld, year: number = 200, env: TurnComm if (req.kind === 'general') return world.getGeneral(req.id) || null; if (req.kind === 'nation') return world.getNation(req.id) || null; if (req.kind === 'city') return world.snapshot.cities.find((c) => c.id === req.id) || null; + if (req.kind === 'destCity') return world.snapshot.cities.find((c) => c.id === req.id) || null; if (req.kind === 'generalList') return world.getAllGenerals(); if (req.kind === 'nationList') return world.snapshot.nations; if (req.kind === 'env') { @@ -159,7 +161,7 @@ describe('che_NPC능동', () => { expect(updated?.cityId).toBe(destCityId); }); - it('should reject non-NPC general at resolve stage', async () => { + it('does not reapply the reservation-only NPC permission at execution', async () => { const bootstrapResult = buildScenarioBootstrap({ scenario: MOCK_SCENARIO_BASE, map: MINIMAL_MAP, @@ -212,16 +214,47 @@ describe('che_NPC능동', () => { expect(result.kind).toBe('allow'); const runner = new TestGameRunner(world, 200, 1); - await expect( - runner.runTurn([ - { - generalId: general.id, - commandKey: 'che_NPC능동', - resolver: def, - args, - }, - ]) - ).rejects.toThrow('NPC가 아닙니다.'); + await runner.runTurn([ + { + generalId: general.id, + commandKey: 'che_NPC능동', + resolver: def, + args, + }, + ]); + + expect(world.getGeneral(general.id)?.cityId).toBe(destCityId); + }); + + it('rejects a destination that does not exist', async () => { + const bootstrapResult = buildScenarioBootstrap({ + scenario: MOCK_SCENARIO_BASE, + map: MINIMAL_MAP, + }); + const world = new InMemoryWorld(bootstrapResult.snapshot); + const general = { + ...world.snapshot.generals[0]!, + id: 1, + npcState: 2, + }; + world.snapshot.generals.push(general); + const definition = ( + await import('../../../src/actions/turn/general/che_NPC능동.js') + ).commandSpec.createDefinition({} as TurnCommandEnv); + const args = { optionText: '순간이동' as const, destCityId: 999_999 }; + + const result = evaluateActionConstraints( + definition, + createConstraintContext(general, 200, args), + createViewState(world, 200), + args + ); + + expect(result).toMatchObject({ + kind: 'deny', + constraintName: 'existsDestCity', + reason: '도시 정보가 없습니다.', + }); }); it('rejects structurally invalid command arguments', async () => { @@ -231,7 +264,15 @@ describe('che_NPC능동', () => { expect(definition.parseArgs(null)).toBeNull(); expect(definition.parseArgs({ optionText: '순간이동' })).toBeNull(); - expect(definition.parseArgs({ optionText: '순간이동', destCityId: '101' })).toBeNull(); + expect(definition.parseArgs({ optionText: '순간이동', destCityId: '101' })).toEqual({ + optionText: '순간이동', + destCityId: 101, + }); + expect(definition.parseArgs({ optionText: '순간이동', destCityId: 101.9 })).toEqual({ + optionText: '순간이동', + destCityId: 101, + storedDestCityId: 102, + }); expect(definition.parseArgs({ optionText: '알수없음', destCityId: 101 })).toBeNull(); expect(definition.parseArgs({ optionText: '순간이동', destCityId: 101 })).toEqual({ optionText: '순간이동', diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 00c8346..c2bdb29 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -424,6 +424,69 @@ integration('general command success matrix', () => { ); }); +interface NpcActiveBoundaryCase { + name: string; + args: Record; + actorPatch: Record; + completed: boolean; +} + +const npcActiveBoundaryCases: NpcActiveBoundaryCase[] = [ + { + name: 'execution does not reapply the reservation-only NPC permission', + args: { optionText: '순간이동', destCityID: 70 }, + actorPatch: { npcState: 0 }, + completed: true, + }, + { + name: 'a missing destination city is rejected before execution', + args: { optionText: '순간이동', destCityID: 999_999 }, + actorPatch: { npcState: 2 }, + completed: false, + }, + { + name: 'a numeric-string destination follows the legacy weak integer argument', + args: { optionText: '순간이동', destCityID: '70' }, + actorPatch: { npcState: 2 }, + completed: true, + }, + { + name: 'a fractional destination preserves the legacy split lookup and storage coercion', + args: { optionText: '순간이동', destCityID: 70.9 }, + actorPatch: { npcState: 2 }, + completed: true, + }, +]; + +integration('NPC active command boundary parity', () => { + it.each(npcActiveBoundaryCases)( + '$name', + async ({ name, args, actorPatch, completed }) => { + const request = buildRequest('che_NPC능동', args, actorPatch); + request.setup!.world!.hiddenSeed = `general-npc-active-${name}`; + 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: 'che_NPC능동', + actionKey: completed ? 'che_NPC능동' : '휴식', + usedFallback: !completed, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + interface SelfStateBoundaryCase { name: string; action: 'che_단련' | 'che_숙련전환' | 'che_사기진작' | 'che_요양';