fix: complete reserved nation command parity

This commit is contained in:
2026-07-26 02:28:44 +00:00
parent 398472be11
commit 9ca11f33db
8 changed files with 220 additions and 59 deletions
@@ -62,9 +62,9 @@ normalized to the core `*Id` spelling before command identity comparison.
The matrix includes the four-call `전투태세` path, lifecycle and The matrix includes the four-call `전투태세` path, lifecycle and
appointment commands, troop assembly, rebellion and all three founding appointment commands, troop assembly, rebellion and all three founding
variants. variants.
- `turnCommandNationMatrix.integration.test.ts`: 32 successful nation - `turnCommandNationMatrix.integration.test.ts`: all 35 reserved nation
command paths, including all nine unit-research commands and the command paths, including the generated-general state and 36-call RNG
multi-turn `필사즉생`, `허보`, and `초토화` paths. trace of `의병모집`.
- `turnCommandCoreReference.integration.test.ts`: declaration and live - `turnCommandCoreReference.integration.test.ts`: declaration and live
sortie fixtures. sortie fixtures.
@@ -169,11 +169,11 @@ Compatibility is established per case only when:
4. live sortie also passes the battle trace comparison; 4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence. 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 and live sortie pass this boundary. Live sortie is the remaining general
command key and covers battle entry, conquest, defeated-general neutralization command key and covers battle entry, conquest, defeated-general neutralization
and last-city nation collapse. Thus all 55 general command keys have a 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 it is not yet a claim that failure/boundary paths or all 38 nation commands
have been dynamically compared. have been dynamically compared.
@@ -28,6 +28,7 @@ export interface ActionContextWorldState {
currentYear: number; currentYear: number;
currentMonth: number; currentMonth: number;
tickSeconds: number; tickSeconds: number;
lastTurnTime?: Date;
meta?: Record<string, unknown>; meta?: Record<string, unknown>;
} }
@@ -16,6 +16,7 @@ export interface NationSummary {
averageStats?: General['stats']; averageStats?: General['stats'];
averageExperience?: number; averageExperience?: number;
averageDedication?: number; averageDedication?: number;
averageDex?: [number, number, number, number, number];
} }
export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSummary => { 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 expSum = generals.reduce((acc, general) => acc + general.experience, 0);
const dedSum = generals.reduce((acc, general) => acc + general.dedication, 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 { return {
averageStats: { averageStats: {
leadership: statSum.leadership / total, leadership: statSum.leadership / total,
@@ -76,6 +87,7 @@ export const buildNationSummary = (world: ActionContextWorldRef | null, nationId
}, },
averageExperience: expSum / total, averageExperience: expSum / total,
averageDedication: dedSum / total, averageDedication: dedSum / total,
averageDex: [dexSum[0] / total, dexSum[1] / total, dexSum[2] / total, dexSum[3] / total, dexSum[4] / total],
}; };
}; };
@@ -52,8 +52,11 @@ export interface VolunteerRecruitResolveContext<
nationAverageStats?: StatBlock; nationAverageStats?: StatBlock;
nationAverageExperience?: number; nationAverageExperience?: number;
nationAverageDedication?: number; nationAverageDedication?: number;
nationAverageDex?: [number, number, number, number, number];
generalPool?: VolunteerRecruitCandidate[]; generalPool?: VolunteerRecruitCandidate[];
createGeneralId: () => number; createGeneralId: () => number;
turnTermSeconds: number;
turnTimeBase: Date;
} }
export interface VolunteerRecruitEnvironment { export interface VolunteerRecruitEnvironment {
@@ -71,6 +74,13 @@ export interface VolunteerRecruitEnvironment {
npcDeathYears?: number; npcDeathYears?: number;
killTurnMin?: number; killTurnMin?: number;
killTurnMax?: number; killTurnMax?: number;
npcStatTotal?: number;
npcStatMin?: number;
npcStatMax?: number;
randomGeneralFirstNames?: string[];
randomGeneralMiddleNames?: string[];
randomGeneralLastNames?: string[];
availablePersonalities?: string[];
decorateName?: (name: string, npcState: number) => string; decorateName?: (name: string, npcState: number) => string;
pickCandidate?: (context: VolunteerRecruitResolveContext, rng: RandomGenerator) => VolunteerRecruitCandidate | null; pickCandidate?: (context: VolunteerRecruitResolveContext, rng: RandomGenerator) => VolunteerRecruitCandidate | null;
buildStats?: ( buildStats?: (
@@ -91,20 +101,9 @@ const DEFAULT_NPC_DEATH_YEARS = 10;
const DEFAULT_KILLTURN_MIN = 64; const DEFAULT_KILLTURN_MIN = 64;
const DEFAULT_KILLTURN_MAX = 70; const DEFAULT_KILLTURN_MAX = 70;
const DEFAULT_SPEC_AGE = 19; const DEFAULT_SPEC_AGE = 19;
const DEFAULT_NPC_STAT_TOTAL = 150;
const resolveKillturnFromDeathYear = ( const DEFAULT_NPC_STAT_MIN = 10;
currentYear: number, const DEFAULT_NPC_STAT_MAX = 75;
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 addMetaValue = ( const addMetaValue = (
meta: Record<string, TriggerValue>, meta: Record<string, TriggerValue>,
@@ -124,6 +123,58 @@ const readMetaNumber = (meta: Record<string, TriggerValue>, key: string): number
const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1); 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 = <T>(rng: RandomGenerator, values: readonly T[]): T =>
values[legacyChoiceIndex(rng, values.length)]!;
const pickByWeight = <T extends string>(rng: RandomGenerator, weights: Record<T, number>): 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 resolveRelYear = (ctx: ConstraintContext): number => {
const relYear = ctx.env.relYear; const relYear = ctx.env.relYear;
if (typeof relYear === 'number') { if (typeof relYear === 'number') {
@@ -281,53 +332,92 @@ export class ActionResolver<
const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS; const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS;
const killTurnMin = this.env.killTurnMin ?? DEFAULT_KILLTURN_MIN; const killTurnMin = this.env.killTurnMin ?? DEFAULT_KILLTURN_MIN;
const killTurnMax = this.env.killTurnMax ?? DEFAULT_KILLTURN_MAX; 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 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 birthYear = context.currentYear - baseAge;
const deathYear = context.currentYear + deathYears; 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 = { const meta: GeneralMeta = {
killturn: resolveKillturnFromDeathYear( killturn,
context.currentYear,
context.currentMonth,
deathYear,
context.rng
),
npcType: NPC_TYPE, npcType: NPC_TYPE,
crewTypeId: this.env.defaultCrewTypeId, 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, 'picture', candidate.picture ?? null);
addMetaValue(meta, 'birthYear', birthYear); addMetaValue(meta, 'birthYear', birthYear);
addMetaValue(meta, 'deathYear', deathYear);
addMetaValue(meta, 'specAge', DEFAULT_SPEC_AGE); addMetaValue(meta, 'specAge', DEFAULT_SPEC_AGE);
addMetaValue(meta, 'specAge2', 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); addMetaValue(meta, 'text', candidate.text ?? null);
const newGeneral = buildRecruitmentGeneral<TriggerState>({ const newGeneral = {
id: newGeneralId, ...buildRecruitmentGeneral<TriggerState>({
name, id: newGeneralId,
nationId: context.general.nationId, name,
cityId: context.general.cityId, nationId: context.general.nationId,
stats, cityId: context.general.cityId,
officerLevel: 1, stats,
age: baseAge, officerLevel: 1,
npcState: NPC_TYPE, age: baseAge,
gold: this.env.defaultNpcGold, npcState: NPC_TYPE,
rice: this.env.defaultNpcRice, gold: this.env.defaultNpcGold,
experience: context.nationAverageExperience ?? 0, rice: this.env.defaultNpcRice,
dedication: context.nationAverageDedication ?? 0, experience: context.nationAverageExperience ?? 0,
crewTypeId: this.env.defaultCrewTypeId, dedication: context.nationAverageDedication ?? 0,
role: { crewTypeId: this.env.defaultCrewTypeId,
personality: candidate.personality ?? null, role: {
specialDomestic: this.env.defaultSpecialDomestic, personality,
specialWar: this.env.defaultSpecialWar, specialDomestic: this.env.defaultSpecialDomestic,
}, specialWar: this.env.defaultSpecialWar,
meta, },
}); meta,
}),
turnTime,
};
effects.push(createGeneralAddEffect(newGeneral)); effects.push(createGeneralAddEffect(newGeneral));
} }
@@ -396,7 +486,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
nationAverageStats: nationSummary.averageStats, nationAverageStats: nationSummary.averageStats,
nationAverageExperience: nationSummary.averageExperience, nationAverageExperience: nationSummary.averageExperience,
nationAverageDedication: nationSummary.averageDedication, nationAverageDedication: nationSummary.averageDedication,
nationAverageDex: nationSummary.averageDex,
createGeneralId: options.createGeneralId, createGeneralId: options.createGeneralId,
turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)),
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
}; };
}; };
@@ -37,7 +37,6 @@ export interface CounterStrategyResolveContext<
} }
const ACTION_NAME = '피장파장'; const ACTION_NAME = '피장파장';
const DEFAULT_GLOBAL_DELAY = 8;
const TARGET_DELAY = 60; const TARGET_DELAY = 60;
const PRE_REQ_TURN = 1; const PRE_REQ_TURN = 1;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
@@ -201,10 +200,8 @@ export class ActionResolver<
} }
if (nation) { if (nation) {
const globalDelay = DEFAULT_GLOBAL_DELAY;
nation.meta = { nation.meta = {
...(nation.meta as object), ...(nation.meta as object),
strategic_cmd_limit: globalDelay,
[`next_execute_${targetCommandName}`]: currentYearMonth + this.getTargetPostReqTurn(context), [`next_execute_${targetCommandName}`]: currentYearMonth + this.getTargetPostReqTurn(context),
}; };
effects.push( effects.push(
@@ -339,7 +339,7 @@ describe('Nation Missing Actions', () => {
); );
expect(resolution.general.experience).toBeGreaterThan(100); 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.nation?.meta?.next_execute_허보).toBe(227);
expect(resolution.patches?.nations).toContainEqual({ expect(resolution.patches?.nations).toContainEqual({
id: destNation.id, id: destNation.id,
@@ -311,7 +311,7 @@ const buildWorldInput = (
})); }));
const snapshot: TurnWorldSnapshot = { const snapshot: TurnWorldSnapshot = {
scenarioConfig: { 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: '', iconPath: '',
map: {}, map: {},
const: { const: {
@@ -586,7 +586,10 @@ export const runCoreTurnCommandTrace = async (
const worldInput = buildWorldInput(request, referenceBefore, unitSet, map); const worldInput = buildWorldInput(request, referenceBefore, unitSet, map);
const { state, snapshot } = worldInput; const { state, snapshot } = worldInput;
const selector = { 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'))), 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'))),
}; };
@@ -202,7 +202,7 @@ const buildRequest = (
], ],
}, },
observe: { observe: {
generalIds: [1, 2, 3], generalIds: [1, 2, 3, 4, 5, 6],
cityIds: [3, 70], cityIds: [3, 70],
nationIds: [1, 2], nationIds: [1, 2],
logAfterId: 0, logAfterId: 0,
@@ -364,6 +364,61 @@ const cases: NationMatrixCase[] = [
cities: { 70: { nationId: 1, supplyState: 1 } }, 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', () => { integration('nation command success matrix', () => {