From 97f77ce96bd41a2f188cfd7f42e529dc647ddaf4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 14:38:53 +0000 Subject: [PATCH] fix nation personnel command parity --- .../logic/src/actions/turn/nation/che_발령.ts | 33 +-- .../actions/turn/nation/che_부대탈퇴지시.ts | 31 +-- .../logic/test/actions/turn/nation.test.ts | 25 +++ .../src/turn-differential/coreCommandTrace.ts | 5 +- ...urnCommandNationMatrix.integration.test.ts | 190 ++++++++++++++++++ 5 files changed, 252 insertions(+), 32 deletions(-) diff --git a/packages/logic/src/actions/turn/nation/che_발령.ts b/packages/logic/src/actions/turn/nation/che_발령.ts index 25195de..d347962 100644 --- a/packages/logic/src/actions/turn/nation/che_발령.ts +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -1,13 +1,6 @@ -import type { - City, - General, - GeneralMeta, - GeneralTriggerState, - TriggerValue, -} from '@sammo-ts/logic/domain/entities.js'; +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 { - denyWithReason, beChief, existsDestGeneral, friendlyDestGeneral, @@ -32,11 +25,11 @@ import { resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionConte import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; 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({ - destGeneralId: z.number(), - destCityId: z.number(), + destGeneralId: z.preprocess(normalizeLegacyIntegerArg, z.number().int()), + destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int()), }); export type AssignmentArgs = z.infer; @@ -101,6 +94,18 @@ export class ActionResolver< void _args; const destGeneral = context.destGeneral; const destCity = context.destCity; + if (destGeneral.id === context.general.id) { + return { + completed: false, + effects: [ + createLogEffect(`본인입니다 ${destGeneral.name} ${ACTION_NAME} 실패.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } const cityName = this.env.formatCityName ? this.env.formatCityName(destCity) : destCity.name; const cityJosa = JosaUtil.pick(cityName, '로'); const generalJosa = JosaUtil.pick(destGeneral.name, '을'); @@ -158,11 +163,7 @@ export class ActionDefinition< return [beChief(), notBeNeutral(), occupiedCity(), suppliedCity()]; } - buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] { - void _args; - if (ctx.destGeneralId === ctx.actorId) { - return [denyWithReason('본인입니다')]; - } + buildConstraints(_ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] { return [ beChief(), notBeNeutral(), diff --git a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts index 11d616c..dd0030c 100644 --- a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts +++ b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts @@ -1,12 +1,6 @@ import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { - denyWithReason, - beChief, - existsDestGeneral, - friendlyDestGeneral, - notBeNeutral, -} from '@sammo-ts/logic/constraints/presets.js'; +import { beChief, existsDestGeneral, friendlyDestGeneral, notBeNeutral } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionEffect, @@ -23,10 +17,7 @@ import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destGeneralId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destGeneralId: z.number().int().positive(), }); export type TroopKickArgs = z.infer; @@ -52,10 +43,7 @@ export class ActionDefinition< return [notBeNeutral(), beChief()]; } - buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] { - if (ctx.destGeneralId !== undefined && ctx.destGeneralId === ctx.actorId) { - return [denyWithReason('본인입니다')]; - } + buildConstraints(_ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] { return [notBeNeutral(), beChief(), existsDestGeneral(), friendlyDestGeneral()]; } @@ -66,6 +54,19 @@ export class ActionDefinition< const josaUn = JosaUtil.pick(destGeneralName, '은'); const effects: Array> = []; + if (destGeneral.id === general.id) { + return { + completed: false, + effects: [ + createLogEffect(`본인입니다 ${ACTION_NAME} 실패.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + if (destGeneral.troopId === 0) { context.addLog(`${destGeneralName}${josaUn} 부대원이 아닙니다.`); return { effects }; diff --git a/packages/logic/test/actions/turn/nation.test.ts b/packages/logic/test/actions/turn/nation.test.ts index 471feca..f7aaafa 100644 --- a/packages/logic/test/actions/turn/nation.test.ts +++ b/packages/logic/test/actions/turn/nation.test.ts @@ -5,6 +5,8 @@ import { ActionDefinition as DeclareWarAction } from '../../../src/actions/turn/ import { ActionDefinition as NonAggressionProposalAction } from '../../../src/actions/turn/nation/che_불가침제의.js'; import { ActionDefinition as StopWarProposalAction } from '../../../src/actions/turn/nation/che_종전제의.js'; import { ActionDefinition as NonAggressionCancelProposalAction } from '../../../src/actions/turn/nation/che_불가침파기제의.js'; +import { ActionDefinition as TroopKickAction } from '../../../src/actions/turn/nation/che_부대탈퇴지시.js'; +import { ActionDefinition as AssignmentAction } from '../../../src/actions/turn/nation/che_발령.js'; import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js'; import { ActionDefinition as ChangeNationNameAction, @@ -266,6 +268,29 @@ describe('Nation Actions', () => { }); }); + describe('personnel command argument boundaries', () => { + it('keeps troop-kick target IDs strict', () => { + const definition = new TroopKickAction(); + + expect(definition.parseArgs({ destGeneralId: 3 })).toEqual({ destGeneralId: 3 }); + expect(definition.parseArgs({ destGeneralId: '3' })).toBeNull(); + expect(definition.parseArgs({ destGeneralId: 3.9 })).toBeNull(); + }); + + it('uses PHP weak integer coercion for assignment IDs', () => { + const definition = new AssignmentAction({}); + + expect(definition.parseArgs({ destGeneralId: '3', destCityId: '70' })).toEqual({ + destGeneralId: 3, + destCityId: 70, + }); + expect(definition.parseArgs({ destGeneralId: 3.9, destCityId: 70.9 })).toEqual({ + destGeneralId: 3, + destCityId: 70, + }); + }); + }); + describe('che_필사즉생 (Last Stand)', () => { it('applies the legacy three-turn gains and global delay', () => { const nation = buildNation(1); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 324f848..fa8e5ca 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -199,8 +199,11 @@ const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandPr }; }; -const buildGeneral = (row: Record, turnTime: Date): TurnGeneral => { +const buildGeneral = (row: Record, fallbackTurnTime: Date): TurnGeneral => { const meta = asRecord(row.meta); + const rawTurnTime = row.turnTime; + const parsedTurnTime = typeof rawTurnTime === 'string' ? new Date(rawTurnTime) : fallbackTurnTime; + const turnTime = Number.isNaN(parsedTurnTime.getTime()) ? fallbackTurnTime : parsedTurnTime; const rawLastTurn = asRecord(row.lastTurn); const lastTurn = typeof rawLastTurn.command === 'string' diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index a4e0e9b..c1219d0 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -1178,6 +1178,196 @@ integration('nation diplomacy proposal boundary and message parity', () => { ); }); +interface PersonnelCommandBoundaryCase { + name: string; + action: 'che_부대탈퇴지시' | 'che_발령'; + args: Record; + fixturePatches?: FixturePatches; + completed: boolean; + expectedTroopId?: number; + expectedCityId?: number; +} + +const personnelCommandBoundaryCases: PersonnelCommandBoundaryCase[] = [ + { + name: 'troop-kick rejects a numeric-string target', + action: 'che_부대탈퇴지시', + args: { destGeneralID: '3' }, + completed: false, + }, + { + name: 'troop-kick rejects a fractional target instead of truncating it', + action: 'che_부대탈퇴지시', + args: { destGeneralID: 3.9 }, + completed: false, + }, + { + name: 'troop-kick rejects self', + action: 'che_부대탈퇴지시', + args: { destGeneralID: 1 }, + completed: false, + }, + { + name: 'troop-kick rejects a missing target', + action: 'che_부대탈퇴지시', + args: { destGeneralID: 9 }, + completed: false, + }, + { + name: 'troop-kick rejects a foreign target', + action: 'che_부대탈퇴지시', + args: { destGeneralID: 2 }, + completed: false, + }, + { + name: 'troop-kick completes without mutation for a non-member', + action: 'che_부대탈퇴지시', + args: { destGeneralID: 3 }, + completed: true, + expectedTroopId: 0, + }, + { + name: 'troop-kick completes without mutation for a troop leader', + action: 'che_부대탈퇴지시', + args: { destGeneralID: 3 }, + fixturePatches: { generals: { 3: { troopId: 3 } } }, + completed: true, + expectedTroopId: 3, + }, + { + name: 'troop-kick removes a regular troop member', + action: 'che_부대탈퇴지시', + args: { destGeneralID: 3 }, + fixturePatches: { generals: { 3: { troopId: 1 } } }, + completed: true, + expectedTroopId: 0, + }, + { + name: 'assignment accepts numeric-string IDs through PHP weak int coercion', + action: 'che_발령', + args: { destGeneralID: '3', destCityID: '70' }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: true, + expectedCityId: 70, + }, + { + name: 'assignment truncates fractional IDs through PHP weak int coercion', + action: 'che_발령', + args: { destGeneralID: 3.9, destCityID: 70.9 }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: true, + expectedCityId: 70, + }, + { + name: 'assignment rejects self', + action: 'che_발령', + args: { destGeneralID: 1, destCityID: 70 }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: false, + }, + { + name: 'assignment rejects a missing target general at execution time', + action: 'che_발령', + args: { destGeneralID: 9, destCityID: 70 }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: false, + }, + { + name: 'assignment rejects a foreign target general', + action: 'che_발령', + args: { destGeneralID: 2, destCityID: 70 }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: false, + }, + { + name: 'assignment rejects an actor city occupied by another nation', + action: 'che_발령', + args: { destGeneralID: 3, destCityID: 70 }, + fixturePatches: { cities: { 3: { nationId: 2 }, 70: { nationId: 1 } } }, + completed: false, + }, + { + name: 'assignment rejects an unsupplied actor city', + action: 'che_발령', + args: { destGeneralID: 3, destCityID: 70 }, + fixturePatches: { cities: { 3: { supplyState: 0 }, 70: { nationId: 1 } } }, + completed: false, + }, + { + name: 'assignment rejects a destination city occupied by another nation', + action: 'che_발령', + args: { destGeneralID: 3, destCityID: 70 }, + completed: false, + }, + { + name: 'assignment rejects an unsupplied destination city', + action: 'che_발령', + args: { destGeneralID: 3, destCityID: 70 }, + fixturePatches: { cities: { 70: { nationId: 1, supplyState: 0 } } }, + completed: false, + }, + { + name: 'assignment moves a friendly target and records the assignment month', + action: 'che_발령', + args: { destGeneralID: 3, destCityID: 70 }, + fixturePatches: { cities: { 70: { nationId: 1 } } }, + completed: true, + expectedCityId: 70, + }, +]; + +integration('nation personnel command boundary parity', () => { + it.each(personnelCommandBoundaryCases)( + '$name', + async ({ action, args, fixturePatches, completed, expectedTroopId, expectedCityId }) => { + const request = buildRequest(action, args, fixturePatches); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + const originalCommandFailure = args.destGeneralID === 1; + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: completed || originalCommandFailure ? action : '휴식', + usedFallback: !completed && !originalCommandFailure, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + if (!completed) { + if (originalCommandFailure) { + expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + return; + } + + const referenceTarget = reference.after.generals.find((entry) => entry.id === 3); + const coreTarget = core.after.generals.find((entry) => entry.id === 3); + if (expectedTroopId !== undefined) { + expect(referenceTarget?.troopId).toBe(expectedTroopId); + expect(coreTarget?.troopId).toBe(expectedTroopId); + } + if (expectedCityId !== undefined) { + expect(referenceTarget?.cityId).toBe(expectedCityId); + expect(coreTarget?.cityId).toBe(expectedCityId); + expect(readGeneralMeta(coreTarget).last발령).toBe(readGeneralMeta(referenceTarget).last발령); + } + expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + }, + 120_000 + ); +}); + type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed'; const volunteerRecruitBoundaryCases: Array<{