diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 8cca825..edfd495 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -418,20 +418,23 @@ export class ActionResolver< meta, }); - const nameObjJosa = JosaUtil.pick(name, '을'); - const nameSubjJosa = JosaUtil.pick(name, '이'); const recruitVerb = '발견'; const nameRa = JosaUtil.pick(name, '라'); + const generalName = general.name; + const generalNameYi = JosaUtil.pick(generalName, '이'); context.addLog(`${name}${nameRa}는 인재를 ${recruitVerb}하였습니다!`, { category: LogCategory.ACTION, format: LogFormat.MONTH, }); - context.addLog(`인재 ${name}${nameSubjJosa} 등장했습니다.`, { - scope: LogScope.SYSTEM, - category: LogCategory.SUMMARY, - format: LogFormat.MONTH, - }); - context.addLog(`인재 ${name}${nameObjJosa} 발견했습니다.`, { + context.addLog( + `${generalName}${generalNameYi} ${name}${nameRa}는 인재를 ${recruitVerb}하였습니다!`, + { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + } + ); + context.addLog(`${name}${nameRa}는 인재를 ${recruitVerb}`, { category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, }); diff --git a/packages/logic/src/actions/turn/general/che_첩보.ts b/packages/logic/src/actions/turn/general/che_첩보.ts index 5739e33..708cfcd 100644 --- a/packages/logic/src/actions/turn/general/che_첩보.ts +++ b/packages/logic/src/actions/turn/general/che_첩보.ts @@ -14,8 +14,9 @@ import type { GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { z } from 'zod'; +import { JosaUtil } from '@sammo-ts/common'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; @@ -36,7 +37,15 @@ export interface SpyResolveContext< const ACTION_NAME = '첩보'; const ACTION_KEY = 'che_첩보'; const ARGS_SCHEMA = z.object({ - destCityId: z.number(), + destCityId: z.preprocess((value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value)) { + return Number(value); + } + return value; + }, z.number().int()), }); export type SpyArgs = z.infer; @@ -142,6 +151,7 @@ export class ActionResolver< const effects: GeneralActionEffect[] = []; const destCityName = destCity.name; + const josaUl = JosaUtil.pick(destCityName, '을'); const currentCityId = general.cityId; const distance = getCityDistance(ctx.map, currentCityId, destCityId); @@ -157,13 +167,18 @@ export class ActionResolver< const trust = destCity.meta.trust; const trustText = typeof trust === 'number' ? trust.toFixed(1) : '?'; + ctx.addLog(`누군가가 ${destCityName}${josaUl} 살피는 것 같습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + }); if (distance <= 1) { ctx.addLog(`${destCityName}의 정보를 많이 얻었습니다.`, { category: LogCategory.ACTION, format: LogFormat.MONTH, }); ctx.addLog( - `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`, + `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew}`, { category: LogCategory.ACTION, format: LogFormat.RAWTEXT, @@ -208,7 +223,7 @@ export class ActionResolver< format: LogFormat.MONTH, }); ctx.addLog( - `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`, + `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew}`, { category: LogCategory.ACTION, format: LogFormat.RAWTEXT, @@ -227,7 +242,7 @@ export class ActionResolver< format: LogFormat.MONTH, }); ctx.addLog( - `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`, + `【${destCityName}】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew}`, { category: LogCategory.ACTION, format: LogFormat.RAWTEXT, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 214e0f1..5af8ee9 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -107,6 +107,7 @@ interface FixturePatches { diplomacy?: Record>; randomFoundingCandidateCityIds?: number[]; rankData?: Array<{ generalId: number; type: string; value: number }>; + additionalCities?: Array>; } const buildRequest = ( @@ -195,6 +196,7 @@ const buildRequest = ( trade: 100, ...fixturePatches.cities?.[70], }, + ...(fixturePatches.additionalCities ?? []), ], generals: [ { ...general(1, 1, 3, 12), ...actorPatch, ...fixturePatches.generals?.[1] }, @@ -227,7 +229,11 @@ const buildRequest = ( }, observe: { generalIds: [1, 2, 3], - cityIds: [3, 70], + cityIds: [ + 3, + 70, + ...(fixturePatches.additionalCities?.map((city) => Number(city.id)).filter(Number.isFinite) ?? []), + ], nationIds: [1, 2], logAfterId: 0, messageAfterId: 0, @@ -1383,6 +1389,282 @@ integration('general military preparation boundary, value, RNG, and log parity', ); }); +interface TrainingScoutBoundaryCase { + name: string; + action: 'che_훈련' | 'che_전투태세' | 'che_인재탐색' | 'che_첩보'; + args?: Record; + actorPatch?: Record; + fixturePatches?: FixturePatches; + hiddenSeed?: string; + completed: boolean; + fallback?: boolean; +} + +const trainingScoutBoundaryCases: TrainingScoutBoundaryCase[] = [ + { + name: 'training rejects a neutral actor', + action: 'che_훈련', + actorPatch: { nationId: 0 }, + completed: false, + }, + { + name: 'training rejects a wandering nation', + action: 'che_훈련', + fixturePatches: { nations: { 1: { level: 0 } } }, + completed: false, + }, + { + name: 'training rejects a foreign-occupied city', + action: 'che_훈련', + fixturePatches: { cities: { 3: { nationId: 2 } } }, + completed: false, + }, + { + name: 'training rejects zero crew', + action: 'che_훈련', + actorPatch: { crew: 0 }, + completed: false, + }, + { + name: 'training rejects the command maximum', + action: 'che_훈련', + actorPatch: { train: 100 }, + completed: false, + }, + { + name: 'training clamps one below the maximum and applies morale side effect', + action: 'che_훈련', + actorPatch: { train: 99, atmos: 99, crew: 1001 }, + completed: true, + }, + { + name: 'training preserves zero rounded score and morale side effect', + action: 'che_훈련', + actorPatch: { train: 50, atmos: 99, crew: 9_999_999 }, + completed: true, + }, + { + name: 'battle preparation rejects a neutral actor at completion', + action: 'che_전투태세', + actorPatch: { nationId: 0, lastTurn: { command: '전투태세', term: 3 } }, + completed: false, + }, + { + name: 'battle preparation rejects a wandering nation at completion', + action: 'che_전투태세', + actorPatch: { lastTurn: { command: '전투태세', term: 3 } }, + fixturePatches: { nations: { 1: { level: 0 } } }, + completed: false, + }, + { + name: 'battle preparation rejects a foreign-occupied city at completion', + action: 'che_전투태세', + actorPatch: { lastTurn: { command: '전투태세', term: 3 } }, + fixturePatches: { cities: { 3: { nationId: 2 } } }, + completed: false, + }, + { + name: 'battle preparation rejects zero crew at completion', + action: 'che_전투태세', + actorPatch: { crew: 0, lastTurn: { command: '전투태세', term: 3 } }, + completed: false, + }, + { + name: 'battle preparation rejects insufficient gold at completion', + action: 'che_전투태세', + actorPatch: { gold: 0, lastTurn: { command: '전투태세', term: 3 } }, + completed: false, + }, + { + name: 'battle preparation rejects training at ninety', + action: 'che_전투태세', + actorPatch: { train: 90, lastTurn: { command: '전투태세', term: 3 } }, + completed: false, + }, + { + name: 'battle preparation rejects morale at ninety', + action: 'che_전투태세', + actorPatch: { atmos: 90, lastTurn: { command: '전투태세', term: 3 } }, + completed: false, + }, + { + name: 'battle preparation accepts exact training and morale margins', + action: 'che_전투태세', + actorPatch: { + train: 89, + atmos: 89, + gold: 1_000_000, + rice: 100_000, + lastTurn: { command: '전투태세', term: 3 }, + }, + completed: true, + }, + { + name: 'talent scout rejects insufficient development gold', + action: 'che_인재탐색', + actorPatch: { gold: 17 }, + completed: false, + }, + { + name: 'talent scout accepts the exact development gold', + action: 'che_인재탐색', + actorPatch: { gold: 18 }, + hiddenSeed: 'general-talent-scout-exact-cost', + completed: true, + }, + { + name: 'talent scout preserves a second fixed probability branch', + action: 'che_인재탐색', + hiddenSeed: 'general-talent-scout-probability-1', + completed: true, + }, + { + name: 'spy accepts a numeric-string destination city ID', + action: 'che_첩보', + args: { destCityID: '70' }, + completed: true, + }, + { + name: 'spy accepts a fractional destination city ID through PHP array-key coercion', + action: 'che_첩보', + args: { destCityID: 70.9 }, + completed: true, + }, + { + name: 'spy rejects an unknown destination city', + action: 'che_첩보', + args: { destCityID: 999 }, + completed: false, + }, + { + name: 'spy rejects the occupied city', + action: 'che_첩보', + args: { destCityID: 3 }, + completed: false, + }, + { + name: 'spy rejects a neutral actor', + action: 'che_첩보', + args: { destCityID: 70 }, + actorPatch: { nationId: 0 }, + completed: true, + }, + { + name: 'spy rejects insufficient gold', + action: 'che_첩보', + args: { destCityID: 70 }, + actorPatch: { gold: 53 }, + completed: false, + }, + { + name: 'spy rejects insufficient rice', + action: 'che_첩보', + args: { destCityID: 70 }, + actorPatch: { rice: 53 }, + completed: false, + }, + { + name: 'spy reports full adjacent intelligence and tech advantage', + action: 'che_첩보', + args: { destCityID: 70 }, + fixturePatches: { nations: { 1: { tech: 1000 }, 2: { tech: 2000 } } }, + completed: true, + }, + { + name: 'spy reports medium intelligence at distance two', + action: 'che_첩보', + args: { destCityID: 1 }, + fixturePatches: { + generals: { 2: { cityId: 1 } }, + additionalCities: [ + { + id: 1, + nationId: 2, + population: 123_456, + populationMax: 620_500, + agriculture: 1111, + commerce: 2222, + security: 3333, + defence: 4444, + wall: 5555, + supplyState: 1, + frontState: 1, + state: 0, + term: 0, + trust: 66.6, + trade: 100, + }, + ], + }, + completed: true, + }, + { + name: 'spy reports only rumors beyond distance two', + action: 'che_첩보', + args: { destCityID: 4 }, + fixturePatches: { + generals: { 2: { cityId: 4 } }, + additionalCities: [ + { + id: 4, + nationId: 2, + population: 234_567, + populationMax: 655_700, + agriculture: 1111, + commerce: 2222, + security: 3333, + defence: 4444, + wall: 5555, + supplyState: 1, + frontState: 1, + state: 0, + term: 0, + trust: 55.5, + trade: 100, + }, + ], + }, + completed: true, + }, +]; + +integration('general training, talent scout, and spy boundary, value, RNG, and log parity', () => { + it.each(trainingScoutBoundaryCases)( + '$name', + async ({ name, action, args, actorPatch, fixturePatches, hiddenSeed, completed, fallback }) => { + const request = buildRequest(action, args, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = hiddenSeed ?? `general-training-scout-${name}`; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const usedFallback = fallback ?? !completed; + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: usedFallback ? '휴식' : action, + usedFallback, + }); + 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(