From f41a762751e42d9b290f9deac3ffa013c2f92376 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 11:40:04 +0000 Subject: [PATCH] fix: match volunteer recruitment boundaries --- .../src/actions/turn/actionContextHelpers.ts | 12 +- .../src/actions/turn/nation/che_의병모집.ts | 50 ++- .../src/turn-differential/coreCommandTrace.ts | 8 +- ...urnCommandNationMatrix.integration.test.ts | 305 +++++++++++++++++- 4 files changed, 359 insertions(+), 16 deletions(-) diff --git a/packages/logic/src/actions/turn/actionContextHelpers.ts b/packages/logic/src/actions/turn/actionContextHelpers.ts index 2ae8186..1f9efc2 100644 --- a/packages/logic/src/actions/turn/actionContextHelpers.ts +++ b/packages/logic/src/actions/turn/actionContextHelpers.ts @@ -96,11 +96,19 @@ export const buildAverageNationGeneralCount = (world: ActionContextWorldRef | nu return 0; } const generals = world.listGenerals(); - const nations = world.listNations(); + const nations = world.listNations().filter((nation) => nation.level > 0); if (nations.length === 0) { return generals.length; } - return generals.length / nations.length; + return ( + nations.reduce((sum, nation) => { + const storedCount = nation.meta.gennum; + if (typeof storedCount === 'number' && Number.isFinite(storedCount)) { + return sum + storedCount; + } + return sum + generals.filter((general) => general.nationId === nation.id).length; + }, 0) / nations.length + ); }; export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?: ScenarioMeta): number => { diff --git a/packages/logic/src/actions/turn/nation/che_의병모집.ts b/packages/logic/src/actions/turn/nation/che_의병모집.ts index 3059438..08a649e 100644 --- a/packages/logic/src/actions/turn/nation/che_의병모집.ts +++ b/packages/logic/src/actions/turn/nation/che_의병모집.ts @@ -1,5 +1,11 @@ import type { RandomGenerator } from '@sammo-ts/common'; -import type { GeneralMeta, GeneralTriggerState, StatBlock, TriggerValue } from '@sammo-ts/logic/domain/entities.js'; +import type { + General, + GeneralMeta, + GeneralTriggerState, + StatBlock, + TriggerValue, +} from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { availableStrategicCommand, @@ -16,7 +22,7 @@ import type { GeneralActionResolveContext, GeneralActionResolver, } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralAddEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralAddEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { buildRecruitmentGeneral } from '@sammo-ts/logic/actions/turn/general/recruitment.js'; import { JosaUtil } from '@sammo-ts/common'; @@ -53,6 +59,7 @@ export interface VolunteerRecruitResolveContext< nationAverageExperience?: number; nationAverageDedication?: number; nationAverageDex?: [number, number, number, number, number]; + friendlyGenerals: Array>; generalPool?: VolunteerRecruitCandidate[]; createGeneralId: () => number; turnTermSeconds: number; @@ -284,6 +291,10 @@ export class ActionResolver< void _args; const general = context.general; const nation = context.nation; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const actionJosa = JosaUtil.pick(ACTION_NAME, '을'); + const broadcastMessage = `${generalName}${generalJosa} ${ACTION_NAME}${actionJosa} 발동하였습니다.`; const expGain = 5 * (DEFAULT_PRE_TURN + 1); const dedGain = 5 * (DEFAULT_PRE_TURN + 1); @@ -300,10 +311,7 @@ export class ActionResolver< }); if (nation) { - const generalName = general.name; - const generalJosa = JosaUtil.pick(generalName, '이'); - const actionJosa = JosaUtil.pick(actionName, '을'); - context.addLog(`${generalName}${generalJosa} ${actionName}${actionJosa} 발동했습니다.`, { + context.addLog(`${generalName}${generalJosa} ${actionName}${actionJosa} 발동`, { scope: LogScope.NATION, category: LogCategory.HISTORY, nationId: nation.id, @@ -327,6 +335,19 @@ export class ActionResolver< } const effects: Array> = []; + for (const target of context.friendlyGenerals) { + if (target.id === general.id) { + continue; + } + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } const baseAge = this.env.npcAge ?? DEFAULT_NPC_AGE; const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS; @@ -359,7 +380,7 @@ export class ActionResolver< 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] = + const rawDex: [number, number, number, number] = generated.pickType === '무' ? legacyChoice(context.rng, [ [(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8], @@ -367,6 +388,12 @@ export class ActionResolver< [dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8], ]) : [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8]; + const dex: [number, number, number, number] = [ + Math.trunc(rawDex[0]), + Math.trunc(rawDex[1]), + Math.trunc(rawDex[2]), + Math.trunc(rawDex[3]), + ]; const personality = candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']); const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1); @@ -382,7 +409,7 @@ export class ActionResolver< dex2: dex[1], dex3: dex[2], dex4: dex[3], - dex5: averageDex[4], + dex5: Math.trunc(averageDex[4]), turnSecond, turnFraction, }; @@ -406,8 +433,8 @@ export class ActionResolver< npcState: NPC_TYPE, gold: this.env.defaultNpcGold, rice: this.env.defaultNpcRice, - experience: context.nationAverageExperience ?? 0, - dedication: context.nationAverageDedication ?? 0, + experience: Math.trunc(context.nationAverageExperience ?? 0), + dedication: Math.trunc(context.nationAverageDedication ?? 0), crewTypeId: this.env.defaultCrewTypeId, role: { personality, @@ -477,6 +504,8 @@ export class ActionDefinition< // 예약 턴 실행에 필요한 국가 평균 정보를 구성한다. export const actionContextBuilder: ActionContextBuilder = (base, options) => { const nationSummary = buildNationSummary(options.worldRef, base.general.nationId); + const friendlyGenerals = + options.worldRef?.listGenerals().filter((general) => general.nationId === base.general.nationId) ?? []; return { ...base, currentYear: options.world.currentYear, @@ -487,6 +516,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { nationAverageExperience: nationSummary.averageExperience, nationAverageDedication: nationSummary.averageDedication, nationAverageDex: nationSummary.averageDex, + friendlyGenerals, createGeneralId: options.createGeneralId, turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)), turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 22d1b6d..58a9d36 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -480,6 +480,7 @@ const projectWorld = ( generalIds: Set; cityIds: Set; nationIds: Set; + initialGeneralIds: Set; generalCooldowns: GeneralCooldownSelector[]; nationCooldowns: NationCooldownSelector[]; } @@ -566,7 +567,11 @@ const projectWorld = ( rankData: world .listGenerals() .filter((general) => selector.generalIds.has(general.id)) - .flatMap(buildLegacyComparableRankRows) + .flatMap((general) => + buildLegacyComparableRankRows(general).map((row) => + selector.initialGeneralIds.has(general.id) ? row : { ...row, nationId: 0, value: 0 } + ) + ) .map((row) => ({ ...row })), cities: world .listCities() @@ -674,6 +679,7 @@ export const runCoreTurnCommandTrace = async ( ]), cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))), + initialGeneralIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))), generalCooldowns: request.observe?.generalCooldowns ?? [], nationCooldowns: request.observe?.nationCooldowns ?? [], }; diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 7bc8313..2c4b12c 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -331,9 +331,15 @@ const buildRequest = ( 4, 5, 6, + 7, + 8, + 9, + 10, + 11, + 12, ...Object.keys(fixturePatches.generals ?? {}) .map(Number) - .filter((id) => ![1, 2, 3, 4, 5, 6].includes(id)), + .filter((id) => ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(id)), ], cityIds: [ 3, @@ -358,7 +364,8 @@ const buildRequest = ( action === 'che_이호경식' || action === 'che_급습' || action === 'che_필사즉생' || - action === 'che_허보' + action === 'che_허보' || + action === 'che_의병모집' ? { nationCooldowns: [ { @@ -372,7 +379,9 @@ const buildRequest = ( ? '급습' : action === 'che_필사즉생' ? '필사즉생' - : '허보', + : action === 'che_허보' + ? '허보' + : '의병모집', }, ], } @@ -667,6 +676,296 @@ integration('nation command success matrix', () => { ); }); +type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed'; + +const volunteerRecruitBoundaryCases: Array<{ + name: string; + fixturePatches?: FixturePatches; + outcome: VolunteerRecruitOutcome; + coreResolution?: boolean; + expectedCreateCount?: number; + expectedPostReqTurn?: number; + expectedStrategicCommandLimit?: number; + expectedAverageExperience?: number; +}> = [ + { + name: 'rejects a neutral actor', + fixturePatches: { + generals: { 1: { nationId: 0 } }, + cities: { 3: { nationId: 0 } }, + }, + outcome: 'fallback', + coreResolution: false, + }, + { + name: 'rejects an actor city occupied by another nation', + fixturePatches: { cities: { 3: { nationId: 2 } } }, + outcome: 'fallback', + }, + { + name: 'rejects an actor below chief rank', + fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } }, + outcome: 'fallback', + coreResolution: false, + }, + { + name: 'rejects a remaining strategic-command delay', + fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } }, + outcome: 'fallback', + }, + { + name: 'rejects the turn before the three-year opening boundary', + fixturePatches: { world: { year: 182 } }, + outcome: 'fallback', + }, + { + name: 'accepts the exact three-year opening boundary', + fixturePatches: { world: { year: 183 } }, + outcome: 'intermediate', + }, + { + name: 'allows an unsupplied actor city', + fixturePatches: { cities: { 3: { supplyState: 0 } } }, + outcome: 'intermediate', + }, + { + name: 'starts at term one', + outcome: 'intermediate', + }, + { + name: 'advances the prior first term to term two', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '의병모집', term: 1 }, + }, + }, + }, + }, + outcome: 'intermediate', + }, + { + name: 'restarts at term one after another command interrupted the stack', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + }, + outcome: 'intermediate', + }, + { + name: 'creates three default volunteer generals', + outcome: 'completed', + expectedCreateCount: 3, + expectedPostReqTurn: 98, + }, + { + name: 'applies the strategist command and global-delay modifiers', + fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } }, + outcome: 'completed', + expectedCreateCount: 3, + expectedPostReqTurn: 73, + expectedStrategicCommandLimit: 5, + }, + { + name: 'uses stored eleven-general count for the nation cooldown', + fixturePatches: { nations: { 1: { generalCount: 11 } } }, + outcome: 'completed', + expectedCreateCount: 4, + expectedPostReqTurn: 103, + }, + { + name: 'rounds the active nations stored average at the four-general creation boundary', + fixturePatches: { + nations: { + 1: { generalCount: 4 }, + 2: { generalCount: 4 }, + }, + }, + outcome: 'completed', + expectedCreateCount: 4, + expectedPostReqTurn: 98, + }, + { + name: 'excludes a level-zero nation from the stored average', + fixturePatches: { + nations: { + 1: { generalCount: 4 }, + 2: { generalCount: 100, level: 0 }, + }, + }, + outcome: 'completed', + expectedCreateCount: 4, + expectedPostReqTurn: 98, + }, + { + name: 'preserves legacy integer persistence for fractional nation averages', + fixturePatches: { + generals: { + 3: { + experience: 1001, + dedication: 1001, + meta: { dex1: 1, dex2: 1, dex3: 1, dex4: 1, dex5: 1 }, + }, + }, + }, + outcome: 'completed', + expectedCreateCount: 3, + expectedPostReqTurn: 98, + expectedAverageExperience: 1000, + }, +]; + +integration('nation volunteer-recruitment constraints, creation values, RNG, and cooldown boundaries', () => { + it.each(volunteerRecruitBoundaryCases)( + '$name matches legacy progress, created generals, cooldown, logs, RNG, and semantic delta', + async ({ + fixturePatches, + outcome, + coreResolution = true, + expectedCreateCount = 0, + expectedPostReqTurn, + expectedStrategicCommandLimit = 9, + expectedAverageExperience = 1000, + }) => { + const completed = outcome === 'completed'; + const fallback = outcome === 'fallback'; + const completionNationPatch = completed + ? { + turnLastByOfficerLevel: { + 12: { command: '의병모집', term: 2 }, + }, + } + : {}; + const request = buildRequest('che_의병모집', undefined, { + ...fixturePatches, + nations: { + ...fixturePatches?.nations, + 1: { + ...fixturePatches?.nations?.[1], + ...completionNationPatch, + }, + }, + }); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + if (coreResolution) { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_의병모집', + actionKey: fallback ? '휴식' : 'che_의병모집', + usedFallback: fallback, + ...(fallback ? {} : { completed }), + }); + } else { + expect(core.execution.outcome).toBeUndefined(); + } + + for (const snapshot of [reference, core]) { + const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); + const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); + const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1); + const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1); + const beforeGeneralIds = new Set(snapshot.before.generals.map((entry) => entry.id)); + const createdGenerals = snapshot.after.generals.filter((entry) => !beforeGeneralIds.has(entry.id)); + + expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); + expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); + expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe( + completed ? 15 : 0 + ); + expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( + completed ? 15 : 0 + ); + expect(createdGenerals).toHaveLength(completed ? expectedCreateCount : 0); + expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( + completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit') + ); + + if (completed) { + for (const created of createdGenerals) { + expect(created).toMatchObject({ + nationId: 1, + cityId: 3, + officerLevel: 1, + npcState: 4, + gold: 1000, + rice: 1000, + age: 20, + experience: expectedAverageExperience, + dedication: expectedAverageExperience, + specialDomestic: null, + specialWar: null, + }); + expect(String(created.name)).toMatch(/^ⓖ/); + expect( + readNumericField(created, 'leadership') + + readNumericField(created, 'strength') + + readNumericField(created, 'intelligence') + ).toBe(150); + expect(readNumericField(created, 'killTurn')).toBeGreaterThanOrEqual(64); + expect(readNumericField(created, 'killTurn')).toBeLessThanOrEqual(70); + } + const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + expect(addedLogs.map((entry) => entry.text)).toEqual( + expect.arrayContaining([expect.stringContaining('의병모집 발동')]) + ); + expect( + addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('의병모집')) + ).toBe(true); + } + } + + if (outcome === 'intermediate') { + const priorTurnByOfficerLevel = fixturePatches?.nations?.[1]?.turnLastByOfficerLevel; + const priorTerm = + priorTurnByOfficerLevel && typeof priorTurnByOfficerLevel === 'object' + ? (priorTurnByOfficerLevel as Record)[12] + : undefined; + const expectedTerm = priorTerm?.command === '의병모집' && priorTerm.term === 1 ? 2 : 1; + expect(reference.execution.outcome).toMatchObject({ + lastTurn: { + command: '의병모집', + term: expectedTerm, + }, + }); + expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ + command: '의병모집', + term: expectedTerm, + }); + } + if (completed) { + const currentYear = fixturePatches?.world?.year ?? 190; + const expectedNextAvailableTurn = currentYear * 12 + expectedPostReqTurn!; + expect(reference.after.world.nationCooldowns).toEqual([ + { + nationId: 1, + actionName: '의병모집', + nextAvailableTurn: expectedNextAvailableTurn, + }, + ]); + expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); + } + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + const nationResourceAmountCases: Array<{ name: string; action: 'che_포상' | 'che_몰수';