From e4c71c5aef7f9c1952dd117898ec740aaef221d5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 10:13:46 +0000 Subject: [PATCH] fix: match deception command boundaries --- .../logic/src/actions/turn/nation/che_허보.ts | 11 +- .../logic/test/actions/turn/nation.test.ts | 28 ++ ...urnCommandNationMatrix.integration.test.ts | 332 +++++++++++++++++- 3 files changed, 362 insertions(+), 9 deletions(-) diff --git a/packages/logic/src/actions/turn/nation/che_허보.ts b/packages/logic/src/actions/turn/nation/che_허보.ts index 700da09..e4403b7 100644 --- a/packages/logic/src/actions/turn/nation/che_허보.ts +++ b/packages/logic/src/actions/turn/nation/che_허보.ts @@ -23,13 +23,10 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; import { z } from 'zod'; -import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destCityId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int().positive()), }); export type DeceptionArgs = z.infer; @@ -59,7 +56,7 @@ const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: numb const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: number, candidates: City[]): number => { if (candidates.length === 0) { - return destCityId; + throw new RangeError('Cannot choose a deception destination from an empty city collection.'); } let idx = legacyChoiceIndex(rng, candidates.length); let cityId = candidates[idx]?.id ?? destCityId; @@ -258,7 +255,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, const friendlyGenerals = generals.filter((general) => general.nationId === base.general.nationId); const destNationSupplyCities = worldRef .listCities() - .filter((city) => city.nationId === destCity.nationId && city.supplyState > 0); + .filter((city) => city.nationId === destCity.nationId && city.supplyState === 1); return { ...base, destCity, diff --git a/packages/logic/test/actions/turn/nation.test.ts b/packages/logic/test/actions/turn/nation.test.ts index d3b8cd7..4b0e833 100644 --- a/packages/logic/test/actions/turn/nation.test.ts +++ b/packages/logic/test/actions/turn/nation.test.ts @@ -6,6 +6,7 @@ import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn import { ActionDefinition as ChangeNationNameAction } from '../../../src/actions/turn/nation/che_국호변경.js'; import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js'; import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js'; +import { ActionDefinition as DeceptionAction } from '../../../src/actions/turn/nation/che_허보.js'; import { LogCategory, LogScope } from '../../../src/logging/types.js'; import type { MapDefinition } from '../../../src/world/types.js'; import type { TurnSchedule } from '../../../src/turn/calendar.js'; @@ -248,6 +249,33 @@ describe('Nation Actions', () => { }); }); + describe('che_허보 (Deception)', () => { + it('fails like legacy choice when the target nation has no supplied city', () => { + const nation = buildNation(1); + const destNation = buildNation(2); + const general = buildGeneral(1, 1, 1); + const target = buildGeneral(2, 2, 2, 'Target'); + const definition = new DeceptionAction([]); + + expect(() => + definition.resolve( + { + general, + nation, + destNation, + destCity: buildCity(2, 2), + destCityGenerals: [target], + friendlyGenerals: [general], + destNationSupplyCities: [], + addLog: () => {}, + rng: {} as any, + } as any, + { destCityId: 2 } + ) + ).toThrow(RangeError); + }); + }); + describe('che_천도 (Move Capital)', () => { it('changes nation capital city', () => { const nation = buildNation(1); diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 3a56111..9ddfb21 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -354,7 +354,11 @@ const buildRequest = ( }), } : {}), - ...(action === 'che_백성동원' || action === 'che_이호경식' || action === 'che_급습' || action === 'che_필사즉생' + ...(action === 'che_백성동원' || + action === 'che_이호경식' || + action === 'che_급습' || + action === 'che_필사즉생' || + action === 'che_허보' ? { nationCooldowns: [ { @@ -366,7 +370,9 @@ const buildRequest = ( ? '이호경식' : action === 'che_급습' ? '급습' - : '필사즉생', + : action === 'che_필사즉생' + ? '필사즉생' + : '허보', }, ], } @@ -3903,3 +3909,325 @@ integration('nation desperate-survival multistep, diplomacy, effects, and cooldo 120_000 ); }); + +const deceptionBoundaryCases: Array<{ + name: string; + destCityId: unknown; + fixturePatches?: FixturePatches; + outcome: 'fallback' | 'intermediate' | 'completed'; + coreResolution?: boolean; + expectedPostReqTurn?: number; + expectedStrategicCommandLimit?: number; + expectedTargetCityId?: number; + expectedRngCalls?: number; +}> = [ + { + name: 'rejects a missing destination city', + destCityId: 99, + outcome: 'fallback', + }, + { + name: 'accepts a numeric string destination city ID', + destCityId: '70', + fixturePatches: { diplomacy: { '1:2': { state: 0 } } }, + outcome: 'completed', + expectedPostReqTurn: 62, + expectedTargetCityId: 70, + expectedRngCalls: 2, + }, + { + name: 'truncates a fractional destination city ID', + destCityId: 70.9, + fixturePatches: { diplomacy: { '1:2': { state: 0 } } }, + outcome: 'completed', + expectedPostReqTurn: 62, + expectedTargetCityId: 70, + expectedRngCalls: 2, + }, + { + name: 'rejects a neutral destination city', + destCityId: 70, + fixturePatches: { cities: { 70: { nationId: 0 } } }, + outcome: 'fallback', + }, + { + name: 'rejects a destination city occupied by the source nation', + destCityId: 70, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + outcome: 'fallback', + }, + { + name: 'rejects a trade relation to the destination nation', + destCityId: 70, + fixturePatches: { diplomacy: { '1:2': { state: 3 } } }, + outcome: 'fallback', + }, + { + name: 'rejects a reverse-only war relation', + destCityId: 70, + fixturePatches: { + diplomacy: { + '1:2': { state: 3 }, + '2:1': { state: 0 }, + }, + }, + outcome: 'fallback', + }, + { + name: 'rejects a source city occupied by another nation', + destCityId: 70, + fixturePatches: { + cities: { 3: { nationId: 2 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'fallback', + }, + { + name: 'rejects an actor below chief rank', + destCityId: 70, + fixturePatches: { + generals: { 1: { officerLevel: 4, officerCityId: 0 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'fallback', + coreResolution: false, + }, + { + name: 'rejects a remaining strategic-command delay', + destCityId: 70, + fixturePatches: { + nations: { 1: { strategicCommandLimit: 1 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'fallback', + }, + { + name: 'allows an unsupplied source city on the intermediate turn', + destCityId: 70, + fixturePatches: { + cities: { 3: { supplyState: 0 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'intermediate', + }, + { + name: 'completes against a nation under declaration', + destCityId: 70, + fixturePatches: { diplomacy: { '1:2': { state: 1 } } }, + outcome: 'completed', + expectedPostReqTurn: 62, + expectedTargetCityId: 70, + expectedRngCalls: 2, + }, + { + name: 'excludes supply state two and moves the target to the only supply-one city', + destCityId: 70, + fixturePatches: { + cities: { + 70: { supplyState: 2 }, + 23: { nationId: 2, supplyState: 1 }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'completed', + expectedPostReqTurn: 62, + expectedTargetCityId: 23, + expectedRngCalls: 1, + }, + { + name: 'completes without RNG when no general is in the destination city', + destCityId: 70, + fixturePatches: { + cities: { 23: { nationId: 2, supplyState: 1 } }, + generals: { 2: { cityId: 23 } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'completed', + expectedPostReqTurn: 62, + expectedTargetCityId: 23, + expectedRngCalls: 0, + }, + { + name: 'restarts at term one after another command interrupted the stack', + destCityId: 70, + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'intermediate', + }, + { + name: 'applies the strategist command and global-delay modifiers', + destCityId: 70, + fixturePatches: { + nations: { 1: { typeCode: 'che_종횡가' } }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'completed', + expectedPostReqTurn: 46, + expectedStrategicCommandLimit: 5, + expectedTargetCityId: 70, + expectedRngCalls: 2, + }, + { + name: 'uses the actual eleven-general count for the nation cooldown', + destCityId: 70, + fixturePatches: { + nations: { 1: { generalCount: 11 } }, + generals: { + 4: { nationId: 1 }, + 5: { nationId: 1 }, + 6: { nationId: 1 }, + 7: { nationId: 1 }, + 8: { nationId: 1 }, + 9: { nationId: 1 }, + 10: { nationId: 1 }, + 11: { nationId: 1 }, + 12: { nationId: 1 }, + }, + diplomacy: { '1:2': { state: 0 } }, + }, + outcome: 'completed', + expectedPostReqTurn: 65, + expectedTargetCityId: 70, + expectedRngCalls: 2, + }, +]; + +integration('nation deception target, multistep, movement, RNG, and cooldown boundaries', () => { + it.each(deceptionBoundaryCases)( + '$name matches legacy progress, movement, cooldown, logs, RNG, and semantic delta', + async ({ + destCityId, + fixturePatches, + outcome, + coreResolution = true, + expectedPostReqTurn, + expectedStrategicCommandLimit = 9, + expectedTargetCityId, + expectedRngCalls, + }) => { + const normalizedDestCityId = + typeof destCityId === 'string' ? Math.trunc(Number(destCityId)) : Math.trunc(Number(destCityId)); + const completed = outcome === 'completed'; + const fallback = outcome === 'fallback'; + const completionNationPatch = completed + ? { + turnLastByOfficerLevel: { + 12: { command: '허보', arg: { destCityID: destCityId }, term: 1 }, + }, + coreTurnLastByOfficerLevel: { + 12: { command: '허보', arg: { destCityId: normalizedDestCityId }, term: 1 }, + }, + } + : {}; + const request = buildRequest( + 'che_허보', + { destCityID: destCityId }, + { + ...fixturePatches, + nations: { + ...fixturePatches?.nations, + 1: { + ...fixturePatches?.nations?.[1], + ...completionNationPatch, + }, + }, + } + ); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + if (coreResolution) { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_허보', + actionKey: fallback ? '휴식' : 'che_허보', + usedFallback: fallback, + ...(fallback ? {} : { completed }), + }); + } else { + expect(core.execution.outcome).toBeUndefined(); + } + + for (const snapshot of [reference, core]) { + const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); + const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); + const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1); + const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1); + const targetBefore = snapshot.before.generals.find((entry) => entry.id === 2); + const targetAfter = snapshot.after.generals.find((entry) => entry.id === 2); + + expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); + expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); + expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe( + completed ? 10 : 0 + ); + expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( + completed ? 10 : 0 + ); + expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( + completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit') + ); + expect(readNumericField(targetAfter, 'cityId')).toBe( + completed && expectedTargetCityId !== undefined + ? expectedTargetCityId + : readNumericField(targetBefore, 'cityId') + ); + + if (completed) { + const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + expect(addedLogs.map((entry) => entry.text)).toEqual( + expect.arrayContaining([expect.stringContaining('허보 발동')]) + ); + expect( + addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('허보')) + ).toBe(true); + } + } + + if (outcome === 'intermediate') { + expect(reference.execution.outcome).toMatchObject({ + lastTurn: { + command: '허보', + term: 1, + }, + }); + expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ + command: '허보', + term: 1, + }); + } + if (completed) { + const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; + expect(reference.after.world.nationCooldowns).toEqual([ + { + nationId: 1, + actionName: '허보', + nextAvailableTurn: expectedNextAvailableTurn, + }, + ]); + expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); + } + if (expectedRngCalls !== undefined) { + expect(reference.rng).toHaveLength(expectedRngCalls); + } + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +});