fix general domestic command parity

This commit is contained in:
2026-07-26 15:36:04 +00:00
parent c003f9e112
commit eed4017c2c
2 changed files with 207 additions and 3 deletions
@@ -53,6 +53,9 @@ const readTech = (nation: Nation): number => {
return typeof tech === 'number' && Number.isFinite(tech) ? tech : 0; return typeof tech === 'number' && Number.isFinite(tech) ? tech : 0;
}; };
// 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다.
const toLegacyStoredTech = (value: number): number => Number(value.toPrecision(6));
export class ActionDefinition< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, TechResearchArgs, TechResearchContext<TriggerState>> { > implements GeneralActionDefinition<TriggerState, TechResearchArgs, TechResearchContext<TriggerState>> {
@@ -105,7 +108,9 @@ export class ActionDefinition<
context.nation.meta = { context.nation.meta = {
...context.nation.meta, ...context.nation.meta,
tech: currentTech + techScore / Math.max(context.nationGeneralCount, this.env.initialNationGenLimit), tech: toLegacyStoredTech(
currentTech + techScore / Math.max(context.nationGeneralCount, this.env.initialNationGenLimit)
),
}; };
context.general.gold = Math.max(0, context.general.gold - result.costGold); context.general.gold = Math.max(0, context.general.gold - result.costGold);
context.general.experience += result.exp; context.general.experience += result.exp;
@@ -144,8 +149,10 @@ export const actionContextBuilder: ActionContextBuilder = (
...base, ...base,
city: base.city, city: base.city,
nation: base.nation, nation: base.nation,
nationGeneralCount: options.worldRef.listGenerals().filter((general) => general.nationId === base.nation!.id) nationGeneralCount:
.length, typeof base.nation.meta.gennum === 'number' && Number.isFinite(base.nation.meta.gennum)
? base.nation.meta.gennum
: options.worldRef.listGenerals().filter((general) => general.nationId === base.nation!.id).length,
relYear: relYear:
typeof options.scenarioMeta?.startYear === 'number' typeof options.scenarioMeta?.startYear === 'number'
? options.world.currentYear - options.scenarioMeta.startYear ? options.world.currentYear - options.scenarioMeta.startYear
@@ -907,6 +907,203 @@ integration('general movement command boundary and log parity', () => {
); );
}); });
interface DomesticBoundaryCase {
name: string;
action: 'che_성벽보수' | 'che_수비강화' | 'che_치안강화' | 'che_기술연구' | 'che_정착장려' | 'che_물자조달';
actorPatch?: Record<string, unknown>;
fixturePatches?: FixturePatches;
completed: boolean;
}
const domesticBoundaryCases: DomesticBoundaryCase[] = [
{
name: 'wall repair rejects a neutral actor',
action: 'che_성벽보수',
actorPatch: { nationId: 0 },
completed: false,
},
{
name: 'wall repair rejects a wandering nation',
action: 'che_성벽보수',
fixturePatches: { nations: { 1: { level: 0 } } },
completed: false,
},
{
name: 'wall repair rejects a foreign-occupied city',
action: 'che_성벽보수',
fixturePatches: { cities: { 3: { nationId: 2 } } },
completed: false,
},
{
name: 'wall repair rejects an unsupplied city',
action: 'che_성벽보수',
fixturePatches: { cities: { 3: { supplyState: 0 } } },
completed: false,
},
{
name: 'wall repair rejects insufficient gold',
action: 'che_성벽보수',
actorPatch: { gold: 0 },
completed: false,
},
{
name: 'wall repair rejects a wall already at capacity',
action: 'che_성벽보수',
fixturePatches: { cities: { 3: { wall: 1000, wallMax: 1000 } } },
completed: false,
},
{
name: 'wall repair preserves the early-capital front debuff',
action: 'che_성벽보수',
fixturePatches: { cities: { 3: { wall: 0, wallMax: 10_000, frontState: 1 } } },
completed: true,
},
{
name: 'defence reinforcement rejects a defence already at capacity',
action: 'che_수비강화',
fixturePatches: { cities: { 3: { defence: 1000, defenceMax: 1000 } } },
completed: false,
},
{
name: 'defence reinforcement preserves the early-capital front debuff',
action: 'che_수비강화',
fixturePatches: { cities: { 3: { defence: 0, defenceMax: 10_000, frontState: 1 } } },
completed: true,
},
{
name: 'security reinforcement rejects security already at capacity',
action: 'che_치안강화',
fixturePatches: { cities: { 3: { security: 1000, securityMax: 1000 } } },
completed: false,
},
{
name: 'security reinforcement preserves the early-capital front rule',
action: 'che_치안강화',
fixturePatches: { cities: { 3: { security: 0, securityMax: 10_000, frontState: 1 } } },
completed: true,
},
{
name: 'tech research rejects a neutral actor',
action: 'che_기술연구',
actorPatch: { nationId: 0 },
completed: false,
},
{
name: 'tech research rejects a wandering nation',
action: 'che_기술연구',
fixturePatches: { nations: { 1: { level: 0 } } },
completed: false,
},
{
name: 'tech research rejects a foreign-occupied city',
action: 'che_기술연구',
fixturePatches: { cities: { 3: { nationId: 2 } } },
completed: false,
},
{
name: 'tech research rejects an unsupplied city',
action: 'che_기술연구',
fixturePatches: { cities: { 3: { supplyState: 0 } } },
completed: false,
},
{
name: 'tech research rejects insufficient gold',
action: 'che_기술연구',
actorPatch: { gold: 0 },
completed: false,
},
{
name: 'tech research uses stored nation general count at the level cap',
action: 'che_기술연구',
fixturePatches: { nations: { 1: { tech: 3000, generalCount: 11 } } },
completed: true,
},
{
name: 'settlement encouragement rejects insufficient rice',
action: 'che_정착장려',
actorPatch: { rice: 0 },
completed: false,
},
{
name: 'settlement encouragement rejects a city at population capacity',
action: 'che_정착장려',
fixturePatches: { cities: { 3: { population: 200_000, populationMax: 200_000 } } },
completed: false,
},
{
name: 'settlement encouragement clamps at population capacity',
action: 'che_정착장려',
fixturePatches: { cities: { 3: { population: 199_999, populationMax: 200_000 } } },
completed: true,
},
{
name: 'procurement rejects a neutral actor',
action: 'che_물자조달',
actorPatch: { nationId: 0 },
completed: false,
},
{
name: 'procurement rejects a wandering nation',
action: 'che_물자조달',
fixturePatches: { nations: { 1: { level: 0 } } },
completed: false,
},
{
name: 'procurement rejects a foreign-occupied city',
action: 'che_물자조달',
fixturePatches: { cities: { 3: { nationId: 2 } } },
completed: false,
},
{
name: 'procurement rejects an unsupplied city',
action: 'che_물자조달',
fixturePatches: { cities: { 3: { supplyState: 0 } } },
completed: false,
},
{
name: 'procurement succeeds without owned gold or rice',
action: 'che_물자조달',
actorPatch: { gold: 0, rice: 0 },
completed: true,
},
];
integration('general domestic command boundary, value, and log parity', () => {
it.each(domesticBoundaryCases)(
'$name',
async ({ name, action, actorPatch, fixturePatches, completed }) => {
const request = buildRequest(action, undefined, actorPatch, fixturePatches);
request.setup!.world!.hiddenSeed = `general-domestic-${name}`;
request.observe!.includeGlobalHistoryLogs = true;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: completed ? action : '휴식',
usedFallback: !completed,
});
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', () => { integration('명장일람 rank_data command parity', () => {
it('화계 increments firenum from the same seeded value as legacy', async () => { it('화계 increments firenum from the same seeded value as legacy', async () => {
const request = buildRequest( const request = buildRequest(