From 9ca11f33dbed7832527a96a3cb2ec056601e348a Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 02:28:44 +0000 Subject: [PATCH] fix: complete reserved nation command parity --- .../turn-state-differential-testing.md | 10 +- .../logic/src/actions/turn/actionContext.ts | 1 + .../src/actions/turn/actionContextHelpers.ts | 12 ++ .../src/actions/turn/nation/che_의병모집.ts | 187 +++++++++++++----- .../src/actions/turn/nation/che_피장파장.ts | 3 - .../test/actions/turn/nationMissing.test.ts | 2 +- .../src/turn-differential/coreCommandTrace.ts | 7 +- ...urnCommandNationMatrix.integration.test.ts | 57 +++++- 8 files changed, 220 insertions(+), 59 deletions(-) diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index a6f6e9d..7107b3b 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -62,9 +62,9 @@ normalized to the core `*Id` spelling before command identity comparison. The matrix includes the four-call `전투태세` path, lifecycle and appointment commands, troop assembly, rebellion and all three founding variants. - - `turnCommandNationMatrix.integration.test.ts`: 32 successful nation - command paths, including all nine unit-research commands and the - multi-turn `필사즉생`, `허보`, and `초토화` paths. + - `turnCommandNationMatrix.integration.test.ts`: all 35 reserved nation + command paths, including the generated-general state and 36-call RNG + trace of `의병모집`. - `turnCommandCoreReference.integration.test.ts`: declaration and live sortie fixtures. @@ -169,11 +169,11 @@ Compatibility is established per case only when: 4. live sortie also passes the battle trace comparison; 5. any ignored path is documented in the case evidence. -As of 2026-07-26, 54 general matrix cases, 32 nation matrix cases, declaration +As of 2026-07-26, 54 general matrix cases, 35 nation matrix cases, declaration and live sortie pass this boundary. Live sortie is the remaining general command key and covers battle entry, conquest, defeated-general neutralization and last-city nation collapse. Thus all 55 general command keys have a -completed success-path comparison. This is 88 executable comparison cases; +completed success-path comparison. This is 91 executable comparison cases; it is not yet a claim that failure/boundary paths or all 38 nation commands have been dynamically compared. diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index 24818e5..9a07b34 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -28,6 +28,7 @@ export interface ActionContextWorldState { currentYear: number; currentMonth: number; tickSeconds: number; + lastTurnTime?: Date; meta?: Record; } diff --git a/packages/logic/src/actions/turn/actionContextHelpers.ts b/packages/logic/src/actions/turn/actionContextHelpers.ts index bb5d2fd..8f5da0e 100644 --- a/packages/logic/src/actions/turn/actionContextHelpers.ts +++ b/packages/logic/src/actions/turn/actionContextHelpers.ts @@ -16,6 +16,7 @@ export interface NationSummary { averageStats?: General['stats']; averageExperience?: number; averageDedication?: number; + averageDex?: [number, number, number, number, number]; } export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSummary => { @@ -68,6 +69,16 @@ export const buildNationSummary = (world: ActionContextWorldRef | null, nationId ); const expSum = generals.reduce((acc, general) => acc + general.experience, 0); const dedSum = generals.reduce((acc, general) => acc + general.dedication, 0); + const dexSum = generals.reduce<[number, number, number, number, number]>( + (acc, general) => [ + acc[0] + Number(general.meta.dex1 ?? 0), + acc[1] + Number(general.meta.dex2 ?? 0), + acc[2] + Number(general.meta.dex3 ?? 0), + acc[3] + Number(general.meta.dex4 ?? 0), + acc[4] + Number(general.meta.dex5 ?? 0), + ], + [0, 0, 0, 0, 0] as [number, number, number, number, number] + ); return { averageStats: { leadership: statSum.leadership / total, @@ -76,6 +87,7 @@ export const buildNationSummary = (world: ActionContextWorldRef | null, nationId }, averageExperience: expSum / total, averageDedication: dedSum / total, + averageDex: [dexSum[0] / total, dexSum[1] / total, dexSum[2] / total, dexSum[3] / total, dexSum[4] / total], }; }; diff --git a/packages/logic/src/actions/turn/nation/che_의병모집.ts b/packages/logic/src/actions/turn/nation/che_의병모집.ts index fb3f330..3059438 100644 --- a/packages/logic/src/actions/turn/nation/che_의병모집.ts +++ b/packages/logic/src/actions/turn/nation/che_의병모집.ts @@ -52,8 +52,11 @@ export interface VolunteerRecruitResolveContext< nationAverageStats?: StatBlock; nationAverageExperience?: number; nationAverageDedication?: number; + nationAverageDex?: [number, number, number, number, number]; generalPool?: VolunteerRecruitCandidate[]; createGeneralId: () => number; + turnTermSeconds: number; + turnTimeBase: Date; } export interface VolunteerRecruitEnvironment { @@ -71,6 +74,13 @@ export interface VolunteerRecruitEnvironment { npcDeathYears?: number; killTurnMin?: number; killTurnMax?: number; + npcStatTotal?: number; + npcStatMin?: number; + npcStatMax?: number; + randomGeneralFirstNames?: string[]; + randomGeneralMiddleNames?: string[]; + randomGeneralLastNames?: string[]; + availablePersonalities?: string[]; decorateName?: (name: string, npcState: number) => string; pickCandidate?: (context: VolunteerRecruitResolveContext, rng: RandomGenerator) => VolunteerRecruitCandidate | null; buildStats?: ( @@ -91,20 +101,9 @@ const DEFAULT_NPC_DEATH_YEARS = 10; const DEFAULT_KILLTURN_MIN = 64; const DEFAULT_KILLTURN_MAX = 70; const DEFAULT_SPEC_AGE = 19; - -const resolveKillturnFromDeathYear = ( - currentYear: number, - currentMonth: number, - deathYear: number, - rng: RandomGenerator -): number => { - if (!Number.isFinite(deathYear) || deathYear <= 0) { - return 0; - } - const deathMonth = randomRangeInt(rng, 1, 12); - const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth); - return Math.max(diff, 0); -}; +const DEFAULT_NPC_STAT_TOTAL = 150; +const DEFAULT_NPC_STAT_MIN = 10; +const DEFAULT_NPC_STAT_MAX = 75; const addMetaValue = ( meta: Record, @@ -124,6 +123,58 @@ const readMetaNumber = (meta: Record, key: string): number const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1); +type InclusiveRandomGenerator = RandomGenerator & { + nextIntInclusive?: (maxInclusive: number) => number; +}; + +const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => { + if (length <= 0) { + throw new Error('Empty items'); + } + const inclusive = rng as InclusiveRandomGenerator; + return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length); +}; + +const legacyChoice = (rng: RandomGenerator, values: readonly T[]): T => + values[legacyChoiceIndex(rng, values.length)]!; + +const pickByWeight = (rng: RandomGenerator, weights: Record): T => { + const entries = Object.entries(weights) as Array<[T, number]>; + const first = entries[0]; + if (!first) { + throw new Error('Empty weights'); + } + const total = entries.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0); + let cursor = rng.nextFloat1() * total; + for (const [key, weight] of entries) { + cursor -= Math.max(0, weight); + if (cursor <= 0) { + return key; + } + } + return entries.at(-1)?.[0] ?? first[0]; +}; + +const buildLegacyRandomStats = ( + rng: RandomGenerator, + env: VolunteerRecruitEnvironment +): { stats: StatBlock; pickType: '무' | '지' } => { + const total = env.npcStatTotal ?? DEFAULT_NPC_STAT_TOTAL; + const min = env.npcStatMin ?? DEFAULT_NPC_STAT_MIN; + const max = env.npcStatMax ?? DEFAULT_NPC_STAT_MAX; + const pickType = pickByWeight(rng, { 무: 5, 지: 5 }); + const main = max - randomRangeInt(rng, 0, min); + const other = min + randomRangeInt(rng, 0, Math.trunc(min / 2)); + const sub = total - main - other; + return { + pickType, + stats: + pickType === '무' + ? { leadership: sub, strength: main, intelligence: other } + : { leadership: sub, strength: other, intelligence: main }, + }; +}; + const resolveRelYear = (ctx: ConstraintContext): number => { const relYear = ctx.env.relYear; if (typeof relYear === 'number') { @@ -281,53 +332,92 @@ export class ActionResolver< const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS; const killTurnMin = this.env.killTurnMin ?? DEFAULT_KILLTURN_MIN; const killTurnMax = this.env.killTurnMax ?? DEFAULT_KILLTURN_MAX; + const firstNames = this.env.randomGeneralFirstNames ?? ['가']; + const middleNames = this.env.randomGeneralMiddleNames ?? ['']; + const lastNames = this.env.randomGeneralLastNames ?? ['가']; + const candidates = Array.from({ length: createCount }, () => { + const selected = resolveCandidate(context, context.rng, this.env); + if (selected) { + return selected; + } + return { + name: `${legacyChoice(context.rng, firstNames)}${legacyChoice( + context.rng, + middleNames + )}${legacyChoice(context.rng, lastNames)}`, + }; + }); - for (let idx = 0; idx < createCount; idx += 1) { + for (const candidate of candidates) { const newGeneralId = context.createGeneralId(); - const candidate = resolveCandidate(context, context.rng, this.env) ?? { name: `NPC_${newGeneralId}` }; - const name = this.env.decorateName ? this.env.decorateName(candidate.name, NPC_TYPE) : candidate.name; + const name = this.env.decorateName ? this.env.decorateName(candidate.name, NPC_TYPE) : `ⓖ${candidate.name}`; const birthYear = context.currentYear - baseAge; const deathYear = context.currentYear + deathYears; - const stats = resolveStats(context, context.rng, this.env, candidate); + const killturn = randomRangeInt(context.rng, killTurnMin, killTurnMax); + const affinity = candidate.affinity ?? randomRangeInt(context.rng, 1, 150); + const generated = buildLegacyRandomStats(context.rng, this.env); + const stats = candidate.stats ? resolveStats(context, context.rng, this.env, candidate) : generated.stats; + const averageDex = context.nationAverageDex ?? [0, 0, 0, 0, 0]; + const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3]; + const dex: [number, number, number, number] = + generated.pickType === '무' + ? legacyChoice(context.rng, [ + [(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8], + [dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8], + [dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8], + ]) + : [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8]; + const personality = + candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']); + const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1); + const turnFraction = randomRangeInt(context.rng, 0, 999_999); + const turnTime = new Date( + context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000) + ); const meta: GeneralMeta = { - killturn: resolveKillturnFromDeathYear( - context.currentYear, - context.currentMonth, - deathYear, - context.rng - ), + killturn, npcType: NPC_TYPE, crewTypeId: this.env.defaultCrewTypeId, + dex1: dex[0], + dex2: dex[1], + dex3: dex[2], + dex4: dex[3], + dex5: averageDex[4], + turnSecond, + turnFraction, }; - addMetaValue(meta, 'affinity', candidate.affinity ?? null); + addMetaValue(meta, 'affinity', affinity); addMetaValue(meta, 'picture', candidate.picture ?? null); addMetaValue(meta, 'birthYear', birthYear); + addMetaValue(meta, 'deathYear', deathYear); addMetaValue(meta, 'specAge', DEFAULT_SPEC_AGE); addMetaValue(meta, 'specAge2', DEFAULT_SPEC_AGE); - addMetaValue(meta, 'killturn', meta.killturn || randomRangeInt(context.rng, killTurnMin, killTurnMax)); addMetaValue(meta, 'text', candidate.text ?? null); - const newGeneral = buildRecruitmentGeneral({ - id: newGeneralId, - name, - nationId: context.general.nationId, - cityId: context.general.cityId, - stats, - officerLevel: 1, - age: baseAge, - npcState: NPC_TYPE, - gold: this.env.defaultNpcGold, - rice: this.env.defaultNpcRice, - experience: context.nationAverageExperience ?? 0, - dedication: context.nationAverageDedication ?? 0, - crewTypeId: this.env.defaultCrewTypeId, - role: { - personality: candidate.personality ?? null, - specialDomestic: this.env.defaultSpecialDomestic, - specialWar: this.env.defaultSpecialWar, - }, - meta, - }); + const newGeneral = { + ...buildRecruitmentGeneral({ + id: newGeneralId, + name, + nationId: context.general.nationId, + cityId: context.general.cityId, + stats, + officerLevel: 1, + age: baseAge, + npcState: NPC_TYPE, + gold: this.env.defaultNpcGold, + rice: this.env.defaultNpcRice, + experience: context.nationAverageExperience ?? 0, + dedication: context.nationAverageDedication ?? 0, + crewTypeId: this.env.defaultCrewTypeId, + role: { + personality, + specialDomestic: this.env.defaultSpecialDomestic, + specialWar: this.env.defaultSpecialWar, + }, + meta, + }), + turnTime, + }; effects.push(createGeneralAddEffect(newGeneral)); } @@ -396,7 +486,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { nationAverageStats: nationSummary.averageStats, nationAverageExperience: nationSummary.averageExperience, nationAverageDedication: nationSummary.averageDedication, + nationAverageDex: nationSummary.averageDex, createGeneralId: options.createGeneralId, + turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)), + turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime, }; }; diff --git a/packages/logic/src/actions/turn/nation/che_피장파장.ts b/packages/logic/src/actions/turn/nation/che_피장파장.ts index 080d6e1..08b853b 100644 --- a/packages/logic/src/actions/turn/nation/che_피장파장.ts +++ b/packages/logic/src/actions/turn/nation/che_피장파장.ts @@ -37,7 +37,6 @@ export interface CounterStrategyResolveContext< } const ACTION_NAME = '피장파장'; -const DEFAULT_GLOBAL_DELAY = 8; const TARGET_DELAY = 60; const PRE_REQ_TURN = 1; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); @@ -201,10 +200,8 @@ export class ActionResolver< } if (nation) { - const globalDelay = DEFAULT_GLOBAL_DELAY; nation.meta = { ...(nation.meta as object), - strategic_cmd_limit: globalDelay, [`next_execute_${targetCommandName}`]: currentYearMonth + this.getTargetPostReqTurn(context), }; effects.push( diff --git a/packages/logic/test/actions/turn/nationMissing.test.ts b/packages/logic/test/actions/turn/nationMissing.test.ts index 3989324..9a6751e 100644 --- a/packages/logic/test/actions/turn/nationMissing.test.ts +++ b/packages/logic/test/actions/turn/nationMissing.test.ts @@ -339,7 +339,7 @@ describe('Nation Missing Actions', () => { ); expect(resolution.general.experience).toBeGreaterThan(100); - expect(resolution.nation?.meta?.strategic_cmd_limit).toBe(8); + expect(resolution.nation?.meta?.strategic_cmd_limit).toBe(0); expect(resolution.nation?.meta?.next_execute_허보).toBe(227); expect(resolution.patches?.nations).toContainEqual({ id: destNation.id, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 0747be1..1bc38a4 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -311,7 +311,7 @@ const buildWorldInput = ( })); const snapshot: TurnWorldSnapshot = { scenarioConfig: { - stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, iconPath: '', map: {}, const: { @@ -586,7 +586,10 @@ export const runCoreTurnCommandTrace = async ( const worldInput = buildWorldInput(request, referenceBefore, unitSet, map); const { state, snapshot } = worldInput; const selector = { - generalIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))), + generalIds: new Set([ + ...referenceBefore.generals.map((row) => readNumber(row, 'id')), + ...(request.observe?.generalIds ?? []), + ]), cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))), }; diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 6636975..228226e 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -202,7 +202,7 @@ const buildRequest = ( ], }, observe: { - generalIds: [1, 2, 3], + generalIds: [1, 2, 3, 4, 5, 6], cityIds: [3, 70], nationIds: [1, 2], logAfterId: 0, @@ -364,6 +364,61 @@ const cases: NationMatrixCase[] = [ cities: { 70: { nationId: 1, supplyState: 1 } }, }, ], + [ + 'che_의병모집', + undefined, + { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '의병모집', term: 2 }, + }, + }, + }, + }, + ], + [ + 'che_수몰', + { destCityID: 70 }, + { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '수몰', arg: { destCityID: 70 }, term: 2 }, + }, + coreTurnLastByOfficerLevel: { + 12: { command: '수몰', arg: { destCityId: 70 }, term: 2 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, + }, + ], + [ + 'che_피장파장', + { destNationID: 2, commandType: 'che_필사즉생' }, + { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { + command: '피장파장', + arg: { destNationID: 2, commandType: 'che_필사즉생' }, + term: 1, + }, + }, + coreTurnLastByOfficerLevel: { + 12: { + command: '피장파장', + arg: { destNationId: 2, commandType: 'che_필사즉생' }, + term: 1, + }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, + }, + ], ]; integration('nation command success matrix', () => {