fix NPC active command execution parity
This commit is contained in:
@@ -14,8 +14,9 @@ import { z } from 'zod';
|
|||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.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 { mustBeNPC } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
|
import { existsDestCity } from '@sammo-ts/logic/constraints/city.js';
|
||||||
|
|
||||||
export type NPCSelfResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
export type NPCSelfResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||||
GeneralActionResolveContext<TriggerState> & {
|
GeneralActionResolveContext<TriggerState> & {
|
||||||
@@ -24,12 +25,31 @@ export type NPCSelfResolveContext<TriggerState extends GeneralTriggerState = Gen
|
|||||||
|
|
||||||
const ACTION_NAME = 'NPC능동';
|
const ACTION_NAME = 'NPC능동';
|
||||||
const ACTION_KEY = 'che_NPC능동';
|
const ACTION_KEY = 'che_NPC능동';
|
||||||
const ARGS_SCHEMA = z.discriminatedUnion('optionText', [
|
const ARGS_SCHEMA = z.preprocess(
|
||||||
z.object({
|
(raw) => {
|
||||||
optionText: z.literal('순간이동'),
|
if (typeof raw !== 'object' || raw === null || !('destCityId' in raw)) {
|
||||||
destCityId: z.number(),
|
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<typeof ARGS_SCHEMA>;
|
export type NPCSelfArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
@@ -40,10 +60,6 @@ export class ActionResolver<
|
|||||||
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
if (general.npcState < 2) {
|
|
||||||
throw new Error('NPC가 아닙니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.optionText === '순간이동') {
|
if (args.optionText === '순간이동') {
|
||||||
if (args.destCityId === undefined) {
|
if (args.destCityId === undefined) {
|
||||||
// Should be caught by constraints/validation, but safe guard
|
// Should be caught by constraints/validation, but safe guard
|
||||||
@@ -51,6 +67,7 @@ export class ActionResolver<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destCityId = args.destCityId;
|
const destCityId = args.destCityId;
|
||||||
|
const storedDestCityId = args.storedDestCityId ?? destCityId;
|
||||||
let destCityName = `도시(${destCityId})`;
|
let destCityName = `도시(${destCityId})`;
|
||||||
if (context.map) {
|
if (context.map) {
|
||||||
const c = context.map.cities.find((ct) => ct.id === destCityId);
|
const c = context.map.cities.find((ct) => ct.id === destCityId);
|
||||||
@@ -72,7 +89,7 @@ export class ActionResolver<
|
|||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
{
|
{
|
||||||
...general,
|
...general,
|
||||||
cityId: destCityId,
|
cityId: storedDestCityId,
|
||||||
// Legacy doesn't show cost/dedication/exp change in 'che_NPC능동.php' run() method for '순간이동'
|
// 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.
|
// It only does setVar('city', destCityID) and Logging and LastTurn.
|
||||||
},
|
},
|
||||||
@@ -107,7 +124,7 @@ export class ActionDefinition<
|
|||||||
buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
|
||||||
void _ctx;
|
void _ctx;
|
||||||
void args;
|
void args;
|
||||||
return [];
|
return [existsDestCity()];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
|||||||
@@ -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 === 'general') return world.getGeneral(req.id) !== undefined;
|
||||||
if (req.kind === 'nation') return world.getNation(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 === '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 === 'generalList') return true;
|
||||||
if (req.kind === 'nationList') return true;
|
if (req.kind === 'nationList') return true;
|
||||||
if (req.kind === 'env') 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 === 'general') return world.getGeneral(req.id) || null;
|
||||||
if (req.kind === 'nation') return world.getNation(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 === '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 === 'generalList') return world.getAllGenerals();
|
||||||
if (req.kind === 'nationList') return world.snapshot.nations;
|
if (req.kind === 'nationList') return world.snapshot.nations;
|
||||||
if (req.kind === 'env') {
|
if (req.kind === 'env') {
|
||||||
@@ -159,7 +161,7 @@ describe('che_NPC능동', () => {
|
|||||||
expect(updated?.cityId).toBe(destCityId);
|
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({
|
const bootstrapResult = buildScenarioBootstrap({
|
||||||
scenario: MOCK_SCENARIO_BASE,
|
scenario: MOCK_SCENARIO_BASE,
|
||||||
map: MINIMAL_MAP,
|
map: MINIMAL_MAP,
|
||||||
@@ -212,16 +214,47 @@ describe('che_NPC능동', () => {
|
|||||||
expect(result.kind).toBe('allow');
|
expect(result.kind).toBe('allow');
|
||||||
|
|
||||||
const runner = new TestGameRunner(world, 200, 1);
|
const runner = new TestGameRunner(world, 200, 1);
|
||||||
await expect(
|
await runner.runTurn([
|
||||||
runner.runTurn([
|
{
|
||||||
{
|
generalId: general.id,
|
||||||
generalId: general.id,
|
commandKey: 'che_NPC능동',
|
||||||
commandKey: 'che_NPC능동',
|
resolver: def,
|
||||||
resolver: def,
|
args,
|
||||||
args,
|
},
|
||||||
},
|
]);
|
||||||
])
|
|
||||||
).rejects.toThrow('NPC가 아닙니다.');
|
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 () => {
|
it('rejects structurally invalid command arguments', async () => {
|
||||||
@@ -231,7 +264,15 @@ describe('che_NPC능동', () => {
|
|||||||
|
|
||||||
expect(definition.parseArgs(null)).toBeNull();
|
expect(definition.parseArgs(null)).toBeNull();
|
||||||
expect(definition.parseArgs({ optionText: '순간이동' })).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 })).toBeNull();
|
||||||
expect(definition.parseArgs({ optionText: '순간이동', destCityId: 101 })).toEqual({
|
expect(definition.parseArgs({ optionText: '순간이동', destCityId: 101 })).toEqual({
|
||||||
optionText: '순간이동',
|
optionText: '순간이동',
|
||||||
|
|||||||
@@ -424,6 +424,69 @@ integration('general command success matrix', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface NpcActiveBoundaryCase {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
actorPatch: Record<string, unknown>;
|
||||||
|
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<string, unknown>
|
||||||
|
);
|
||||||
|
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 {
|
interface SelfStateBoundaryCase {
|
||||||
name: string;
|
name: string;
|
||||||
action: 'che_단련' | 'che_숙련전환' | 'che_사기진작' | 'che_요양';
|
action: 'che_단련' | 'che_숙련전환' | 'che_사기진작' | 'che_요양';
|
||||||
|
|||||||
Reference in New Issue
Block a user