fix general scouting command parity

This commit is contained in:
2026-07-26 16:23:21 +00:00
parent cabb479cf6
commit ec941ad173
3 changed files with 314 additions and 14 deletions
@@ -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(`<Y>${name}</>${nameRa}는 <C>인재</>를 ${recruitVerb}하였습니다!`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`인재 <Y>${name}</>${nameSubjJosa} 등장했습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
context.addLog(`인재 <Y>${name}</>${nameObjJosa} 발견했습니다.`, {
context.addLog(
`<Y>${generalName}</>${generalNameYi} <Y>${name}</>${nameRa}는 <C>인재</>를 ${recruitVerb}하였습니다!`,
{
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
}
);
context.addLog(`<Y>${name}</>${nameRa}는 <C>인재</>를 ${recruitVerb}`, {
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
});
@@ -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<typeof ARGS_SCHEMA>;
@@ -142,6 +151,7 @@ export class ActionResolver<
const effects: GeneralActionEffect<TriggerState>[] = [];
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(`누군가가 <G><b>${destCityName}</b></>${josaUl} 살피는 것 같습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
if (distance <= 1) {
ctx.addLog(`<G><b>${destCityName}</b></>의 정보를 많이 얻었습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
ctx.addLog(
`【<G>${destCityName}</>】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`,
`【<G>${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(
`【<G>${destCityName}</>】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`,
`【<G>${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(
`【<G>${destCityName}</>】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew.toLocaleString()}`,
`【<G>${destCityName}</>】주민:${destCity.population.toLocaleString()}, 민심:${trustText}, 장수:${cityGenerals.length}, 병력:${totalCrew}`,
{
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
@@ -107,6 +107,7 @@ interface FixturePatches {
diplomacy?: Record<string, Record<string, unknown>>;
randomFoundingCandidateCityIds?: number[];
rankData?: Array<{ generalId: number; type: string; value: number }>;
additionalCities?: Array<Record<string, unknown>>;
}
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<string, unknown>;
actorPatch?: Record<string, unknown>;
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<string, unknown>
);
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(