fix: match volunteer recruitment boundaries

This commit is contained in:
2026-07-26 11:40:04 +00:00
parent b66877b9c2
commit f41a762751
4 changed files with 359 additions and 16 deletions
@@ -96,11 +96,19 @@ export const buildAverageNationGeneralCount = (world: ActionContextWorldRef | nu
return 0; return 0;
} }
const generals = world.listGenerals(); const generals = world.listGenerals();
const nations = world.listNations(); const nations = world.listNations().filter((nation) => nation.level > 0);
if (nations.length === 0) { if (nations.length === 0) {
return generals.length; 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 => { export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?: ScenarioMeta): number => {
@@ -1,5 +1,11 @@
import type { RandomGenerator } from '@sammo-ts/common'; 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 type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
availableStrategicCommand, availableStrategicCommand,
@@ -16,7 +22,7 @@ import type {
GeneralActionResolveContext, GeneralActionResolveContext,
GeneralActionResolver, GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js'; } 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 { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { buildRecruitmentGeneral } from '@sammo-ts/logic/actions/turn/general/recruitment.js'; import { buildRecruitmentGeneral } from '@sammo-ts/logic/actions/turn/general/recruitment.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
@@ -53,6 +59,7 @@ export interface VolunteerRecruitResolveContext<
nationAverageExperience?: number; nationAverageExperience?: number;
nationAverageDedication?: number; nationAverageDedication?: number;
nationAverageDex?: [number, number, number, number, number]; nationAverageDex?: [number, number, number, number, number];
friendlyGenerals: Array<General<TriggerState>>;
generalPool?: VolunteerRecruitCandidate[]; generalPool?: VolunteerRecruitCandidate[];
createGeneralId: () => number; createGeneralId: () => number;
turnTermSeconds: number; turnTermSeconds: number;
@@ -284,6 +291,10 @@ export class ActionResolver<
void _args; void _args;
const general = context.general; const general = context.general;
const nation = context.nation; const nation = context.nation;
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const actionJosa = JosaUtil.pick(ACTION_NAME, '을');
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>${actionJosa} 발동하였습니다.`;
const expGain = 5 * (DEFAULT_PRE_TURN + 1); const expGain = 5 * (DEFAULT_PRE_TURN + 1);
const dedGain = 5 * (DEFAULT_PRE_TURN + 1); const dedGain = 5 * (DEFAULT_PRE_TURN + 1);
@@ -300,10 +311,7 @@ export class ActionResolver<
}); });
if (nation) { if (nation) {
const generalName = general.name; context.addLog(`<Y>${generalName}</>${generalJosa} <M>${actionName}</>${actionJosa} 발동`, {
const generalJosa = JosaUtil.pick(generalName, '이');
const actionJosa = JosaUtil.pick(actionName, '을');
context.addLog(`<Y>${generalName}</>${generalJosa} <M>${actionName}</>${actionJosa} 발동했습니다.`, {
scope: LogScope.NATION, scope: LogScope.NATION,
category: LogCategory.HISTORY, category: LogCategory.HISTORY,
nationId: nation.id, nationId: nation.id,
@@ -327,6 +335,19 @@ export class ActionResolver<
} }
const effects: Array<GeneralActionEffect<TriggerState>> = []; const effects: Array<GeneralActionEffect<TriggerState>> = [];
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 baseAge = this.env.npcAge ?? DEFAULT_NPC_AGE;
const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS; 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 stats = candidate.stats ? resolveStats(context, context.rng, this.env, candidate) : generated.stats;
const averageDex = context.nationAverageDex ?? [0, 0, 0, 0, 0]; const averageDex = context.nationAverageDex ?? [0, 0, 0, 0, 0];
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3]; const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
const dex: [number, number, number, number] = const rawDex: [number, number, number, number] =
generated.pickType === '무' generated.pickType === '무'
? legacyChoice(context.rng, [ ? legacyChoice(context.rng, [
[(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8], [(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 * 5) / 8, dexTotal / 8],
]) ])
: [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 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 = const personality =
candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']); candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1); const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1);
@@ -382,7 +409,7 @@ export class ActionResolver<
dex2: dex[1], dex2: dex[1],
dex3: dex[2], dex3: dex[2],
dex4: dex[3], dex4: dex[3],
dex5: averageDex[4], dex5: Math.trunc(averageDex[4]),
turnSecond, turnSecond,
turnFraction, turnFraction,
}; };
@@ -406,8 +433,8 @@ export class ActionResolver<
npcState: NPC_TYPE, npcState: NPC_TYPE,
gold: this.env.defaultNpcGold, gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice, rice: this.env.defaultNpcRice,
experience: context.nationAverageExperience ?? 0, experience: Math.trunc(context.nationAverageExperience ?? 0),
dedication: context.nationAverageDedication ?? 0, dedication: Math.trunc(context.nationAverageDedication ?? 0),
crewTypeId: this.env.defaultCrewTypeId, crewTypeId: this.env.defaultCrewTypeId,
role: { role: {
personality, personality,
@@ -477,6 +504,8 @@ export class ActionDefinition<
// 예약 턴 실행에 필요한 국가 평균 정보를 구성한다. // 예약 턴 실행에 필요한 국가 평균 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const nationSummary = buildNationSummary(options.worldRef, base.general.nationId); const nationSummary = buildNationSummary(options.worldRef, base.general.nationId);
const friendlyGenerals =
options.worldRef?.listGenerals().filter((general) => general.nationId === base.general.nationId) ?? [];
return { return {
...base, ...base,
currentYear: options.world.currentYear, currentYear: options.world.currentYear,
@@ -487,6 +516,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
nationAverageExperience: nationSummary.averageExperience, nationAverageExperience: nationSummary.averageExperience,
nationAverageDedication: nationSummary.averageDedication, nationAverageDedication: nationSummary.averageDedication,
nationAverageDex: nationSummary.averageDex, nationAverageDex: nationSummary.averageDex,
friendlyGenerals,
createGeneralId: options.createGeneralId, createGeneralId: options.createGeneralId,
turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)), turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)),
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime, turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
@@ -480,6 +480,7 @@ const projectWorld = (
generalIds: Set<number>; generalIds: Set<number>;
cityIds: Set<number>; cityIds: Set<number>;
nationIds: Set<number>; nationIds: Set<number>;
initialGeneralIds: Set<number>;
generalCooldowns: GeneralCooldownSelector[]; generalCooldowns: GeneralCooldownSelector[];
nationCooldowns: NationCooldownSelector[]; nationCooldowns: NationCooldownSelector[];
} }
@@ -566,7 +567,11 @@ const projectWorld = (
rankData: world rankData: world
.listGenerals() .listGenerals()
.filter((general) => selector.generalIds.has(general.id)) .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 })), .map((row) => ({ ...row })),
cities: world cities: world
.listCities() .listCities()
@@ -674,6 +679,7 @@ export const runCoreTurnCommandTrace = async (
]), ]),
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
nationIds: new Set(referenceBefore.nations.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 ?? [], generalCooldowns: request.observe?.generalCooldowns ?? [],
nationCooldowns: request.observe?.nationCooldowns ?? [], nationCooldowns: request.observe?.nationCooldowns ?? [],
}; };
@@ -331,9 +331,15 @@ const buildRequest = (
4, 4,
5, 5,
6, 6,
7,
8,
9,
10,
11,
12,
...Object.keys(fixturePatches.generals ?? {}) ...Object.keys(fixturePatches.generals ?? {})
.map(Number) .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: [ cityIds: [
3, 3,
@@ -358,7 +364,8 @@ const buildRequest = (
action === 'che_이호경식' || action === 'che_이호경식' ||
action === 'che_급습' || action === 'che_급습' ||
action === 'che_필사즉생' || action === 'che_필사즉생' ||
action === 'che_허보' action === 'che_허보' ||
action === 'che_의병모집'
? { ? {
nationCooldowns: [ nationCooldowns: [
{ {
@@ -372,7 +379,9 @@ const buildRequest = (
? '급습' ? '급습'
: action === 'che_필사즉생' : 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<string, unknown>
);
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<number, { command?: string; term?: number }>)[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<{ const nationResourceAmountCases: Array<{
name: string; name: string;
action: 'che_포상' | 'che_몰수'; action: 'che_포상' | 'che_몰수';