From cabb479cf6a811d47ac26a507bfe20209d9d0b45 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 16:02:24 +0000 Subject: [PATCH] fix general military preparation parity --- .../src/actions/turn/general/che_장비매매.ts | 20 +- .../src/actions/turn/general/che_징병.ts | 29 +- ...rnCommandGeneralMatrix.integration.test.ts | 279 ++++++++++++++++++ 3 files changed, 303 insertions(+), 25 deletions(-) diff --git a/packages/logic/src/actions/turn/general/che_장비매매.ts b/packages/logic/src/actions/turn/general/che_장비매매.ts index 1580b2e..de72276 100644 --- a/packages/logic/src/actions/turn/general/che_장비매매.ts +++ b/packages/logic/src/actions/turn/general/che_장비매매.ts @@ -18,12 +18,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js' import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { - cloneItemInventory, - ensureItemInventory, - equipNewItem, - removeEquippedItem, -} from '@sammo-ts/logic/items/inventory.js'; +import { equipNewItem, removeEquippedItem } from '@sammo-ts/logic/items/inventory.js'; const ACTION_NAME = '장비매매'; const ACTION_KEY = 'che_장비매매'; @@ -167,17 +162,12 @@ export class ActionDefinition< const josaUl = JosaUtil.pick(itemRawName, '을'); const nextGold = buying ? Math.max(0, general.gold - itemCost) : general.gold + Math.floor(itemCost / 2); - const nextGeneral = { - ...general, - role: { ...general.role, items: { ...general.role.items } }, - itemInventory: cloneItemInventory(ensureItemInventory(general)), - }; if (buying) { - equipNewItem(nextGeneral, itemType, finalItemCode, { + equipNewItem(general, itemType, finalItemCode, { ...(item?.initialCharges === undefined ? {} : { charges: item.initialCharges }), }); } else { - removeEquippedItem(nextGeneral, itemType); + removeEquippedItem(general, itemType); } if (buying) { @@ -198,7 +188,7 @@ export class ActionDefinition< const josaYi = JosaUtil.pick(general.name, '이'); context.addLog(`${general.name}${josaYi} ${itemName}${josaUl} 판매했습니다!`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, }); context.addLog( `【판매】${nation.name}${general.name}${josaYi} ${itemName}${josaUl} 판매했습니다!`, @@ -216,8 +206,6 @@ export class ActionDefinition< createGeneralPatchEffect({ gold: nextGold, experience: general.experience + 10, - role: nextGeneral.role, - itemInventory: nextGeneral.itemInventory, }), ], }; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 5f1a025..901fe2c 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -68,14 +68,21 @@ export const ARGS_SCHEMA = z.preprocess( return { ...record, crewType }; }, z.object({ - crewType: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), - amount: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().min(0) - ), + crewType: z.number().int().positive(), + amount: z.preprocess((value) => { + if (typeof value === 'number') { + return Number.isFinite(value) ? Math.trunc(value) : value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!/^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?$/.test(trimmed)) { + return value; + } + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? Math.trunc(numeric) : value; + } + return value; + }, z.number().int().min(0)), }) ); export type RecruitArgs = z.infer; @@ -107,6 +114,10 @@ const readCityTrust = (city: City, fallback: number): number => { return typeof trust === 'number' ? trust : fallback; }; +// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로 +// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다. +const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6)); + const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => { const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0; return { ...meta, [key]: current + delta }; @@ -365,7 +376,7 @@ export class ActionResolver< const nextPopulation = Math.max(city.population - recruitPop, 0); const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST); const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0; - const nextTrust = Math.max(baseTrust - trustLoss, 0); + const nextTrust = toLegacyStoredTrust(Math.max(baseTrust - trustLoss, 0)); const actionName = this.env.actionName ?? ACTION_NAME; const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] = diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 752b478..214e0f1 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1104,6 +1104,285 @@ integration('general domestic command boundary, value, and log parity', () => { ); }); +interface MilitaryPreparationBoundaryCase { + name: string; + action: 'che_모병' | 'che_징병' | 'che_장비매매' | 'che_소집해제' | 'cr_맹훈련'; + args?: Record; + actorPatch?: Record; + fixturePatches?: FixturePatches; + completed: boolean; +} + +const militaryPreparationBoundaryCases: MilitaryPreparationBoundaryCase[] = [ + { + name: 'recruitment rejects a numeric-string crew type', + action: 'che_징병', + args: { crewType: '1100', amount: 100 }, + completed: false, + }, + { + name: 'recruitment rejects a fractional crew type', + action: 'che_징병', + args: { crewType: 1100.9, amount: 100 }, + completed: false, + }, + { + name: 'recruitment accepts a numeric-string amount', + action: 'che_징병', + args: { crewType: 1100, amount: '100' }, + completed: true, + }, + { + name: 'recruitment rejects a non-decimal numeric-looking amount', + action: 'che_징병', + args: { crewType: 1100, amount: '0x64' }, + completed: false, + }, + { + name: 'recruitment truncates a fractional amount', + action: 'che_징병', + args: { crewType: 1100, amount: 100.9 }, + completed: true, + }, + { + name: 'recruitment raises zero amount to the minimum', + action: 'che_징병', + args: { crewType: 1100, amount: 0 }, + completed: true, + }, + { + name: 'recruitment rejects a negative amount', + action: 'che_징병', + args: { crewType: 1100, amount: -1 }, + completed: false, + }, + { + name: 'recruitment rejects an unknown crew type', + action: 'che_징병', + args: { crewType: 9999, amount: 100 }, + completed: false, + }, + { + name: 'recruitment rejects a neutral actor', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + actorPatch: { nationId: 0 }, + completed: false, + }, + { + name: 'recruitment rejects a foreign-occupied city', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + fixturePatches: { cities: { 3: { nationId: 2 } } }, + completed: false, + }, + { + name: 'recruitment rejects population below the fixed reserve', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + fixturePatches: { cities: { 3: { population: 30_099 } } }, + completed: false, + }, + { + name: 'recruitment accepts the exact population reserve', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + fixturePatches: { cities: { 3: { population: 30_100 } } }, + completed: true, + }, + { + name: 'recruitment rejects trust below twenty', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + fixturePatches: { cities: { 3: { trust: 19.99 } } }, + completed: false, + }, + { + name: 'recruitment accepts trust exactly twenty', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + fixturePatches: { cities: { 3: { trust: 20 } } }, + completed: true, + }, + { + name: 'recruitment rejects insufficient gold', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + actorPatch: { gold: 0 }, + completed: false, + }, + { + name: 'recruitment rejects insufficient rice', + action: 'che_징병', + args: { crewType: 1100, amount: 100 }, + actorPatch: { rice: 0 }, + completed: false, + }, + { + name: 'recruitment clamps the same crew type at leadership capacity', + action: 'che_징병', + args: { crewType: 1100, amount: 99_999 }, + fixturePatches: { cities: { 3: { population: 200_000 } } }, + completed: true, + }, + { + name: 'mercenary recruitment keeps its doubled price and higher readiness', + action: 'che_모병', + args: { crewType: 1100, amount: 100 }, + completed: true, + }, + { + name: 'equipment trade rejects an unknown item type', + action: 'che_장비매매', + args: { itemType: 'armor', itemCode: 'che_무기_01_단도' }, + completed: false, + }, + { + name: 'equipment trade rejects an item from a different slot', + action: 'che_장비매매', + args: { itemType: 'book', itemCode: 'che_무기_01_단도' }, + completed: false, + }, + { + name: 'equipment trade rejects an unbuyable item', + action: 'che_장비매매', + args: { itemType: 'weapon', itemCode: 'che_무기_11_고정도' }, + completed: false, + }, + { + name: 'equipment trade does not treat the city trade value as trader availability', + action: 'che_장비매매', + args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, + fixturePatches: { cities: { 3: { trade: 0 } } }, + completed: true, + }, + { + name: 'equipment trade rejects insufficient gold', + action: 'che_장비매매', + args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, + actorPatch: { gold: 999 }, + completed: false, + }, + { + name: 'equipment trade rejects buying the equipped item', + action: 'che_장비매매', + args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, + actorPatch: { itemWeapon: 'che_무기_01_단도' }, + completed: false, + }, + { + name: 'equipment trade rejects selling an empty slot', + action: 'che_장비매매', + args: { itemType: 'weapon', itemCode: 'None' }, + completed: false, + }, + { + name: 'equipment trade sells a normal item for half price', + action: 'che_장비매매', + args: { itemType: 'weapon', itemCode: 'None' }, + actorPatch: { itemWeapon: 'che_무기_01_단도' }, + completed: true, + }, + { + name: 'equipment trade permits selling a unique item and records global logs', + action: 'che_장비매매', + args: { itemType: 'weapon', itemCode: 'None' }, + actorPatch: { itemWeapon: 'che_무기_11_고정도' }, + completed: true, + }, + { + name: 'disband rejects zero crew', + action: 'che_소집해제', + actorPatch: { crew: 0 }, + completed: false, + }, + { + name: 'disband allows a neutral actor and exceeds population capacity', + action: 'che_소집해제', + actorPatch: { nationId: 0, crew: 1001 }, + fixturePatches: { cities: { 3: { population: 200_000, populationMax: 200_000 } } }, + completed: true, + }, + { + name: 'fierce training rejects a neutral actor', + action: 'cr_맹훈련', + actorPatch: { nationId: 0 }, + completed: false, + }, + { + name: 'fierce training rejects a wandering nation', + action: 'cr_맹훈련', + fixturePatches: { nations: { 1: { level: 0 } } }, + completed: false, + }, + { + name: 'fierce training rejects a foreign-occupied city', + action: 'cr_맹훈련', + fixturePatches: { cities: { 3: { nationId: 2 } } }, + completed: false, + }, + { + name: 'fierce training rejects zero crew', + action: 'cr_맹훈련', + actorPatch: { crew: 0 }, + completed: false, + }, + { + name: 'fierce training rejects training at the command maximum', + action: 'cr_맹훈련', + actorPatch: { train: 100 }, + completed: false, + }, + { + name: 'fierce training enforces the advertised rice cost before execution', + action: 'cr_맹훈련', + actorPatch: { rice: 0 }, + completed: false, + }, + { + name: 'fierce training succeeds with morale already capped', + action: 'cr_맹훈련', + actorPatch: { train: 99, atmos: 100, crew: 1001 }, + completed: true, + }, +]; + +integration('general military preparation boundary, value, RNG, and log parity', () => { + it.each(militaryPreparationBoundaryCases)( + '$name', + async ({ name, action, args, actorPatch, fixturePatches, completed }) => { + const request = buildRequest(action, args, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = `general-military-preparation-${name}`; + request.observe!.includeGlobalHistoryLogs = true; + 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: action, + actionKey: completed ? action : '휴식', + usedFallback: !completed, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (completed) { + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + integration('명장일람 rank_data command parity', () => { it('화계 increments firenum from the same seeded value as legacy', async () => { const request = buildRequest(